/**
 * engine/github.js — GitHub integration for Minions engine.
 * Parallel to ado.js: PR status polling, comment polling, reconciliation for GitHub-hosted projects.
 * Uses `gh` CLI for all GitHub API calls.
 */

const shared = require('./shared');
const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeJsonArr, safeWrite, mutateJsonFileLocked, mutatePullRequests, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, ENGINE_DEFAULTS, createThrottleTracker, getProjectOrg } = shared;
const { getPrs } = require('./queries');
const { isPreviewStatusBody, hasMinionsMarker, hasVerdictPrefix } = require('./comment-classifier');
const { wrapUntrusted, buildSource } = require('./untrusted-fence');
const ghToken = require('./gh-token');
const path = require('path');

// Lazy require to avoid circular dependency — only needed for engine().handlePostMerge
let _engine = null;
function engine() {
  if (!_engine) _engine = require('../engine');
  return _engine;
}

// Lazy require for dispatch module (avoids circular dependency via engine)
let _dispatch = null;
function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }

// Lazy require for lifecycle module — only needed for the stale-noopfix sweep
// in pollPrStatus (W-mpx44p05000ze8d8). lifecycle.js lazy-requires github.js
// back, so this MUST stay lazy to avoid a circular import at module-load time.
let _lifecycle = null;
function lifecycleModule() { if (!_lifecycle) _lifecycle = require('./lifecycle'); return _lifecycle; }

// W-mp5trwh60008386d: test seam so unit tests can mock `gh api` shell-outs
// without spawning the real CLI. Production code goes through the real
// `execAsync` from shared; tests call `_setExecAsyncForTest(fn)` to swap in
// a stub. Pass `null` to restore. Mirrors the `_setAdoTokenForTest` pattern
// in engine/ado.js.
let _execAsyncOverride = null;
function _runExec(cmd, opts) {
  return (_execAsyncOverride || execAsync)(cmd, opts);
}
function _setExecAsyncForTest(fn) {
  _execAsyncOverride = (typeof fn === 'function') ? fn : null;
}

// P-f2-gh-shell (F2): parallel test seam for argv-form (shellSafeGh) shell-outs.
// The legacy `_runExec` seam intercepts shell-string `gh api ...` invocations,
// but the F2 conversion routes ghApi / build-log fetch / dismiss-review through
// shared.shellSafeGh (execFile, shell:false). New tests can mock at the argv
// level via `_setShellSafeGhForTest`; pre-existing tests that mock the shell-string
// layer keep working because `_runShellSafeGh` falls back to the legacy override
// by synthesizing a cmd string from argv (quoting any non-trivial argument so
// existing `/"repos\/owner\/repo"$/`-style route patterns still match).
let _shellSafeGhOverride = null;
function _runShellSafeGh(args, opts) {
  if (_shellSafeGhOverride) return _shellSafeGhOverride(args, opts);
  if (_execAsyncOverride) {
    const cmd = ['gh', ...args]
      .map(a => /^[A-Za-z0-9_=.-]+$/.test(String(a)) ? String(a) : `"${String(a)}"`)
      .join(' ');
    return _execAsyncOverride(cmd, opts);
  }
  return shared.shellSafeGh(args, opts);
}
function _setShellSafeGhForTest(fn) {
  _shellSafeGhOverride = (typeof fn === 'function') ? fn : null;
}

// P-f1ghstale: test seam to mock ghApi at the call-site level. ghApi itself
// always catches (returns null on failure, GH_NOT_FOUND on 404), so the inner
// `catch` in checkLiveBuildAndConflict cannot be reached by mocking the shell
// layer alone. This seam lets tests inject a throwing/resolving override that
// exercises the buildStatusStale branch (parity with ADO's adoFetch testability
// via globalThis.fetch).
let _ghApiOverrideForTest = null;
function _setGhApiForTest(fn) {
  _ghApiOverrideForTest = (typeof fn === 'function') ? fn : null;
}
async function _ghApiViaTestSeam(endpoint, slug, opts) {
  if (_ghApiOverrideForTest) return _ghApiOverrideForTest(endpoint, slug, opts);
  return ghApi(endpoint, slug, opts);
}

// ─── Constants ──────────────────────────────────────────────────────────────

// 10 MB — prevents maxBuffer exceeded errors on repos with many open PRs.
// Node.js default is 1 MB which overflows when GitHub API returns 100+ PR objects.
const GH_MAX_BUFFER = 10 * 1024 * 1024;
// ─── Helpers ────────────────────────────────────────────────────────────────

function isGitHub(project) {
  return project?.repoHost === 'github';
}

/** Get GitHub owner/repo slug from project config (e.g. "x3-design/Bebop_Workspaces") */
function getRepoSlug(project) {
  const org = getProjectOrg(project);
  const repo = project.repoName || '';
  if (!org || !repo) return null;
  return `${org}/${repo}`;
}

function getConfiguredGitHubAuthorLogins(config = {}) {
  const accounts = config?.engine?.ghAccounts;
  if (!accounts || typeof accounts !== 'object') return new Set();
  const logins = new Set();
  for (const value of Object.values(accounts)) {
    const values = Array.isArray(value) ? value : [value];
    for (const login of values) {
      const normalized = String(login || '').trim().toLowerCase();
      if (normalized) logins.add(normalized);
    }
  }
  return logins;
}

// P-f23classifier (F4): delegates to the host-agnostic VERDICT prefix check
// in engine/comment-classifier.js. The literal regex used to live here;
// extraction allowed F4 (Wave 3) to tighten the prefix in a single place.
// The tightened form requires `VERDICT: APPROVE|REQUEST_CHANGES` at line
// start (under /m), optionally wrapped in `**`, `__`, or `#+ ` markers —
// see engine/comment-classifier.js for the full contract.
function _hasMinionsReviewVerdict(body) {
  return hasVerdictPrefix(body);
}

function _isAgentComment(c) {
  const body = c.body || '';
  if (_hasMinionsReviewVerdict(body)) return true;
  if (/\bMinions\s*\(/i.test(body)) return true;
  if (/\bby\s+Minions\b/i.test(body)) return true;
  if (/\[minions\]/i.test(body)) return true;
  if (/\bMinions(?:\s+agent)?\s+triage\b/i.test(body)) return true;
  return false;
}

function _isGitHubBotComment(c) {
  const login = String(c?.user?.login || '');
  const type = String(c?.user?.type || '');
  return type.toLowerCase() === 'bot' || /\[bot\]$/i.test(login);
}

// P-f23classifier (F2): retains the GH-side bot-author gate (ADO has no
// `user.type === 'bot'` equivalent) but delegates the body check to the
// host-agnostic isPreviewStatusBody helper in engine/comment-classifier.js.
// The body matcher covers Coverage/Build/Test/Deploy/Pipeline report
// headings + Firebase App Distribution + Appetize + Deploy-ready phrases +
// shields.io badges (same set as before the extraction).
function _isPreviewStatusComment(c) {
  if (!_isGitHubBotComment(c)) return false;
  return isPreviewStatusBody(c?.body);
}

function _isNonActionableComment(c, config = {}) {
  const ignoredAuthors = new Set((config.engine?.ignoredCommentAuthors || []).map(a => String(a).toLowerCase()));
  const login = String(c?.user?.login || '').toLowerCase();
  if (ignoredAuthors.has(login)) return true;
  if (_isAgentComment(c)) return true;
  // P-f23classifier (F2): body-only preview/CI-report check is now host-
  // agnostic. The legacy `_isCiReportCommentBody` body match (Coverage /
  // Build / Test / Deploy / Pipeline headings + shields.io badges) is the
  // strict subset of isPreviewStatusBody, so a single call covers both
  // pre-existing branches. Bot-author gated preview matches still go
  // through _isPreviewStatusComment for callers that want the narrower
  // bot-only signal exported for testing.
  if (isPreviewStatusBody(c?.body)) return true;
  if (_isMinionsAuthoredComment(c)) return true;
  return false;
}

// W-mp3bp0ha000997ab-b — Structural classifier for Minions-authored PR comments.
//
// Replaces the body-shape classifier chain (PR #2431's _isAgentSelfReviewDeclinedComment,
// PR #2442's _isAgentPositiveSignalComment + _hasAgentPositiveSignalBody) with a single
// structural check: a Minions-authored comment carries `MINIONS_COMMENT_MARKER` (defined
// in engine/gh-comment.js) on its own first line AND was authored by the viewer (the
// shared PAT identity used by all minions agents).
//
// Both gates are required:
//   - `viewerDidAuthor: true` prevents humans from spoofing the marker by quoting it.
//   - `MINIONS_COMMENT_MARKER_RE` enforces "own first line" — quoted markers in the
//     middle of a comment, or markers with trailing content on the same line, do NOT
//     match. This is the structural complement to the viewer check.
//
// Production note: the GitHub REST endpoints we poll (/issues/:n/comments and
// /pulls/:n/comments) do NOT populate `viewerDidAuthor` — that field is GraphQL-
// only. `pollPrHumanComments` therefore backfills it via `_backfillViewerDidAuthor`
// using the viewer login resolved by `_resolveViewerLogin` (one `gh api user` call
// per process). This keeps the classifier alive in production without forcing a
// GraphQL migration on every fetch site.
//
// Sub-item -a populates the marker on every minions-posted comment via a helper.
// Sub-item -c migrates existing engine call sites + playbook examples.
//
// P-f23classifier (F3): the body-only "structural marker on own first line"
// check is now hasMinionsMarker in engine/comment-classifier.js. GH retains
// the viewerDidAuthor gate as the spoof guard (ADO uses a uniqueName cross-
// check against config.engine.minionsAdoIdentities instead — see ado.js).
function _isMinionsAuthoredComment(c) {
  if (!c || c.viewerDidAuthor !== true) return false;
  return hasMinionsMarker(c.body || '');
}

// ─── Viewer Login Resolution ────────────────────────────────────────────────
// `_isMinionsAuthoredComment` (above) gates on `c.viewerDidAuthor === true`.
// That field is populated by GitHub's GraphQL API but NOT by the REST
// `/issues/:n/comments` and `/pulls/:n/comments` endpoints we use in
// `pollPrHumanComments` (REST returns `viewerDidAuthor: null`). To make the
// classifier alive in production without changing every fetch site to
// GraphQL, we backfill the field by comparing the comment author's login to
// the viewer login resolved via `gh api user`.
//
// W-mp76pw7a001da7c1 — The viewer login is no longer process-wide because
// `ghApi` now resolves a per-slug token via `engine/gh-token.js`. Different
// accounts return different logins from `gh api user`, so the cache is keyed
// by the resolved account name (or the sentinel `__ambient__` when no
// account mapping applies and the call falls back to the ambient gh
// identity). One shell-out per distinct account per process is still cheap.
const _viewerLoginByAccount = new Map();
const _AMBIENT_ACCOUNT = '__ambient__';

function _accountKeyForSlug(slug) {
  return ghToken.resolveAccountForSlug(slug) || _AMBIENT_ACCOUNT;
}

async function _resolveViewerLogin(slug) {
  const account = _accountKeyForSlug(slug);
  if (_viewerLoginByAccount.has(account)) return _viewerLoginByAccount.get(account);
  try {
    const token = ghToken.resolveTokenForSlug(slug);
    const env = token ? { ...process.env, GH_TOKEN: token } : process.env;
    const result = await _runExec('gh api user', { timeout: 10000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER, env });
    const parsed = JSON.parse(String(result || ''));
    const login = parsed?.login ? String(parsed.login).toLowerCase() : null;
    if (login) _viewerLoginByAccount.set(account, login);
    return login;
  } catch (e) {
    log('warn', `GitHub viewer-login resolution failed for ${slug || '<no-slug>'}: ${e?.message || e}`);
    return null;
  }
}

// Test hook: lets tests prime or clear a cached viewer login per account
// without shelling out. Pass `(account, null)` to force a re-resolve.
// W-mp76pw7a001da7c1: signature changed from `(login)` → `(account, login)`
// because the cache is now keyed by the resolved gh account name.
function _setCachedViewerLogin(account, login) {
  const key = account ? String(account) : _AMBIENT_ACCOUNT;
  if (login == null) _viewerLoginByAccount.delete(key);
  else _viewerLoginByAccount.set(key, String(login).toLowerCase());
}

// Backfill `c.viewerDidAuthor` for an array of REST-fetched comments using
// the resolved viewer login. Only sets the field when REST returned
// null/undefined — never overrides a value that GraphQL or the test fixture
// already supplied. Mutates the comment objects in place.
function _backfillViewerDidAuthor(comments, viewerLogin) {
  if (!Array.isArray(comments) || !viewerLogin) return;
  for (const c of comments) {
    if (!c || typeof c !== 'object') continue;
    if (c.viewerDidAuthor === true || c.viewerDidAuthor === false) continue;
    const login = c.user?.login;
    if (typeof login !== 'string' || !login) continue;
    c.viewerDidAuthor = login.toLowerCase() === viewerLogin;
  }
}

// ─── Per-Repo Poll Backoff ──────────────────────────────────────────────────
// Tracks consecutive poll failures per repo slug to avoid spamming logs when
// a repo is inaccessible. Backoff doubles each failure: 2min, 4min, 8min, 16min, max 30min.
const _ghPollBackoff = new Map(); // slug → { failures, backoffUntil }
const GH_POLL_BACKOFF_BASE_MS = 2 * 60 * 1000; // 2 minutes (one poll cycle)
const GH_POLL_BACKOFF_MAX_MS = 30 * 60 * 1000;  // 30 minutes cap

/** Check if a repo slug is currently in backoff. Returns true if should skip. */
function isSlugInBackoff(slug) {
  const entry = _ghPollBackoff.get(slug);
  if (!entry) return false;
  return Date.now() < entry.backoffUntil;
}

/** Record a poll failure for a repo slug, applying exponential backoff. */
function recordSlugFailure(slug) {
  const existing = _ghPollBackoff.get(slug);
  const failures = (existing?.failures || 0) + 1;
  const backoffMs = Math.min(GH_POLL_BACKOFF_BASE_MS * Math.pow(2, failures - 1), GH_POLL_BACKOFF_MAX_MS);
  _ghPollBackoff.set(slug, { failures, backoffUntil: Date.now() + backoffMs });
  if (failures === 1) {
    // First failure is genuinely actionable — surface it.
    log('warn', `GitHub poll: repo ${slug} failed — will retry in ${Math.round(backoffMs / 1000)}s`);
  } else {
    // Subsequent escalations are deterministic backoff math — info, not warn.
    // We already warned on the first failure; spamming the same operator
    // with 12 escalation lines per outage adds no signal.
    log('info', `GitHub poll: repo ${slug} failed ${failures} times — backoff ${Math.round(backoffMs / 1000)}s`);
  }
}

/** Reset backoff for a repo slug after a successful poll. */
function resetSlugBackoff(slug) {
  if (_ghPollBackoff.has(slug)) {
    const entry = _ghPollBackoff.get(slug);
    if (entry.failures > 0) {
      log('info', `GitHub poll: repo ${slug} recovered after ${entry.failures} failure(s)`);
    }
    _ghPollBackoff.delete(slug);
  }
}

// ─── GitHub Rate-Limit Throttle ────────────────────────────────────────────
// Tracks rate-limiting from GitHub API (gh CLI exits non-zero with rate-limit messages).
// GitHub rate limits reset hourly, so cap at 60 min.
// jitterRatio: 0.2 — apply ±20% random jitter to backoff to avoid thundering herd
// when many concurrent gh calls race the same 1-hr reset window. See sub-item
// W-mq03l6zh0006f0a1-a for the createThrottleTracker jitter math.
const _ghThrottle = createThrottleTracker({ label: 'gh', baseBackoffMs: 60000, maxBackoffMs: 60 * 60000, jitterRatio: 0.2 });

/** Returns true if GitHub is rate-limited and retryAfter hasn't elapsed. */
const isGhThrottled = () => _ghThrottle.isThrottled();

/** Returns a snapshot of the current GitHub throttle state. */
const getGhThrottleState = () => _ghThrottle.getState();

/** Sentinel returned by ghApi when the resource does not exist (HTTP 404). */
const GH_NOT_FOUND = Object.freeze({ _notFound: true });

/** Run a `gh api` call and parse JSON result. Returns null on failure, GH_NOT_FOUND on 404.
 * @param {string} endpoint - API endpoint (e.g. '/pulls?state=open')
 * @param {string} slug - owner/repo slug
 * @param {object} [opts] - Options: { paginate: boolean, timeout: number }
 */
async function ghApi(endpoint, slug, opts = {}) {
  try {
    // P-f2-gh-shell (F2): argv form via shellSafeGh — eliminates shell
    // interpolation of slug/endpoint. validateGhSlug + validateGhEndpoint
    // reject shell metacharacters before they reach child_process.
    const args = ['api'];
    if (opts.paginate) args.push('--paginate');
    args.push(`repos/${shared.validateGhSlug(slug)}${shared.validateGhEndpoint(endpoint)}`);
    const token = ghToken.resolveTokenForSlug(slug);
    const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
    const result = await _runShellSafeGh(args, { timeout: opts.timeout || 30000, maxBuffer: GH_MAX_BUFFER, env });
    const parsed = JSON.parse(result);
    _ghThrottle.recordSuccess();
    return parsed;
  } catch (e) {
    const msg = e?.message || e?.stderr || '';
    // Detect GitHub rate-limit errors from gh CLI output
    if (/rate.?limit|secondary rate|429/i.test(msg)) {
      // Try to extract retry seconds from "N seconds" pattern in error message
      const secMatch = msg.match(/(\d+)\s*seconds?/i);
      const retryAfterMs = secMatch ? parseInt(secMatch[1], 10) * 1000 : 0;
      _ghThrottle.recordThrottle(retryAfterMs);
    }
    if (/HTTP 404|Not Found/i.test(msg)) {
      log('warn', `GitHub API error (${endpoint}): ${e.message}`);
      return GH_NOT_FOUND;
    }
    log('warn', `GitHub API error (${endpoint}): ${e.message}`);
    return null;
  }
}

/**
 * Run a `gh api` call with per-slug backoff tracking. Returns null on failure.
 * On success, resets the slug's backoff. On failure, increments it.
 */
async function ghApiWithBackoff(endpoint, slug) {
  const result = await ghApi(endpoint, slug);
  if (result === null) {
    recordSlugFailure(slug);
  } else {
    resetSlugBackoff(slug);
  }
  return result;
}

const BUILD_ERROR_LOG_MAX_LINES = 150;

/**
 * Fetch actual build/compiler error logs from GitHub when a check run fails.
 * Tries annotations first (structured error messages), then falls back to the
 * Actions job log. Returns truncated log text or null if unavailable.
 */
async function fetchGhBuildErrorLog(slug, failedRuns) {
  try {
    const logParts = [];

    for (const run of (failedRuns || []).slice(0, 3)) {
      if (!run?.id) continue;

      // Try annotations for structured compiler/lint errors
      let hasUsefulAnnotations = false;
      try {
        const annotations = await ghApi(`/check-runs/${run.id}/annotations`, slug);
        if (Array.isArray(annotations) && annotations.length > 0) {
          const failures = annotations.filter(a => a.annotation_level === 'failure');
          if (failures.length > 0) {
            const formatted = failures
              .map(a => `${a.path || ''}:${a.start_line || ''} [${a.annotation_level}] ${a.message || ''}`)
              .join('\n');
            logParts.push(`--- ${run.name || 'Check'} (annotations) ---\n${formatted}`);
            hasUsefulAnnotations = true;
          }
        }
      } catch { /* fall through to job log */ }

      // Always fetch job log — annotations alone often lack test failure details.
      // P-f2-gh-shell (F2): argv form via shellSafeGh. validateJobId rejects
      // non-integer run.id values before they reach child_process. Stderr is
      // captured via shellSafeGh's execFile path (stderr surfaces on the thrown
      // Error in the catch block) instead of a `2>&1` shell redirect, which is
      // moot once shell:false is in effect.
      try {
        const jobId = shared.validateJobId(run.id);
        const args = ['api', `repos/${shared.validateGhSlug(slug)}/actions/jobs/${jobId}/logs`];
        const token = ghToken.resolveTokenForSlug(slug);
        const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
        const result = await _runShellSafeGh(args, { timeout: FETCH_TIMEOUT_MS.GH_CLI, maxBuffer: GH_MAX_BUFFER, env });
        if (result && !result.includes('Not Found')) {
          logParts.push(`--- ${run.name || 'Check'} (log) ---\n${result}`);
        }
      } catch { /* skip individual log fetch failures */ }
    }

    if (logParts.length === 0) return null;

    // Join and truncate to last N lines
    const combined = logParts.join('\n\n');
    const lines = combined.split('\n');
    if (lines.length > BUILD_ERROR_LOG_MAX_LINES) {
      return `... (truncated, showing last ${BUILD_ERROR_LOG_MAX_LINES} lines)\n` +
        lines.slice(-BUILD_ERROR_LOG_MAX_LINES).join('\n');
    }
    return combined;
  } catch (e) {
    log('warn', `Failed to fetch GitHub build error log: ${e.message}`);
    return null;
  }
}

// ─── Shared PR Polling Loop ─────────────────────────────────────────────────

async function forEachActiveGhPr(config, callback) {
  const projects = getProjects(config).filter(isGitHub);
  let totalUpdated = 0;

  for (const project of projects) {
    // W-mp625n27000m6e78 — Honor per-project workSources.pullRequests.enabled.
    // Default-ON: only skip when explicitly disabled (=== false). Missing/undefined
    // falls through to global workSources, then defaults to polling. Without this
    // gate, disabling PR polling via the dashboard Settings UI was cosmetic — the
    // engine still polled and could flip PRs to abandoned via the GH_NOT_FOUND
    // trapdoor in pollPrStatus.
    const src = project?.workSources?.pullRequests || config?.workSources?.pullRequests;
    if (src && src.enabled === false) continue;

    const slug = getRepoSlug(project);
    if (!slug) continue;

    // Skip projects in backoff (inaccessible repo)
    if (isSlugInBackoff(slug)) continue;

    const prs = getPrs(project);
    const activePrs = prs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status)
      && shared.isPrCompatibleWithProject(project, pr, pr.url || ''));
    if (activePrs.length === 0) continue;

    // Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo.
    // W-mp5trwh60008386d: ghApi returns the GH_NOT_FOUND sentinel on 404 (a frozen object,
    // *not* null). The pre-fix gate only matched `null`, so a 404 on the base repo (caused by
    // a multi-account `gh auth` switch, network blip, or token rotation) fell through and every
    // per-PR call below 404'd, permanently flipping all active PRs to `abandoned`. We now treat
    // both null and GH_NOT_FOUND as "skip the project for this tick" and explicitly do NOT
    // increment per-PR `_consecutive404s` counters since no per-PR call was made.
    const probe = await ghApi('', slug);
    if (probe === null || probe === GH_NOT_FOUND) {
      if (probe === GH_NOT_FOUND) {
        log('warn', `GitHub repo probe for ${slug} returned 404 — skipping all per-PR polls for this project this tick (avoids spurious abandonments). Per-PR 404 counters NOT incremented.`);
      }
      recordSlugFailure(slug);
      continue;
    }
    resetSlugBackoff(slug);

    let projectUpdated = 0;
    const updatedRecords = [];

    for (const pr of activePrs) {
      const prNum = shared.getPrNumber(pr);
      if (!prNum) continue;

      try {
        const before = shared.snapshotPrRecord(pr);
        const updated = await callback(project, pr, prNum, slug);
        if (updated) {
          projectUpdated++;
          updatedRecords.push({ before, after: shared.snapshotPrRecord(pr) });
        }
      } catch (err) {
        log('warn', `GitHub: failed to poll PR ${pr.id}: ${err.message}`);
      }
    }

    if (projectUpdated > 0) {
      mutateJsonFileLocked(projectPrPath(project), (currentPrs) => {
        // Merge back only fields changed by callbacks; preserve concurrent disk updates.
        for (const { before, after } of updatedRecords) {
          const idx = currentPrs.findIndex(p => p.id === after.id);
          if (idx >= 0) {
            // Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
            if (currentPrs[idx].reviewStatus === REVIEW_STATUS.APPROVED && after.reviewStatus !== REVIEW_STATUS.APPROVED) {
              after.reviewStatus = REVIEW_STATUS.APPROVED;
            }
            // W-mp5trwh60008386d: never downgrade `status: merged` — terminal state. A stale
            // 404 reaching `prAbandonConfirmCount` could otherwise overwrite a concurrent
            // success/merge poll. Keeps the central pull-requests.json status invariants
            // intact even under multi-writer races.
            if (currentPrs[idx].status === PR_STATUS.MERGED && after.status !== PR_STATUS.MERGED) {
              after.status = PR_STATUS.MERGED;
            }
            shared.applyPrFieldDelta(currentPrs[idx], before, after);
          }
        }
        // Remove duplicates — prefer merged/abandoned over active
        const bestById = new Map();
        const statusRank = { merged: 3, abandoned: 2, closed: 2, active: 1 };
        for (const p of currentPrs) {
          const existing = bestById.get(p.id);
          if (!existing || (statusRank[p.status] || 0) > (statusRank[existing.status] || 0)) {
            bestById.set(p.id, p);
          }
        }
        return [...bestById.values()];
      }, { defaultValue: [] });
      totalUpdated += projectUpdated;
    }
  }

  // Also poll manually-linked PRs from central pull-requests.json (extract slug from URL)
  const centralPath = path.join(MINIONS_DIR, 'pull-requests.json');
  const centralPrs = safeJsonArr(centralPath);
  const activeCentral = centralPrs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status) && pr.url);

  // W-mp5trwh60008386d: probe each unique slug in the central list ONCE before iterating PRs.
  // Without this gate, central PRs would inherit the same per-PR 404 trapdoor that project-local
  // PRs had pre-fix — a multi-account `gh auth` switch or token rotation would let every
  // central PR for an inaccessible slug accumulate `_consecutive404s` even though the failure
  // is at the auth/repo layer, not the PR. Probe results live for this tick only (Map cleared
  // each invocation).
  const centralSlugProbes = new Map(); // slug → 'ok' | 'fail'
  for (const pr of activeCentral) {
    const ghMatch = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
    if (!ghMatch) continue;
    const slug = ghMatch[1];
    if (centralSlugProbes.has(slug)) continue;
    if (isSlugInBackoff(slug)) { centralSlugProbes.set(slug, 'fail'); continue; }
    const probe = await ghApi('', slug);
    if (probe === null || probe === GH_NOT_FOUND) {
      if (probe === GH_NOT_FOUND) {
        log('warn', `GitHub repo probe for ${slug} returned 404 (central PR poll) — skipping all central PRs for this slug this tick. Per-PR 404 counters NOT incremented.`);
      }
      recordSlugFailure(slug);
      centralSlugProbes.set(slug, 'fail');
    } else {
      resetSlugBackoff(slug);
      centralSlugProbes.set(slug, 'ok');
    }
  }

  let centralUpdated = 0;
  const updatedCentralRecords = [];
  for (const pr of activeCentral) {
    const ghMatch = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
    if (!ghMatch) continue;
    const slug = ghMatch[1];
    if (centralSlugProbes.get(slug) !== 'ok') continue; // probe failed → skip per-PR call
    const prNum = ghMatch[2];
    try {
      const before = shared.snapshotPrRecord(pr);
      const updated = await callback(null, pr, prNum, slug);
      if (updated) {
        // Also update title/author/branch if still placeholder
        const currentTitle = pr.title || '';
        if (!currentTitle || currentTitle.includes('polling...') || pr.agent === 'human' || pr.description === undefined) {
          const prData = await ghApi(`/pulls/${prNum}`, slug);
          if (prData) {
            const latestTitle = pr.title || '';
            if (!latestTitle || latestTitle.includes('polling...') || /[{}"\[\]]/.test(latestTitle) || /^[0-9a-f-]{8,}$/i.test(latestTitle)) {
              pr.title = (prData.title || latestTitle).slice(0, 120);
            }
            if (pr.description === undefined) pr.description = (prData.body || '').slice(0, 500);
            if (pr.agent === 'human' && prData.user?.login) pr.agent = prData.user.login;
            if (!pr.branch && prData.head?.ref) {
              // P-a7c4d2e8 (F3): validate API-derived branch ref before
              // persistence. Defensive degrade — log and skip on invalid ref
              // rather than crash the poller.
              try {
                pr.branch = shared.validateGitRef(prData.head.ref);
                if (pr._branchResolutionError) delete pr._branchResolutionError;
                if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
              } catch (refErr) {
                log('warn', `GitHub: invalid head.ref for PR ${pr.id}, skipping branch persistence: ${refErr.message}`);
              }
            }
          }
        }
        centralUpdated++;
        updatedCentralRecords.push({ before, after: shared.snapshotPrRecord(pr) });
      }
    } catch (err) {
      log('warn', `GitHub: failed to poll central PR ${pr.id}: ${err.message}`);
    }
  }
  if (centralUpdated > 0) {
    mutateJsonFileLocked(centralPath, (currentPrs) => {
      // Only merge back central PRs that the callback actually modified
      for (const { before, after } of updatedCentralRecords) {
        const idx = currentPrs.findIndex(p => p.id === after.id);
        if (idx >= 0) {
          // W-mp5trwh60008386d: same merged-status guard as project-local PRs.
          if (currentPrs[idx].status === PR_STATUS.MERGED && after.status !== PR_STATUS.MERGED) {
            after.status = PR_STATUS.MERGED;
          }
          shared.applyPrFieldDelta(currentPrs[idx], before, after);
        }
      }
      return currentPrs;
    }, { defaultValue: [] });
    totalUpdated += centralUpdated;
  }

  return totalUpdated;
}

// ─── PR Status Polling ──────────────────────────────────────────────────────

async function pollPrStatus(config) {
  // W-mp5trwh60008386d: per-PR 404 must be confirmed N times before flipping to abandoned.
  // Single 404s are routinely transient (multi-account `gh auth` race, network blip, token
  // rotation). Counter is per-PR (`pr._consecutive404s`) and reset on any successful response.
  // Default N from ENGINE_DEFAULTS; opt-in override via config.engine.prAbandonConfirmCount.
  const confirmCount = Math.max(1, Number(config?.engine?.prAbandonConfirmCount) || ENGINE_DEFAULTS.prAbandonConfirmCount);

  const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
    const prData = await ghApi(`/pulls/${prNum}`, slug);
    if (!prData) return false;
    if (prData === GH_NOT_FOUND) {
      // Per-PR 404. Base-repo probe in forEachActiveGhPr already passed for this tick, so the
      // 404 is specific to this PR — but we still require N consecutive confirmations across
      // separate poll ticks before flipping. Worst-case delay for a genuinely deleted PR is
      // N × prPollStatusEvery × tickInterval (~36 min at defaults), which is acceptable for an
      // irreversible status change.
      const next = (Number(pr._consecutive404s) || 0) + 1;
      if (next >= confirmCount) {
        if (pr.status !== PR_STATUS.ABANDONED) {
          pr.status = PR_STATUS.ABANDONED;
          delete pr._consecutive404s;
          log('info', `PR ${pr.id} returned 404 ${next} consecutive polls — marking abandoned`);
          return true;
        }
        // Already abandoned; clear the counter if it lingered.
        if (pr._consecutive404s) {
          delete pr._consecutive404s;
          return true;
        }
        return false;
      }
      pr._consecutive404s = next;
      log('warn', `PR ${pr.id} returned 404 (${next}/${confirmCount}) — deferring abandonment until threshold`);
      return true; // counter increment is a state change worth persisting
    }

    let updated = false;

    // Successful response — clear any pending 404 confirmation streak.
    if (pr._consecutive404s) {
      delete pr._consecutive404s;
      updated = true;
    }

    const headBranch = prData.head?.ref ? String(prData.head.ref).trim() : '';
    if (headBranch && pr.branch !== headBranch) {
      // P-a7c4d2e8 (F3): validate API-derived branch ref before persistence.
      try {
        const validated = shared.validateGitRef(headBranch);
        pr.branch = validated;
        if (pr._branchResolutionError) delete pr._branchResolutionError;
        if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
        updated = true;
      } catch (refErr) {
        log('warn', `GitHub: invalid head.ref "${headBranch.slice(0, 64)}" for PR ${pr.id}, skipping branch persistence: ${refErr.message}`);
      }
    }

    // W-mphm0kt0000cebc3 (Bug B): backfill title/description/agent for
    // project-local PRs that are still on the link-time placeholder. The
    // central poller (above, ~lines 561-583) does this already; without
    // parity here a manually-linked project-local PR whose initial
    // enrichment IIFE failed (cross-account auth, transient blip) would
    // stay stuck on "PR #N (polling...)" forever. `prData` is already in
    // hand from line 622 — no extra API call needed.
    const currentTitleForBackfill = pr.title || '';
    if (!currentTitleForBackfill
        || currentTitleForBackfill.includes('polling...')
        || /[{}"\[\]]/.test(currentTitleForBackfill)
        || /^[0-9a-f-]{8,}$/i.test(currentTitleForBackfill)) {
      if (prData.title) {
        const nextTitle = String(prData.title).slice(0, 120);
        if (pr.title !== nextTitle) {
          pr.title = nextTitle;
          updated = true;
        }
      }
    }
    if (pr.description === undefined) {
      pr.description = (prData.body || '').slice(0, 500);
      updated = true;
    }
    if (pr.agent === 'human' && prData.user?.login) {
      pr.agent = prData.user.login;
      updated = true;
    }

    // Map GitHub PR state to minions status
    let newStatus = pr.status;
    if (prData.merged) newStatus = PR_STATUS.MERGED;
    else if (prData.state === 'closed') newStatus = PR_STATUS.ABANDONED;
    else if (prData.state === 'open') newStatus = PR_STATUS.ACTIVE;

    // W-mpej044m00076d63: backfill `pr.created` from the platform's real
    // creation timestamp. lifecycle.js attach paths deliberately omit
    // `created` so this poll is the single source of truth — and historical
    // records that pre-date the fix get backfilled the next time they're
    // polled. Idempotent: only writes when missing.
    if (!pr.created && prData.created_at) {
      pr.created = prData.created_at;
      updated = true;
    }

    // Track head SHA changes to detect new pushes (used for review re-dispatch gating)
    if (prData.head?.sha && pr.headSha !== prData.head.sha) {
      pr.headSha = prData.head.sha;
      pr.lastPushedAt = ts();
      updated = true;
    }
    if (prData.base?.sha && pr.baseSha !== prData.base.sha) {
      pr.baseSha = prData.base.sha;
      updated = true;
    }

    // #3079 — Track GitHub target branch name + clear stale merge-conflict
    // dispatch state when the PR is retargeted (e.g. `gh pr edit --base
    // master` after parent PR merges). Mirrors ado.js applyAdoPrMetadata.
    // First-poll seeding (no prior baseRefName) is a no-op so we don't
    // strip state immediately after upsert.
    const nextBaseRefName = String(prData.base?.ref || '').trim();
    if (nextBaseRefName && pr.baseRefName !== nextBaseRefName) {
      if (shared.resetMergeConflictStateOnRetarget(pr, nextBaseRefName)) {
        log('info', `GitHub: PR ${pr.id} retargeted ${pr.baseRefName} → ${nextBaseRefName}, clearing stale MERGE_CONFLICT dispatch records`);
      }
      pr.baseRefName = nextBaseRefName;
      updated = true;
    }

    // P-w1a3f9b2 — Phase 1.1: plumb mergeable / isDraft / mergeStateStatus /
    // headRefOid onto the PR object so watches captureState (engine/watches.js)
    // and future predicates (Phase 2.1: head-commit-change, mergeable-flipped,
    // ready-for-merge, draft-flipped) can read them. We mirror GitHub's
    // tri-state contract for `mergeable` (true|false|null — see
    // checkLiveBuildAndConflict comments above):
    //   - true  → GitHub computed: mergeable
    //   - false → GitHub computed: conflicts
    //   - null  → GitHub still computing — preserve null verbatim, do NOT
    //             substitute the cached value (predicates treat null as "no
    //             signal" and won't fire transitions involving null).
    // `headRefOid` is an alias of `headSha` for spec parity (GitHub's GraphQL
    // schema and many tools name it `headRefOid`); both stay in sync.
    if (prData.head?.sha && pr.headRefOid !== prData.head.sha) {
      pr.headRefOid = prData.head.sha;
      updated = true;
    }
    const newMergeable = prData.mergeable === true ? true
      : prData.mergeable === false ? false
      : null;
    if (pr.mergeable !== newMergeable) {
      pr.mergeable = newMergeable;
      updated = true;
    }
    const newIsDraft = prData.draft === true;
    if (pr.isDraft !== newIsDraft) {
      pr.isDraft = newIsDraft;
      updated = true;
    }
    const newMergeStateStatus = typeof prData.mergeable_state === 'string' && prData.mergeable_state
      ? prData.mergeable_state
      : null;
    if ((pr.mergeStateStatus || null) !== newMergeStateStatus) {
      pr.mergeStateStatus = newMergeStateStatus;
      updated = true;
    }

    // P-w3a7b9c4 — Phase 1.3: optional gated `behindBy` field via GitHub
    // /compare/{base}...{head} API. Each call costs one extra GitHub
    // request, so it's gated behind the opt-in
    // `config.engine.watchesIncludeBehindBy` flag. The natural per-tick
    // budget IS the prPollStatusEvery cadence (default ~12 ticks) — by
    // living inside pollPrStatus this fetch inherits that gate without
    // needing a separate counter. When skipped (flag off, PR closed,
    // missing refs, cross-repo fork, or compare-API error), behindBy is
    // explicitly set to null so the `behind-master` predicate
    // (engine/watches.js BEHIND_MASTER) treats it as "no signal" and does
    // not over-fire on legacy / stale state. ADO PRs are handled in
    // engine/ado.js with the same null contract.
    const includeBehindBy = config.engine?.watchesIncludeBehindBy === true;
    let newBehindBy = null;
    if (includeBehindBy && prData.state === 'open' && prData.base?.ref && prData.head?.sha) {
      // Same-repo PRs only — cross-repo (forked) compare needs `owner:branch`
      // syntax which we don't normalize here. Treat fork PRs as "no signal".
      const baseRepo = prData.base?.repo?.full_name || '';
      const headRepo = prData.head?.repo?.full_name || '';
      if (baseRepo && headRepo && baseRepo === headRepo) {
        const cmp = await ghApi(`/compare/${encodeURIComponent(prData.base.ref)}...${encodeURIComponent(prData.head.sha)}`, slug);
        if (cmp && cmp !== GH_NOT_FOUND && typeof cmp.behind_by === 'number') {
          newBehindBy = cmp.behind_by;
        }
      }
    }
    if ((pr.behindBy ?? null) !== newBehindBy) {
      pr.behindBy = newBehindBy;
      updated = true;
    }

    if (pr.status !== newStatus) {
      const oldStatus = pr.status;
      log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
      pr.status = newStatus;
      updated = true;

      // W-mpej044m00076d63: persist platform close/merge timestamps on the
      // normal-poll status flip (previously only the abandoned-reconciliation
      // path at ~line 1489 set mergedAt). Idempotency guard prevents a later
      // reconciliation pass from overwriting an earlier-known value.
      if (newStatus === PR_STATUS.MERGED && !pr.mergedAt) {
        pr.mergedAt = prData.merged_at || prData.closed_at || ts();
      }
      if ((newStatus === PR_STATUS.ABANDONED || newStatus === PR_STATUS.CLOSED) && !pr.closedAt) {
        pr.closedAt = prData.closed_at || ts();
      }

      if (newStatus === PR_STATUS.MERGED || newStatus === PR_STATUS.ABANDONED) {
        // Resolve stale 'waiting' review status — won't be polled again after this
        if (pr.reviewStatus === REVIEW_STATUS.WAITING) {
          pr.reviewStatus = newStatus === PR_STATUS.MERGED ? REVIEW_STATUS.APPROVED : REVIEW_STATUS.PENDING;
          log('info', `PR ${pr.id} reviewStatus: waiting → ${pr.reviewStatus} (${newStatus})`);
        }
        // Clear stale build status — checks won't be polled after close
        if (pr.buildStatus && pr.buildStatus !== BUILD_STATUS.NONE) {
          delete pr.buildStatus;
          delete pr.buildFailReason;
          delete pr.buildErrorLog;
          delete pr.buildFailureSignature;
          delete pr._buildFailNotified;
          delete pr.buildFixAttempts;
        }
        // F6 (P-f6commentedit): drop the comment-edit dedup map on merge/abandon
        // — no further re-dispatch path can fire from this PR, so keeping it
        // around would only bloat pull-requests.json.
        if (pr.humanFeedback && pr.humanFeedback.editsSeen) {
          delete pr.humanFeedback.editsSeen;
        }
        // Cancel any pending review/fix dispatches — they're stale now that the PR is closed
        try {
          dispatchModule().cancelPendingDispatchesForPr(pr.id);
        } catch (e) { log('warn', `Cancel dispatches for ${pr.id}: ${e.message}`); }
        await engine().handlePostMerge(pr, project, config, newStatus);
      }

      // P-f5reopenreset: reopen transient-state reset.
      // On reopen (abandoned→active), the prior review session is no longer
      // authoritative for the now-active PR — wipe transient state that
      // references it. Invariant #14 ('approved is permanent terminal') is
      // preserved: we keep reviewStatus when it was 'approved' rather than
      // carving a wasReopened exception into the 8+ guard sites in
      // engine/lifecycle.js, engine.js, engine/github.js, and engine/ado.js.
      // Re-review safety on new commits is handled by the pre-existing
      // pr.lastPushedAt > pr.lastReviewedAt path (engine.js alreadyReviewed
      // gate) plus the changes-requested override in lifecycle.js.
      if (oldStatus === PR_STATUS.ABANDONED && newStatus === PR_STATUS.ACTIVE) {
        pr.minionsReview = null;
        pr._lastDispatchByCause = {};
        pr.humanFeedback = { ...(pr.humanFeedback || {}), lastSeenAt: null, lastSeenCommentId: null };
        // F6 (P-f6commentedit): drop the comment-edit dedup map on reopen so a
        // re-edited comment after reopen still re-dispatches. Safe no-op when
        // F6 not yet shipped (field absent).
        delete pr.humanFeedback.editsSeen;
        pr.fixDispatched = false;
        if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = REVIEW_STATUS.PENDING;
        log('info', `PR ${pr.id} reopened — reset transient state (reviewStatus=${pr.reviewStatus})`);
      }
    }

    // Review status from GitHub reviews
    const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
    if (reviews && Array.isArray(reviews)) {
      // Get latest review per user
      const latestByUser = new Map();
      for (const r of reviews) {
        const user = r.user?.login || '';
        if (r.state === 'COMMENTED') continue; // Skip plain comments
        latestByUser.set(user, r.state);
      }
      const states = [...latestByUser.values()];

      // Store human reviewer names who approved or requested changes
      const reviewedBy = [...latestByUser.entries()]
        .filter(([, state]) => state === 'APPROVED' || state === 'CHANGES_REQUESTED')
        .map(([user]) => user)
        .filter(Boolean);
      // Fallback: if PR was merged and no decisive reviews, use merged_by
      if (!reviewedBy.length && prData.merged && prData.merged_by?.login) {
        reviewedBy.push(prData.merged_by.login);
      }
      if (JSON.stringify(pr.reviewedBy || []) !== JSON.stringify(reviewedBy)) {
        pr.reviewedBy = reviewedBy; updated = true;
      }

      let newReviewStatus = pr.reviewStatus || REVIEW_STATUS.PENDING;
      // Once approved, it stays approved permanently
      if (pr.reviewStatus === REVIEW_STATUS.APPROVED) {
        newReviewStatus = REVIEW_STATUS.APPROVED;
      } else if (states.some(s => s === 'CHANGES_REQUESTED')) {
        if (pr.reviewStatus === REVIEW_STATUS.WAITING && pr.minionsReview?.fixedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.minionsReview.fixedAt)) {
          newReviewStatus = REVIEW_STATUS.WAITING;
        } else {
          newReviewStatus = REVIEW_STATUS.CHANGES_REQUESTED;
        }
      }
      else if (states.some(s => s === 'APPROVED')) newReviewStatus = REVIEW_STATUS.APPROVED;
      else if (states.length > 0) newReviewStatus = REVIEW_STATUS.PENDING;
      else if (states.length === 0 && reviews.length > 0 && newReviewStatus === REVIEW_STATUS.PENDING) newReviewStatus = REVIEW_STATUS.WAITING;

      if (pr.reviewStatus !== newReviewStatus) {
        log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
        pr.reviewStatus = newReviewStatus;
        updated = true;
        shared.trackReviewMetric(pr, newReviewStatus, config);
      }
    }

    // Check status / checks
    if (prData.state === 'open' && prData.head?.sha) {
      const checksData = await ghApi(`/commits/${prData.head.sha}/check-runs`, slug);
      if (checksData && checksData.check_runs) {
        // Record actual poll time — makes lastBuildCheck reflect when the engine last
        // talked to GitHub, not when the agent was dispatched. Issue #1233.
        pr.lastBuildCheck = ts();
        updated = true;
        const runs = checksData.check_runs;
        let buildStatus = BUILD_STATUS.NONE;
        let buildFailReason = '';
        let buildFailureSignature = '';

        if (runs.length > 0) {
          const hasFailed = runs.some(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
          const allDone = runs.every(r => r.status === 'completed');
          const allPassed = runs.every(r => r.conclusion === 'success' || r.conclusion === 'skipped' || r.conclusion === 'neutral');

          if (hasFailed) {
            buildStatus = BUILD_STATUS.FAILING;
            const failed = runs.find(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
            buildFailReason = failed?.name || 'Check failed';
            buildFailureSignature = shared.safeSlugComponent([
              failed?.name,
              failed?.conclusion,
              failed?.output?.title,
              failed?.output?.summary,
              failed?.output?.text,
            ].filter(Boolean).join('\n') || buildFailReason, 80);
          } else if (allDone && allPassed) {
            buildStatus = BUILD_STATUS.PASSING;
          } else if (allDone) {
            // Terminal-but-not-passing conclusions (cancelled, action_required,
            // stale, etc.) — map to 'none' rather than 'failing'. These are not
            // actionable build failures, so we deliberately do not block merge or
            // dispatch build-fix agents. Previously fell through to 'running'
            // forever (P-bf02-github-cancelled-stuck).
            buildStatus = BUILD_STATUS.NONE;
          } else {
            buildStatus = BUILD_STATUS.RUNNING;
          }
        }

        if (pr.buildStatus !== buildStatus) {
          log('info', `PR ${pr.id} build: ${pr.buildStatus || BUILD_STATUS.NONE} → ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
          pr.buildStatus = buildStatus;
          if (buildFailReason) pr.buildFailReason = buildFailReason;
          else delete pr.buildFailReason;
          if (buildFailureSignature) pr.buildFailureSignature = buildFailureSignature;
          else delete pr.buildFailureSignature;
          // Build transitioned — clear grace period and auto-complete flag
          delete pr._buildFixPushedAt;
          if (buildStatus === BUILD_STATUS.FAILING) delete pr._autoCompleted; // allow re-merge after fix
          if (buildStatus !== BUILD_STATUS.FAILING) {
            delete pr._buildFailNotified;
            // Preserve buildErrorLog + buildFixAttempts through transient 'none'/'running'
            // transitions — only clear on confirmed 'passing' recovery. Issue #1232:
            // clearing on every non-failing transition blinded the next fix agent
            // while a queued build was still running.
            if (buildStatus === BUILD_STATUS.PASSING) {
              delete pr.buildErrorLog;
              delete pr.buildFailureSignature;
              // Reset build fix retry counter on recovery — allows fresh auto-fix cycles if build breaks again
              if (pr.buildFixAttempts) { delete pr.buildFixAttempts; }
            }
          }
          updated = true;
        }
        if (buildStatus === BUILD_STATUS.FAILING) {
          if (buildFailReason && pr.buildFailReason !== buildFailReason) {
            pr.buildFailReason = buildFailReason;
            updated = true;
          }
          if (buildFailureSignature && pr.buildFailureSignature !== buildFailureSignature) {
            pr.buildFailureSignature = buildFailureSignature;
            updated = true;
          }
        }
      }
    }

    // Merge conflict detection
    if (prData.state === 'open' && prData.mergeable === false) {
      if (!pr._mergeConflict) {
        pr._mergeConflict = true;
        log('info', `PR ${pr.id} has merge conflicts — will dispatch fix if not already in progress`);
        updated = true;
      }
    } else if (pr._mergeConflict) {
      delete pr._mergeConflict;
      delete pr._conflictFixedAt; // confirmed clean — allow re-dispatch if conflicts recur
      updated = true;
    }

    // Auto-complete: merge PR when builds green + review approved
    if (pr.status === PR_STATUS.ACTIVE && pr.reviewStatus === REVIEW_STATUS.APPROVED && pr.buildStatus === BUILD_STATUS.PASSING && !pr._autoCompleted) {
      const autoComplete = config.engine?.autoCompletePrs === true; // opt-in
      if (autoComplete) {
        try {
          const mergeMethod = ['squash', 'merge', 'rebase'].includes(config.engine?.prMergeMethod) ? config.engine.prMergeMethod : 'squash';
          // P-a7c4d2e8 (F1): argv-form merge call — eliminates shell interpolation of slug/prNum/mergeMethod.
          await shared.shellSafeGh(['pr', 'merge', String(prNum), `--${mergeMethod}`, '--repo', shared.validateGhSlug(slug), '--delete-branch'], { timeout: 30000, maxBuffer: GH_MAX_BUFFER });
          pr._autoCompleted = true;
          log('info', `Auto-completed PR ${pr.id}: builds green + review approved → merged (${mergeMethod})`);
          updated = true;
        } catch (e) {
          log('warn', `Auto-complete failed for PR ${pr.id}: ${e.message}`);
        }
      }
    }

    // W-mpx44p05000ze8d8 — Proactive GC for stale `_noOpFixes` records. Any
    // cause whose stored evidence fingerprint no longer matches the live PR
    // (head SHA shifted, build failure reason changed, new human comment,
    // review status flipped, etc.) is dropped via clearPrNoOpFixAttempt. The
    // dispatch gate already ignores these stale records (isPrNoOpFixCausePaused
    // re-validates the fingerprint), but without this sweep they linger in
    // pull-requests.json forever — bloating the file and confusing the
    // dashboard `pr-paused-chip`. Runs AFTER all live-field updates so the
    // fingerprint comparison uses post-poll PR state.
    try {
      if (lifecycleModule().sweepStalePrNoOpFixes(pr)) updated = true;
    } catch (e) {
      log('warn', `sweepStalePrNoOpFixes for ${pr.id}: ${e.message}`);
    }

    return updated;
  });

  if (totalUpdated > 0) {
    log('info', `GitHub PR status poll: updated ${totalUpdated} PR(s)`);
  }
}

// ─── Poll Human Comments on PRs ─────────────────────────────────────────────

async function pollPrHumanComments(config) {
  // Load dispatch state once per poll cycle (cached for ~2s by queries.getDispatch).
  // (Previously used by `_isAgentSelfReviewDeclinedComment` to verify dispatch ids;
  // kept loaded in case downstream phases need dispatch context — cheap on cache hit.)
  const queries = require('./queries');
  const dispatch = (() => {
    try { return queries.getDispatch(); }
    catch { return { pending: [], active: [], completed: [] }; }
  })();
  // Viewer login is resolved per-PR (cheap on cache hit) because each slug
  // can route to a different gh account via `engine/gh-token.js`. The cache
  // is keyed by the resolved account name, so multiple PRs sharing one
  // account pay only the first shell-out.
  const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
    const viewerLogin = await _resolveViewerLogin(slug);
    // Get issue comments (general PR comments)
    const comments = await ghApi(`/issues/${prNum}/comments`, slug);
    if (!comments || !Array.isArray(comments)) return false;

    // Also get review comments (inline code comments)
    const reviewComments = await ghApi(`/pulls/${prNum}/comments`, slug);
    const allComments = [
      ...(comments || []).map(c => ({ ...c, _type: 'issue' })),
      ...(Array.isArray(reviewComments) ? reviewComments : []).map(c => ({ ...c, _type: 'review' }))
    ];

    // Backfill viewerDidAuthor on each comment so _isMinionsAuthoredComment
    // can actually fire on production traffic. No-op when GraphQL or a test
    // fixture already populated the field.
    _backfillViewerDidAuthor(allComments, viewerLogin);

    const cutoffStr = pr.humanFeedback?.lastProcessedCommentDate || pr.created || pr._attachedAt || '1970-01-01';
    const cutoffMs = new Date(cutoffStr).getTime() || 0;

    // F6 (P-f6commentedit): TZ-normalized per-comment edit dedup map. Comments
    // that were EDITED (updated_at > created_at) but whose normalized edit
    // timestamp differs from what we already saw are queued as new feedback so
    // a follow-up fix re-dispatches. First-seen comments and re-polls of
    // already-seen edits do NOT route through this path.
    const prevEditsSeen = (pr.humanFeedback && pr.humanFeedback.editsSeen) || {};
    const editsInput = allComments.map(c => ({
      id: c.id,
      createdRaw: c.created_at,
      updatedRaw: c.updated_at,
    }));
    // Sort chronologically by updated timestamp so FIFO cap eviction keeps the
    // most-recently-edited entries (the ones most likely to be re-edited).
    editsInput.sort((a, b) => String(a.updatedRaw || '').localeCompare(String(b.updatedRaw || '')));
    const { editsSeen, newlyEditedIds, changed: editsSeenChanged } =
      shared.buildEditsSeen(editsInput, prevEditsSeen);

    // Collect comments that should advance the cutoff separately from comments
    // that should dispatch a fix. Informational bot/status comments and
    // Minions-authored triage comments should be seen once, then ignored.
    const allCommentDates = [];
    const allCommentEntries = [];
    const newComments = [];
    const seenInNewComments = new Set();

    for (const c of allComments) {
      const date = c.created_at || c.updated_at || '';
      const dateMs = date ? new Date(date).getTime() : 0;
      // W-mp3bp0ha000997ab-b — All non-actionable filtering routes through
      // _isNonActionableComment, which now composes _isMinionsAuthoredComment
      // (structural marker check). The previous OR chain that appended
      // _isAgentSelfReviewDeclinedComment is gone — that helper was deleted
      // with this PR.
      const isNonActionable = _isNonActionableComment(c, config);
      if (dateMs) allCommentDates.push(date);
      if (isNonActionable) continue;
      const entry = {
        commentId: c.id,
        author: c.user?.login || 'Human',
        content: c.body || '',
        date,
        _isAgent: false,
        // Issue #2994: namespace GH comment ids by API source ("issue" from
        // `/issues/N/comments` vs "review" from `/pulls/N/comments`). Each
        // endpoint maintains its own id space, so two comments — one issue,
        // one review — can collide on the same numeric id. Mirror the ADO
        // poller's thread-aware key shape so the engine.js human-feedback
        // same-head guard can dedup via a single comparable key.
        _type: c._type,
      };
      allCommentEntries.push(entry);

      if (dateMs && dateMs > cutoffMs) {
        newComments.push(entry);
        seenInNewComments.add(String(c.id));
      } else if (c.id != null && newlyEditedIds.has(String(c.id))) {
        // F6: pre-cutoff comment that was newly edited — route into newComments
        // so a fresh fix dispatches, but tag with the edit timestamp so the
        // fix-agent prompt context flags it as new.
        newComments.push({ ...entry, date: editsSeen[String(c.id)] || date, _isEditedRedispatch: true });
        seenInNewComments.add(String(c.id));
      }
    }

    // Update cutoff even if only non-actionable comments are new.
    const allNewDates = allCommentDates.filter(date => (new Date(date).getTime() || 0) > cutoffMs);
    if (allNewDates.length > 0 && newComments.length === 0) {
      pr.humanFeedback = { ...(pr.humanFeedback || {}), lastProcessedCommentDate: allNewDates.sort().pop() };
      if (editsSeenChanged) pr.humanFeedback.editsSeen = editsSeen;
      return true; // non-actionable comments only — persist cutoff without triggering fix
    }
    if (newComments.length === 0) {
      // F6: persist pruned/updated editsSeen map even when no dispatch is queued.
      if (editsSeenChanged) {
        pr.humanFeedback = { ...(pr.humanFeedback || {}), editsSeen };
        return true;
      }
      return false;
    }

    // Sort all comments chronologically and build full context for the fix agent
    allCommentEntries.sort((a, b) => a.date.localeCompare(b.date));
    newComments.sort((a, b) => a.date.localeCompare(b.date));
    const latestDate = allNewDates.sort().pop() || newComments[newComments.length - 1].date;

    // Provide ALL comments as context — the agent needs full thread context to fix properly.
    // F5 (W-mpeklod3000we69c): wrap each comment body individually in an
    // <UNTRUSTED-INPUT> fence with per-comment provenance. The "**author**
    // (date):" header is engine-controlled and stays outside the fence so the
    // agent can attribute each block; the comment body itself (the
    // attacker-controlled part) lands inside.
    const feedbackContent = allCommentEntries
      .map(c => {
        const isNew = (new Date(c.date).getTime() || 0) > cutoffMs;
        const cleanedBody = String(c.content || '').replace(/@minions\s*/gi, '').trim();
        const source = buildSource('pr-comment', {
          host: 'gh', slug, number: prNum, author: c.author || 'unknown',
        });
        const fenced = wrapUntrusted(cleanedBody, source);
        const bodyForPrompt = fenced || cleanedBody;
        return `${isNew ? '**[NEW]** ' : ''}**${c.author}** (${c.date}):\n${bodyForPrompt}`;
      })
      .join('\n\n---\n\n');

    pr.humanFeedback = {
      lastProcessedCommentDate: latestDate,
      lastProcessedCommentId: String(newComments[newComments.length - 1].commentId),
      // Issue #2994: namespaced key — `${type}:${commentId}` — for the
      // engine.js human-feedback same-head guard. Mirrors ADO's
      // `${threadId}:${commentId}` shape so the guard compares a single key
      // form. Falls back to the bare id when `_type` is absent (defensive;
      // every entry above sets it, but the pollPrHumanComments path in
      // engine/ado.js writes the legacy format too).
      lastProcessedCommentKey: `${newComments[newComments.length - 1]._type || ''}:${newComments[newComments.length - 1].commentId}`,
      pendingFix: true,
      feedbackContent,
      editsSeen,
    };

    log('info', `PR ${pr.id}: ${newComments.length} new comment(s), ${allCommentEntries.length} total — full thread context provided`);
    return true;
  });

  if (totalUpdated > 0) {
    log('info', `GitHub PR comment poll: found human feedback on ${totalUpdated} PR(s)`);
  }
}

// ─── PR Reconciliation ──────────────────────────────────────────────────────

async function reconcilePrs(config) {
  const projects = getProjects(config).filter(isGitHub);
  const branchPatterns = [/^work\//i, /^feat\//i, /^fix\//i, /^e2e\//i, /^user\/yemishin\//i];
  const configuredAuthorLogins = getConfiguredGitHubAuthorLogins(config);
  let totalAdded = 0;

  for (const project of projects) {
    const slug = getRepoSlug(project);
    if (!slug) continue;

    // Skip projects in backoff (inaccessible repo)
    if (isSlugInBackoff(slug)) continue;

    // Skip projects with no tracked PRs and no work items — nothing to reconcile
    const existingPrs = getPrs(project);
    if (existingPrs.length === 0) {
      try {
        const wiPath = projectWorkItemsPath(project);
        const wis = safeJsonArr(wiPath);
        if (wis.length === 0) continue;
      } catch { continue; }
    }

    // Fetch open PRs — paginate to handle repos with >100 open PRs
    const prsData = await ghApi('/pulls?state=open&per_page=100', slug, { paginate: true, timeout: 60000 });
    if (!prsData || !Array.isArray(prsData)) {
      recordSlugFailure(slug);
      continue;
    }
    resetSlugBackoff(slug);

    const ghPrs = prsData.filter(pr => {
      const branch = pr.head?.ref || '';
      return branchPatterns.some(pat => pat.test(branch));
    });

    if (ghPrs.length === 0) continue;

    const prPath = projectPrPath(project);
    const currentPrs = safeJsonArr(prPath);
    shared.normalizePrRecords(currentPrs, project);
    const existingIds = new Set(currentPrs.map(p => p.id));
    let metadataUpdated = 0;
    let projectAdded = 0;

    // Load work items to match branches
    const wiPath = projectWorkItemsPath(project);
    const workItems = safeJsonArr(wiPath);
    const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
    const centralItems = safeJsonArr(centralWiPath);
    const allItems = [...workItems, ...centralItems];

    for (const ghPr of ghPrs) {
      const prUrl = project.prUrlBase ? project.prUrlBase + ghPr.number : ghPr.html_url || '';
      const prId = shared.getCanonicalPrId(project, ghPr.number, prUrl);
      // P-a7c4d2e8 (F3): validate API-derived branch ref before persistence
      // / regex matching. Defensive degrade — log and treat as missing on
      // invalid ref rather than letting it flow into stored records.
      const rawBranch = ghPr.head?.ref || '';
      let branch = '';
      if (rawBranch) {
        try { branch = shared.validateGitRef(rawBranch); }
        catch (refErr) {
          log('warn', `GitHub: invalid head.ref "${rawBranch.slice(0, 64)}" for PR ${prId}: ${refErr.message}`);
        }
      }
      const wiMatch = branch.match(/(P-[a-z0-9]{6,})/i) || branch.match(/(W-[a-z0-9]{6,})/i) || branch.match(/(PL-[a-z0-9]{6,})/i);
      const linkedItemId = wiMatch ? wiMatch[1] : null;
      const linkedItem = linkedItemId ? allItems.find(i => i.id === linkedItemId) : null;
      const confirmedItemId = linkedItem ? linkedItemId : null;

      if (existingIds.has(prId)) {
        const existing = currentPrs.find(p => p.id === prId);
        // Backfill prNumber for existing records missing it
        if (existing && existing.prNumber == null) {
          existing.prNumber = ghPr.number;
        }
        if (existing && !existing.branch && branch) {
          existing.branch = branch;
          if (existing._branchResolutionError) delete existing._branchResolutionError;
          if (existing._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete existing._pendingReason;
          metadataUpdated++;
        }
        if (confirmedItemId) {
          shared.upsertPullRequestRecord(prPath, existing || {
            id: prId,
            prNumber: ghPr.number,
            title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
            agent: (linkedItem?.dispatched_to || ghPr.user?.login || 'unknown').toLowerCase(),
            branch,
            reviewStatus: REVIEW_STATUS.PENDING,
            status: 'active',
            created: ghPr.created_at || ts(),
            url: prUrl,
            prdItems: [],
          }, { project, itemId: confirmedItemId });
          if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
            existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
            existing.prdItems.push(confirmedItemId);
          }
        }
        continue;
      }

      // Auto-import only when local state proves this branch belongs to a
      // Minions work item and the PR was opened by one of this client's
      // configured GitHub accounts. Existing/manual records are handled above.
      const authorLogin = String(ghPr.user?.login || '').trim().toLowerCase();
      if (!confirmedItemId || !configuredAuthorLogins.has(authorLogin)) continue;

      const entry = {
        id: prId,
        prNumber: ghPr.number,
        title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
        agent: (linkedItem?.dispatched_to || ghPr.user?.login || 'unknown').toLowerCase(),
        branch,
        reviewStatus: REVIEW_STATUS.PENDING,
        status: 'active',
        created: ghPr.created_at || ts(),
        url: prUrl,
        prdItems: [confirmedItemId],
      };
      const upserted = shared.upsertPullRequestRecord(prPath, entry, { project, itemId: confirmedItemId });
      currentPrs.push(upserted.record || entry);
      existingIds.add(prId);
      projectAdded++;

      log('info', `GitHub PR reconciliation: added ${prId} (branch: ${branch}, linked to ${confirmedItemId}) to ${project.name}`);
    }

    // Backfill prNumber from pr.id for any PR missing it (e.g. created before prNumber was stored)
    for (const pr of currentPrs) {
      if (pr.prNumber == null) {
        const derived = shared.getPrNumber(pr);
        if (derived) pr.prNumber = derived;
      }
    }

    // Backfill prdItems from pr-links for any PR with empty array
    const backfilled = backfillPrPrdItems(currentPrs, getPrLinks());

    if (projectAdded > 0 || backfilled > 0 || metadataUpdated > 0) {
      mutateJsonFileLocked(prPath, (lockedPrs) => {
        for (const pr of currentPrs) {
          const idx = lockedPrs.findIndex(p => p.id === pr.id);
          if (idx >= 0) lockedPrs[idx] = pr;
          else lockedPrs.push(pr);
        }
        return lockedPrs;
      }, { defaultValue: [] });
      totalAdded += projectAdded;
    }
  }

  if (totalAdded > 0) {
    log('info', `GitHub PR reconciliation: added ${totalAdded} missing PR(s)`);
  }
}

/**
 * Fetch live review status for a single PR from GitHub. Returns 'approved', 'changes-requested',
 * 'waiting', or 'pending'. Returns null if the check fails.
 */
async function checkLiveReviewStatus(pr, project) {
  try {
    const slug = getRepoSlug(project);
    if (!slug) return null;
    const prNum = shared.getPrNumber(pr);
    if (!prNum) return null;
    const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
    if (!reviews || !Array.isArray(reviews)) return null;
    const latestByUser = new Map();
    for (const r of reviews) {
      if (!r.state || r.state === 'COMMENTED') continue;
      latestByUser.set(r.user?.login || '', r.state);
    }
    const states = [...latestByUser.values()];
    if (states.some(s => s === 'CHANGES_REQUESTED')) return REVIEW_STATUS.CHANGES_REQUESTED;
    if (states.some(s => s === 'APPROVED')) return REVIEW_STATUS.APPROVED;
    if (states.length > 0) return REVIEW_STATUS.PENDING;
    return REVIEW_STATUS.PENDING;
  } catch (e) {
    log('warn', `Live review check for ${pr.id}: ${e.message}`);
    return null;
  }
}

/**
 * W-mp7b1g8q000fea45 — Dismiss prior CHANGES_REQUESTED reviews by the
 * authenticated viewer when an agent flips their verdict to approved.
 *
 * GitHub doesn't auto-supersede prior reviews: a new COMMENTED review (which is
 * what minions agents post on GH per CLAUDE.md "GitHub review self-approval
 * constraint") leaves any earlier `CHANGES_REQUESTED` review by the same login
 * intact, so `checkLiveReviewStatus` still returns 'changes-requested' and the
 * red ❌ badge stays on the PR. Active dismissal via PUT
 * /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/dismissals
 * clears the stale red badge.
 *
 * Only dismisses reviews whose state is CHANGES_REQUESTED AND whose author
 * matches the resolved viewer login (i.e., the engine's own gh PAT). Human
 * CHANGES_REQUESTED reviews under a different login are never touched.
 *
 * Returns null on transport failure. Otherwise returns
 *   { attempted: boolean, dismissed: number, errors: number }
 */
async function dismissPriorViewerChangesRequestedReviews(pr, project) {
  let tmpFile = null;
  try {
    const slug = getRepoSlug(project);
    if (!slug) return null;
    const prNum = shared.getPrNumber(pr);
    if (!prNum) return null;
    const viewerLogin = await _resolveViewerLogin(slug);
    if (!viewerLogin) return null;
    const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
    if (!reviews || !Array.isArray(reviews)) return null;
    // Latest CHANGES_REQUESTED review IDs by the viewer login. The reviews API
    // returns them in chronological order; we keep all that are still active
    // (not already dismissed) since GitHub's per-user "latest" semantics are
    // computed from non-dismissed reviews and we want the badge fully cleared.
    const viewerLower = String(viewerLogin).toLowerCase();
    const targets = reviews
      .filter(r => r && r.state === 'CHANGES_REQUESTED' && String(r.user?.login || '').toLowerCase() === viewerLower)
      .map(r => r.id)
      .filter(Number.isFinite);
    if (targets.length === 0) {
      return { attempted: false, dismissed: 0, errors: 0 };
    }
    const fs = require('fs');
    const os = require('os');
    let dismissed = 0;
    let errors = 0;
    // P-f6-tmp-toctou: per-call mkdtempSync dir (random 6-char suffix) closes
    // the predictable-prefix window on <os.tmpdir>/gh-review-dismiss-* that
    // an attacker could otherwise plant as a symlink.
    // P-f2-gh-shell: argv form via shellSafeGh — validates slug, prNum, and
    // reviewId so a poisoned PR record can't smuggle shell metacharacters.
    let scopedDir = null;
    try {
      scopedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gh-review-dismiss-'));
      if (process.platform !== 'win32') {
        try { fs.chmodSync(scopedDir, 0o700); } catch { /* best-effort */ }
      }
      for (const reviewId of targets) {
        tmpFile = path.join(scopedDir, `dismiss-${reviewId}.json`);
        const body = JSON.stringify({
          message: 'Superseded by Minions re-review (verdict flipped to APPROVE).',
          event: 'DISMISS',
        });
        try {
          fs.writeFileSync(tmpFile, body);
          const args = [
            'api', '-X', 'PUT', '--input', tmpFile,
            `repos/${shared.validateGhSlug(slug)}/pulls/${shared.validatePrNum(prNum)}/reviews/${shared.validateReviewId(reviewId)}/dismissals`,
          ];
          const token = ghToken.resolveTokenForSlug(slug);
          const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
          await _runShellSafeGh(args, { timeout: 10000, maxBuffer: GH_MAX_BUFFER, env });
          dismissed += 1;
          log('info', `PR ${pr.id}: dismissed prior CHANGES_REQUESTED review ${reviewId} by ${viewerLogin}`);
        } catch (e) {
          errors += 1;
          log('warn', `PR ${pr.id}: dismiss review ${reviewId} failed: ${e?.message || e}`);
        } finally {
          try { fs.unlinkSync(tmpFile); } catch {}
          tmpFile = null;
        }
      }
    } finally {
      if (scopedDir) {
        try { fs.rmSync(scopedDir, { recursive: true, force: true }); } catch {}
      }
    }
    return { attempted: true, dismissed, errors };
  } catch (e) {
    log('warn', `dismissPriorViewerChangesRequestedReviews for ${pr?.id || 'unknown PR'}: ${e?.message || e}`);
    return null;
  } finally {
    if (tmpFile) {
      try { require('fs').unlinkSync(tmpFile); } catch {}
    }
  }
}

/**
 * Cheap pre-dispatch freshness check for build status and merge-conflict state.
 * Mirrors checkLiveReviewStatus — fetches PR data once, classifies check-runs
 * for the current head SHA, and reports whether GitHub still considers the PR
 * unmergeable.
 *
 * Returns null if the check can't run (no slug/PR/network) so callers can fall
 * back to cached state. Otherwise returns:
 *   {
 *     buildStatus: 'failing' | 'passing' | 'running' | 'none' | null,
 *     mergeConflict: boolean,
 *     buildStatusStale?: boolean,
 *     buildStatusDetail?: string,
 *   }
 *
 * `mergeConflict` is true only when GitHub explicitly reports `mergeable: false`
 * — `mergeable: null` means GitHub is still computing the merge state, so the
 * caller should treat that as "no fresh signal" and trust the cache.
 *
 * `buildStatus` is null when we couldn't query check-runs or the PR isn't open;
 * caller falls back to cached value.
 *
 * `buildStatusStale` (+ `buildStatusDetail`) are set when the inner check-runs
 * query throws — P-f1ghstale parity with ADO so engine.js's stale-marker branch
 * fires for GitHub too.
 */
async function checkLiveBuildAndConflict(pr, project) {
  try {
    const slug = getRepoSlug(project);
    if (!slug) return null;
    const prNum = shared.getPrNumber(pr);
    if (!prNum) return null;
    const prData = await _ghApiViaTestSeam(`/pulls/${prNum}`, slug);
    if (!prData || prData === GH_NOT_FOUND) return null;

    // Conflict signal — only treat `mergeable === false` as a positive
    // conflict. `null` means "GitHub still computing" → we have no fresh
    // info, so fall back to whatever the cache says.
    let mergeConflict;
    if (prData.mergeable === false) mergeConflict = true;
    else if (prData.mergeable === true) mergeConflict = false;
    else mergeConflict = !!pr._mergeConflict; // computing — preserve cached view

    // Build signal — only meaningful for open PRs. Mirrors pollPrStatus's
    // check-runs classification so the live read and cached poll agree on
    // 'failing' / 'passing' / 'running' / 'none'.
    let buildStatus = null;
    // P-f1ghstale: stale-signal parity with ADO. When the check-runs query
    // throws (transient gh CLI failure, network blip), surface a stale signal
    // so engine.js's `if (live?.buildStatusStale)` branch can persist the
    // cached state instead of treating a missing buildStatus as recovered.
    // EXPLICIT NON-GOALS (rejected in plan): no mergeable===null branch,
    // no wallclock/age branch, no empty check_runs branch.
    let buildStatusStale = false;
    let buildStatusDetail = '';
    if (prData.state === 'open' && prData.head?.sha) {
      try {
        const checksData = await _ghApiViaTestSeam(`/commits/${prData.head.sha}/check-runs`, slug);
        if (checksData && Array.isArray(checksData.check_runs)) {
          const runs = checksData.check_runs;
          if (runs.length === 0) {
            buildStatus = BUILD_STATUS.NONE;
          } else {
            const hasFailed = runs.some(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
            const allDone = runs.every(r => r.status === 'completed');
            const allPassed = runs.every(r => r.conclusion === 'success' || r.conclusion === 'skipped' || r.conclusion === 'neutral');
            if (hasFailed) buildStatus = BUILD_STATUS.FAILING;
            else if (allDone && allPassed) buildStatus = BUILD_STATUS.PASSING;
            else buildStatus = BUILD_STATUS.RUNNING;
          }
        }
      } catch (e) {
        buildStatusStale = true;
        buildStatusDetail = `Live check-runs query failed: ${e.message}`;
        log('warn', `Live build check checks query for ${pr.id}: ${e.message}`);
      }
    }

    return {
      buildStatus,
      mergeConflict,
      ...(buildStatusStale ? { buildStatusStale, buildStatusDetail } : {}),
    };
  } catch (e) {
    log('warn', `Live build/conflict check for ${pr.id}: ${e.message}`);
    return null;
  }
}

// ─── One-Shot Startup Reconciliation for Abandoned PRs (W-mp60tw0u000j3931) ───
//
// Pairs with the W-mp5trwh60008386d 404-hardening fix in pollPrStatus.
// pollPrStatus only iterates PR_POLLABLE_STATUSES = {active, linked} — once a
// PR has status: 'abandoned' it is terminal for the regular poll loop. That
// behavior is correct (no per-tick re-probing of dead PRs), but it leaves any
// historical false-flips (e.g. the 16 PRs marked abandoned by the 2026-05-14
// gh-auth-flip incident) permanently stuck unless something explicitly
// re-probes the abandoned set.
//
// This function is the explicit re-probe. Same base-repo probe gate as
// pollPrStatus (cached per slug for the duration of the pass), then a single
// `repos/{slug}/pulls/{n}` per abandoned PR. Open → active, merged → merged,
// closed (not merged) → closed, confirmed-404 → leave abandoned + mark
// `_reconciliation404Confirmed: true` so we don't re-probe deleted PRs on
// every future restart.
//
// Caller: engine/abandoned-pr-reconciliation.js (version-gated).
async function reconcileAbandonedPrs(config) {
  const projects = shared.getProjects(config).filter(isGitHub);
  let flipped = 0, confirmedDeleted = 0, skipped = 0, errored = 0;
  // Cache base-repo probe results for the duration of THIS pass — multiple
  // projects can share the same slug (rare) and multiple PRs definitely share
  // it. Cleared when the function returns.
  const slugProbeCache = new Map(); // slug → 'ok' | 'fail'

  for (const project of projects) {
    const slug = getRepoSlug(project);
    if (!slug) continue;

    const prPath = projectPrPath(project);
    const prs = safeJsonArr(prPath);
    // Filter: only abandoned PRs that don't already have the confirmed-404
    // marker from a previous reconciliation pass. Marker means "we already
    // verified this PR is genuinely deleted on the server" — no point
    // re-probing it on every future pass.
    const abandonedPrs = prs.filter(pr =>
      pr.status === PR_STATUS.ABANDONED
      && !pr._reconciliation404Confirmed
      && shared.isPrCompatibleWithProject(project, pr, pr.url || '')
    );
    if (abandonedPrs.length === 0) continue;

    // Probe base repo (cached). Failure → skip ALL of this slug's abandoned
    // PRs and increment skipped counter — auth/access issue at boot, retry
    // next restart.
    let probeResult = slugProbeCache.get(slug);
    if (probeResult === undefined) {
      const probe = await ghApi('', slug);
      probeResult = (probe === null || probe === GH_NOT_FOUND) ? 'fail' : 'ok';
      slugProbeCache.set(slug, probeResult);
    }
    if (probeResult === 'fail') {
      log('warn', `Abandoned PR reconciliation: skipping ${slug} (${abandonedPrs.length} PR${abandonedPrs.length === 1 ? '' : 's'}) — base-repo probe failed, retry next startup`);
      skipped += abandonedPrs.length;
      continue;
    }

    // Per-PR re-probe. Collect updates first, then apply via mutatePullRequests
    // for atomic single-writer semantics. We match by prNumber on writeback
    // (not id) because mutatePullRequests calls normalizePrRecords which can
    // lowercase the id slug — prNumber is the stable key within a project's
    // pull-requests.json.
    const updates = []; // { prNumber, action, newStatus?, mergedAt? }
    for (const pr of abandonedPrs) {
      const prNum = shared.getPrNumber(pr);
      if (!prNum) continue;

      try {
        const prData = await ghApi(`/pulls/${prNum}`, slug);
        if (prData === GH_NOT_FOUND) {
          // 404 with base-probe OK → genuinely deleted. Mark so we don't
          // re-probe this PR on future startups.
          updates.push({ prNumber: prNum, action: 'confirm404' });
          confirmedDeleted++;
          log('info', `Confirmed PR #${prNum} (${slug}): truly deleted, leaving abandoned`);
        } else if (prData) {
          // Successful response. Map GitHub state to minions status.
          let newStatus, reason;
          if (prData.merged) {
            newStatus = PR_STATUS.MERGED;
            reason = 'was merged';
          } else if (prData.state === 'open') {
            newStatus = PR_STATUS.ACTIVE;
            reason = 'was open';
          } else if (prData.state === 'closed') {
            newStatus = PR_STATUS.CLOSED;
            reason = 'was closed';
          } else {
            // Unknown state — be defensive, leave as-is and don't mark as
            // confirmed-404 either (wait for clearer signal next pass).
            log('warn', `Skipped PR #${prNum} (${slug}): unknown GitHub state ${JSON.stringify(prData.state)}`);
            errored++;
            continue;
          }
          updates.push({
            prNumber: prNum,
            action: 'flip',
            newStatus,
            mergedAt: prData.merged_at || null,
          });
          flipped++;
          log('info', `Reconciled PR #${prNum} (${slug}): abandoned → ${newStatus} (${reason})`);
        } else {
          // null = network/rate-limit/other non-404 error. Don't mark
          // _reconciliation404Confirmed — we want to retry next startup.
          log('warn', `Skipped PR #${prNum} (${slug}): API error, retry next startup`);
          errored++;
        }
      } catch (err) {
        log('warn', `Abandoned PR reconciliation error on PR #${prNum} (${slug}): ${err.message}`);
        errored++;
      }
    }

    if (updates.length > 0) {
      const reconciledAt = ts();
      mutatePullRequests(prPath, (currentPrs) => {
        for (const upd of updates) {
          const pr = currentPrs.find(p => shared.getPrNumber(p) === upd.prNumber);
          if (!pr) continue;
          // Defensive: never downgrade a merged record. Should already be
          // filtered by the abandoned-only scan above, but a concurrent writer
          // could have flipped it between our read and this write.
          if (pr.status === PR_STATUS.MERGED && upd.action !== 'flip') continue;
          if (pr.status === PR_STATUS.MERGED && upd.newStatus !== PR_STATUS.MERGED) continue;
          if (upd.action === 'flip') {
            pr.status = upd.newStatus;
            if (upd.mergedAt && !pr.mergedAt) pr.mergedAt = upd.mergedAt;
            // Clear stale 404-counter state from the false-flip era.
            delete pr._consecutive404s;
            delete pr._404Count;
            delete pr._404FirstAt;
            pr._reconciledAt = reconciledAt;
            pr._reconciledFrom = 'startup-pass';
          } else if (upd.action === 'confirm404') {
            pr._reconciledAt = reconciledAt;
            pr._reconciliation404Confirmed = true;
          }
        }
        return currentPrs;
      });
    }
  }

  return { flipped, confirmedDeleted, skipped, errored };
}

module.exports = {
  pollPrStatus,
  pollPrHumanComments,
  reconcilePrs,
  reconcileAbandonedPrs, // W-mp60tw0u000j3931 — one-shot startup re-probe of abandoned PRs
  checkLiveReviewStatus,
  checkLiveBuildAndConflict,
  dismissPriorViewerChangesRequestedReviews, // W-mp7b1g8q000fea45 — clear stale CHANGES_REQUESTED on verdict flip
  isGhThrottled,
  getGhThrottleState,
  // Exported for testing
  isGitHub,
  getRepoSlug,
  isSlugInBackoff,
  recordSlugFailure,
  resetSlugBackoff,
  _ghPollBackoff,
  _ghThrottle, // exported for testing
  GH_MAX_BUFFER, // exported for testing
  GH_POLL_BACKOFF_BASE_MS, // exported for testing
  GH_POLL_BACKOFF_MAX_MS, // exported for testing
  GH_NOT_FOUND, // W-mp625n27000m6e78 — exported for forEachActiveGhPr probe-predicate tests
  forEachActiveGhPr, // W-mp625n27000m6e78 — exported for workSources-gate + probe tests
  _hasMinionsReviewVerdict, // exported for testing
  _isAgentComment, // exported for testing
  _isNonActionableComment, // exported for testing
  _isMinionsAuthoredComment, // exported for testing (W-mp3bp0ha000997ab-b)
  _isPreviewStatusComment, // exported for testing
  _resolveViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
  _setCachedViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
  _backfillViewerDidAuthor, // exported for testing (W-mp3bp0ha000997ab-b backfill)
  _setExecAsyncForTest, // W-mp5trwh60008386d: test seam to mock `gh api` shell-outs
  _setShellSafeGhForTest, // P-f2-gh-shell (F2): test seam to mock argv-form gh api calls
  _setGhApiForTest, // P-f1ghstale: test seam to mock ghApi call sites for stale-signal branch coverage
  GH_NOT_FOUND, // W-mp5trwh60008386d: exported so tests can assert sentinel propagation
};
