quadwork 2.0.1 → 2.2.0

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.
Files changed (83) hide show
  1. package/out/404.html +1 -1
  2. package/out/__next.__PAGE__.txt +3 -3
  3. package/out/__next._full.txt +14 -14
  4. package/out/__next._head.txt +4 -4
  5. package/out/__next._index.txt +8 -8
  6. package/out/__next._tree.txt +2 -2
  7. package/out/_next/static/chunks/{0~-kpl6f_x5s6.js → 02kx5r305y-id.js} +1 -1
  8. package/out/_next/static/chunks/{0jllnzexn48._.js → 044n.~stdsjlo.js} +1 -1
  9. package/out/_next/static/chunks/0dbfrdfwj565f.css +2 -0
  10. package/out/_next/static/chunks/0fvw~.-bjbvj3.js +27 -0
  11. package/out/_next/static/chunks/0y_59cdk3r4z6.js +1 -0
  12. package/out/_next/static/chunks/12yxvamsloafv.js +1 -0
  13. package/out/_not-found/__next._full.txt +13 -13
  14. package/out/_not-found/__next._head.txt +4 -4
  15. package/out/_not-found/__next._index.txt +8 -8
  16. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  17. package/out/_not-found/__next._not-found.txt +3 -3
  18. package/out/_not-found/__next._tree.txt +2 -2
  19. package/out/_not-found.html +1 -1
  20. package/out/_not-found.txt +13 -13
  21. package/out/app-shell/__next._full.txt +13 -13
  22. package/out/app-shell/__next._head.txt +4 -4
  23. package/out/app-shell/__next._index.txt +8 -8
  24. package/out/app-shell/__next._tree.txt +2 -2
  25. package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
  26. package/out/app-shell/__next.app-shell.txt +3 -3
  27. package/out/app-shell.html +1 -1
  28. package/out/app-shell.txt +13 -13
  29. package/out/index.html +1 -1
  30. package/out/index.txt +14 -14
  31. package/out/project/_/__next._full.txt +14 -14
  32. package/out/project/_/__next._head.txt +4 -4
  33. package/out/project/_/__next._index.txt +8 -8
  34. package/out/project/_/__next._tree.txt +2 -2
  35. package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
  36. package/out/project/_/__next.project.$d$id.txt +3 -3
  37. package/out/project/_/__next.project.txt +3 -3
  38. package/out/project/_/queue/__next._full.txt +14 -14
  39. package/out/project/_/queue/__next._head.txt +4 -4
  40. package/out/project/_/queue/__next._index.txt +8 -8
  41. package/out/project/_/queue/__next._tree.txt +2 -2
  42. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
  43. package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
  44. package/out/project/_/queue/__next.project.$d$id.txt +3 -3
  45. package/out/project/_/queue/__next.project.txt +3 -3
  46. package/out/project/_/queue.html +1 -1
  47. package/out/project/_/queue.txt +14 -14
  48. package/out/project/_.html +1 -1
  49. package/out/project/_.txt +14 -14
  50. package/out/settings/__next._full.txt +14 -14
  51. package/out/settings/__next._head.txt +4 -4
  52. package/out/settings/__next._index.txt +8 -8
  53. package/out/settings/__next._tree.txt +2 -2
  54. package/out/settings/__next.settings.__PAGE__.txt +3 -3
  55. package/out/settings/__next.settings.txt +3 -3
  56. package/out/settings.html +1 -1
  57. package/out/settings.txt +14 -14
  58. package/out/setup/__next._full.txt +14 -14
  59. package/out/setup/__next._head.txt +4 -4
  60. package/out/setup/__next._index.txt +8 -8
  61. package/out/setup/__next._tree.txt +2 -2
  62. package/out/setup/__next.setup.__PAGE__.txt +3 -3
  63. package/out/setup/__next.setup.txt +3 -3
  64. package/out/setup.html +1 -1
  65. package/out/setup.txt +14 -14
  66. package/package.json +2 -1
  67. package/server/index.js +111 -18
  68. package/server/routes.js +1717 -499
  69. package/server/run-tests.js +122 -0
  70. package/server/self-heal.js +100 -0
  71. package/templates/GITHUB.md +46 -0
  72. package/templates/seeds/butler.CLAUDE.md +2 -0
  73. package/templates/seeds/dev.AGENTS.md +12 -0
  74. package/templates/seeds/head.AGENTS.md +21 -6
  75. package/templates/seeds/re1.AGENTS.md +17 -3
  76. package/templates/seeds/re2.AGENTS.md +17 -3
  77. package/out/_next/static/chunks/0_79hkefw1mo2.js +0 -1
  78. package/out/_next/static/chunks/0q4bm04c1jl_3.js +0 -1
  79. package/out/_next/static/chunks/13xk0vgfbrcld.css +0 -2
  80. package/out/_next/static/chunks/163_ddkdca5q4.js +0 -25
  81. /package/out/_next/static/{MmPC1Rj12BOy4-HvMJjEX → h8gr2UEtEQkyXBVa2J0z9}/_buildManifest.js +0 -0
  82. /package/out/_next/static/{MmPC1Rj12BOy4-HvMJjEX → h8gr2UEtEQkyXBVa2J0z9}/_clientMiddlewareManifest.js +0 -0
  83. /package/out/_next/static/{MmPC1Rj12BOy4-HvMJjEX → h8gr2UEtEQkyXBVa2J0z9}/_ssgManifest.js +0 -0
package/server/routes.js CHANGED
@@ -26,6 +26,16 @@ const ENV_PATH = path.join(CONFIG_DIR, ".env");
26
26
  const TEMPLATES_DIR = path.join(__dirname, "..", "templates");
27
27
  const REPO_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
28
28
 
29
+ // #837: `gh api` list fetches (closed-PR pages with `-i`, GraphQL repo
30
+ // snapshot, batch progress) can exceed Node's default 1MB execFile maxBuffer.
31
+ // A real `pulls?state=closed&per_page=100` -i page measured ~1.74MB on
32
+ // realproject7/quadwork, which made ghApiConditional reject with
33
+ // ERR_CHILD_PROCESS_STDIO_MAXBUFFER → "Recently Merged PRs" stayed empty AND
34
+ // the #828/#834 queued-from-snapshot fast-path was forced off because
35
+ // closedPagesComplete couldn't ever become true. 32MB is well above today's
36
+ // page sizes and still well under node's hard 2GB cap.
37
+ const GH_LIST_MAX_BUFFER = 32 * 1024 * 1024;
38
+
29
39
  function isLocalhost(ip) {
30
40
  return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
31
41
  }
@@ -40,6 +50,15 @@ const _rateLimit = {
40
50
  updatedAt: 0, // epoch ms when we last fetched
41
51
  error: null, // last fetch error message, if any
42
52
  };
53
+ // #802: GitHub tracks REST (core) and GraphQL as SEPARATE budgets, so GraphQL
54
+ // can hit 0 while REST is full. The background GraphQL poller and the
55
+ // batch-progress GraphQL query must back off on THIS bucket, not the REST one.
56
+ const _graphqlRateLimit = {
57
+ limit: 5000, // GraphQL is a points/hour budget, not request count
58
+ remaining: 5000,
59
+ resetAt: 0, // epoch ms
60
+ updatedAt: 0, // epoch ms when we last fetched
61
+ };
43
62
  const RATE_LIMIT_POLL_MS = 60_000; // refresh every 60s
44
63
  const RATE_LIMIT_LOW_THRESHOLD = 200; // below this → back off
45
64
  const RATE_LIMIT_CRITICAL = 50; // below this → stop all infra gh calls
@@ -47,15 +66,24 @@ let _rateLimitTimer = null;
47
66
 
48
67
  async function refreshRateLimit() {
49
68
  try {
69
+ // One `gh api rate_limit` call returns every resource bucket; grab both
70
+ // core (REST) and graphql so each stream can gate on its own budget (#802).
50
71
  const { stdout } = await _execFileAsync("gh", [
51
- "api", "rate_limit", "--jq", ".resources.core | {limit,remaining,reset}",
72
+ "api", "rate_limit", "--jq",
73
+ "{core: (.resources.core | {limit,remaining,reset}), graphql: (.resources.graphql | {limit,remaining,reset})}",
52
74
  ], { encoding: "utf-8", timeout: 10000 });
53
75
  const data = JSON.parse(stdout);
54
- _rateLimit.limit = data.limit;
55
- _rateLimit.remaining = data.remaining;
56
- _rateLimit.resetAt = data.reset * 1000; // seconds → ms
76
+ _rateLimit.limit = data.core.limit;
77
+ _rateLimit.remaining = data.core.remaining;
78
+ _rateLimit.resetAt = data.core.reset * 1000; // seconds → ms
57
79
  _rateLimit.updatedAt = Date.now();
58
80
  _rateLimit.error = null;
81
+ if (data.graphql) {
82
+ _graphqlRateLimit.limit = data.graphql.limit;
83
+ _graphqlRateLimit.remaining = data.graphql.remaining;
84
+ _graphqlRateLimit.resetAt = data.graphql.reset * 1000;
85
+ _graphqlRateLimit.updatedAt = Date.now();
86
+ }
59
87
  } catch (err) {
60
88
  _rateLimit.error = err.message;
61
89
  _rateLimit.updatedAt = Date.now();
@@ -65,7 +93,13 @@ async function refreshRateLimit() {
65
93
  function startRateLimitPolling() {
66
94
  if (_rateLimitTimer) return;
67
95
  refreshRateLimit();
96
+ // #836: .unref() so this interval doesn't alone pin the event loop.
97
+ // Production keeps the process alive via the Express HTTP listener, so
98
+ // cadence/auto-start is unchanged; .unref() only lets test imports and
99
+ // one-off `node -e` invocations exit cleanly (an unref'd `node -e` poller
100
+ // ran for 11 days in the wild before this was caught).
68
101
  _rateLimitTimer = setInterval(refreshRateLimit, RATE_LIMIT_POLL_MS);
102
+ _rateLimitTimer.unref();
69
103
  }
70
104
 
71
105
  function isRateLimited() {
@@ -74,6 +108,13 @@ function isRateLimited() {
74
108
  function isRateLow() {
75
109
  return _rateLimit.remaining < RATE_LIMIT_LOW_THRESHOLD;
76
110
  }
111
+ // #802: same thresholds, applied to the separate GraphQL budget.
112
+ function isGraphqlRateLimited() {
113
+ return _graphqlRateLimit.remaining < RATE_LIMIT_CRITICAL;
114
+ }
115
+ function isGraphqlRateLow() {
116
+ return _graphqlRateLimit.remaining < RATE_LIMIT_LOW_THRESHOLD;
117
+ }
77
118
 
78
119
  // Adaptive cache TTL: normal 30s, low 120s, critical ∞ (serve stale)
79
120
  function adaptiveTTL(baseTTL) {
@@ -82,72 +123,15 @@ function adaptiveTTL(baseTTL) {
82
123
  return baseTTL;
83
124
  }
84
125
 
85
- // ─── Cached GitHub endpoint helper (#554) ─────────────────────────────────
86
- // Wraps a synchronous execFileSync gh call with an in-memory cache that
87
- // serves stale data when rate-limited instead of hammering the API.
88
- const _ghEndpointCache = new Map(); // key { ts, data }
126
+ // ─── Shared GitHub endpoint cache (#554) ──────────────────────────────────
127
+ // Per-endpoint in-memory cache of the canonical board slices, populated by the
128
+ // REST+ETag fetcher (#806, githubStateFetcher / refreshRepoRest) and served by
129
+ // the /api/github/* routes (serveGithubList) so reads cost ~0.
130
+ const _ghEndpointCache = new Map(); // key → { ts, data, stale? }
89
131
  const GH_ENDPOINT_CACHE_TTL = 60_000; // #698: 60s base TTL (was 30s)
90
-
91
- // #698: concurrency-limited background refresh queue. Caps simultaneous
92
- // gh CLI calls to avoid triggering GitHub's secondary rate limit even
93
- // when many endpoints expire on the same poll cycle.
94
- const _ghRefreshing = new Set();
132
+ // #698: cap simultaneous gh calls (per-PR fan-out, multi-project refresh) so we
133
+ // never trip GitHub's secondary rate limit.
95
134
  const GH_MAX_CONCURRENT = 2;
96
- const _ghRefreshQueue = [];
97
- let _ghActiveRefreshes = 0;
98
-
99
- function _ghDrainQueue() {
100
- while (_ghRefreshQueue.length > 0 && _ghActiveRefreshes < GH_MAX_CONCURRENT) {
101
- const job = _ghRefreshQueue.shift();
102
- _ghActiveRefreshes++;
103
- job().finally(() => { _ghActiveRefreshes--; _ghDrainQueue(); });
104
- }
105
- }
106
-
107
- function _ghEnqueueRefresh(cacheKey, ghArgs, transform) {
108
- if (_ghRefreshing.has(cacheKey)) return; // already queued/in-flight
109
- _ghRefreshing.add(cacheKey);
110
- _ghRefreshQueue.push(() =>
111
- _execFileAsync("gh", ghArgs, { encoding: "utf-8", timeout: 15000 })
112
- .then(({ stdout }) => {
113
- let data = JSON.parse(stdout);
114
- if (transform) data = transform(data);
115
- _ghEndpointCache.set(cacheKey, { ts: Date.now(), data, stale: false });
116
- })
117
- .catch(() => {}) // keep serving stale on error
118
- .finally(() => _ghRefreshing.delete(cacheKey))
119
- );
120
- _ghDrainQueue();
121
- }
122
-
123
- function cachedGhEndpoint(cacheKey, ghArgs, res, { transform } = {}) {
124
- const ttl = adaptiveTTL(GH_ENDPOINT_CACHE_TTL);
125
- const cached = _ghEndpointCache.get(cacheKey);
126
- if (cached && Date.now() - cached.ts < ttl) {
127
- return res.json(cached.stale ? { ...cached.data, _stale: true } : cached.data);
128
- }
129
- // If critically rate-limited, serve whatever we have (even expired)
130
- if (isRateLimited() && cached) {
131
- return res.json({ ...cached.data, _stale: true, _rateLimited: true });
132
- }
133
- // #698: stale-while-revalidate — if we have stale data, serve it
134
- // immediately and enqueue a background refresh. The queue caps
135
- // concurrent gh calls to GH_MAX_CONCURRENT to prevent burst traffic.
136
- if (cached) {
137
- _ghEnqueueRefresh(cacheKey, ghArgs, transform);
138
- return res.json({ ...cached.data, _stale: true });
139
- }
140
- // No cached data at all — must fetch synchronously for first load
141
- try {
142
- const out = execFileSync("gh", ghArgs, { encoding: "utf-8", timeout: 15000 });
143
- let data = JSON.parse(out);
144
- if (transform) data = transform(data);
145
- _ghEndpointCache.set(cacheKey, { ts: Date.now(), data, stale: false });
146
- res.json(data);
147
- } catch (err) {
148
- res.status(502).json({ error: "gh call failed", detail: err.message });
149
- }
150
- }
151
135
 
152
136
  const DEFAULT_CONFIG = {
153
137
  port: 8400,
@@ -240,6 +224,23 @@ function writeOvernightQueueFileSafe(projectId, projectName, repo) {
240
224
  } catch { /* non-fatal */ }
241
225
  }
242
226
 
227
+ // #807: seed the per-project GITHUB.md from the template (idempotent — no-op if
228
+ // it already exists). Mirrors writeOvernightQueueFileSafe. The machine sections
229
+ // are then authored by the server on each fetch pass (writeGithubFileFromSnapshot).
230
+ function writeGithubFileSafe(projectId, projectName, repo) {
231
+ try {
232
+ const ghPath = path.join(CONFIG_DIR, projectId, "GITHUB.md");
233
+ if (fs.existsSync(ghPath)) return;
234
+ const tpl = path.join(TEMPLATES_DIR, "GITHUB.md");
235
+ if (!fs.existsSync(tpl)) return;
236
+ ensureSecureDir(path.dirname(ghPath));
237
+ let content = fs.readFileSync(tpl, "utf-8");
238
+ content = content.replace(/\{\{project_name\}\}/g, projectName || projectId || "");
239
+ content = content.replace(/\{\{repo\}\}/g, repo || "");
240
+ fs.writeFileSync(ghPath, content);
241
+ } catch { /* non-fatal */ }
242
+ }
243
+
243
244
  function getProjectMaxHops(projectId) {
244
245
  if (!projectId) return 30;
245
246
  const cfg = readConfigFile();
@@ -278,16 +279,20 @@ router.get("/api/chat", (req, res) => {
278
279
  // Bare "head", "dev", "re1", "re2" become "@head", "@dev", "@re1", "@re2".
279
280
  // Already-prefixed mentions are not double-prefixed; suffixed names like
280
281
  // "head-2" or "re1-3" are left untouched.
282
+ // #788: `skipName` is the sender's own agent name — it is never converted, so
283
+ // an agent writing "head received the batch" doesn't self-mention. Pass a
284
+ // falsy skipName (user / bridge senders) to normalize every name.
281
285
  const MENTION_AGENT_NAMES = ["head", "dev", "re1", "re2"];
282
- function normalizeMentions(text) {
286
+ function normalizeMentions(text, skipName) {
283
287
  if (typeof text !== "string" || !text) return text || "";
288
+ const names = skipName ? MENTION_AGENT_NAMES.filter((n) => n !== skipName) : MENTION_AGENT_NAMES;
284
289
  const preserved = [];
285
290
  const ph = "\x00CODE\x00";
286
291
  let safe = text.replace(/```[\s\S]*?```|`[^`]+`/g, (m) => {
287
292
  preserved.push(m);
288
293
  return ph;
289
294
  });
290
- safe = MENTION_AGENT_NAMES.reduce(
295
+ safe = names.reduce(
291
296
  (t, name) =>
292
297
  t.replace(new RegExp(`(?<![@\\w])\\b${name}\\b(?![\\w-])`, "gi"), (match, offset, str) => {
293
298
  const before = str.slice(Math.max(0, offset - 20), offset);
@@ -710,17 +715,21 @@ router.post("/api/chat", (req, res) => {
710
715
  const shimToken = req.headers["x-chat-token"];
711
716
  const bridgeSender = req.headers["x-bridge-sender"];
712
717
  let sender = "user";
718
+ // #788: only an authenticated agent (shim) sender skips its own name; user
719
+ // and bridge messages normalize every name.
720
+ let selfMentionSkip = null;
713
721
  if (shimSender && shimToken) {
714
722
  if (!fileChat.validateShimToken(projectId, shimSender, shimToken)) {
715
723
  return res.status(403).json({ error: "Invalid shim token" });
716
724
  }
717
725
  sender = shimSender;
726
+ selfMentionSkip = shimSender;
718
727
  } else if (bridgeSender && isLocalhost(req.ip)) {
719
728
  sender = bridgeSender;
720
729
  }
721
730
  const msg = fileChat.appendMessage(projectId, {
722
731
  sender,
723
- text: normalizeMentions(text),
732
+ text: normalizeMentions(text, selfMentionSkip),
724
733
  channel: req.body?.channel || "general",
725
734
  type: "message",
726
735
  });
@@ -850,14 +859,30 @@ router.get("/api/projects", async (req, res) => {
850
859
  async function fetchProjectGhData(p) {
851
860
  let openPrs = 0;
852
861
  let lastActivity = null;
862
+ // #812: parked (idle) project — no gh calls; return zero/last-known metadata.
863
+ if (p.idle) {
864
+ const hasAgentsIdle = p.agents && Object.keys(p.agents).length > 0;
865
+ return {
866
+ id: p.id,
867
+ name: p.name,
868
+ repo: p.repo,
869
+ agentCount: hasAgentsIdle ? Object.keys(p.agents).length : 0,
870
+ openPrs: 0,
871
+ state: "idle",
872
+ lastActivity: null,
873
+ _idle: true,
874
+ };
875
+ }
853
876
  if (REPO_RE.test(p.repo)) {
854
877
  try {
855
- const [prs, recentPrs] = await Promise.allSettled([
856
- ghJsonExecAsync(["pr", "list", "-R", p.repo, "--json", "number", "--limit", "100"]),
857
- ghJsonExecAsync(["pr", "list", "-R", p.repo, "--state", "all", "--json", "updatedAt", "--limit", "1"]),
878
+ // #806: REST + ETag instead of `gh pr list` (GraphQL-backed). Open-PR
879
+ // count + latest cross-state PR activity; both conditional (mostly 304).
880
+ const [prs, recentPrs] = await Promise.all([
881
+ ghApiConditional(`${p.repo}#projects-open-pulls`, `repos/${p.repo}/pulls?state=open&per_page=100`),
882
+ ghApiConditional(`${p.repo}#projects-last-activity`, `repos/${p.repo}/pulls?state=all&sort=updated&direction=desc&per_page=1`),
858
883
  ]);
859
- if (prs.status === "fulfilled") openPrs = prs.value.length;
860
- if (recentPrs.status === "fulfilled") lastActivity = recentPrs.value[0]?.updatedAt || null;
884
+ if (Array.isArray(prs.data)) openPrs = prs.data.length;
885
+ if (Array.isArray(recentPrs.data) && recentPrs.data[0]) lastActivity = recentPrs.data[0].updated_at || null;
861
886
  } catch {}
862
887
  }
863
888
  const hasAgents = p.agents && Object.keys(p.agents).length > 0;
@@ -937,11 +962,25 @@ function getRepo(projectId) {
937
962
  }
938
963
  }
939
964
 
940
- // ─── #703: Batched GraphQL layer ──────────────────────────────────────────
941
- // Instead of spawning individual `gh issue list` / `gh pr list` subprocesses
942
- // per project per endpoint, we fetch ALL configured projects' GitHub data in
943
- // a single GraphQL query. The per-project endpoints read from this shared
944
- // cache, falling back to individual gh CLI calls if GraphQL fails.
965
+ // #812: per-project Idle toggle. When a project is idle, QuadWork must
966
+ // initiate ZERO project-specific GitHub/API activity for it. Callers
967
+ // (board fetch, per-endpoint handlers, batch-progress, /api/projects)
968
+ // check this before issuing any gh call.
969
+ function isProjectIdle(projectId) {
970
+ if (!projectId) return false;
971
+ try {
972
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
973
+ return !!cfg.projects?.find((p) => p.id === projectId)?.idle;
974
+ } catch {
975
+ return false;
976
+ }
977
+ }
978
+
979
+ // ─── #703 / #806: shared board cache ──────────────────────────────────────
980
+ // `_graphqlCache` holds each repo's assembled board snapshot. As of #806 it is
981
+ // populated by the REST+ETag fetcher (githubStateFetcher / refreshRepoRest);
982
+ // the batched GraphQL query below (fetchAllProjectsGraphQL) remains ONLY as an
983
+ // emergency fallback, gated on the GraphQL budget. Name kept for minimal churn.
945
984
 
946
985
  const _graphqlCache = new Map(); // repo → { ts, issues, prs, closedIssues, mergedPrs }
947
986
  const GRAPHQL_CACHE_TTL = 60_000; // same as GH_ENDPOINT_CACHE_TTL
@@ -949,6 +988,676 @@ let _graphqlRefreshInFlight = false;
949
988
 
950
989
  const RECENT_FETCH_LIMIT = 20;
951
990
  const RECENT_DISPLAY_LIMIT = 5;
991
+ // #828 P3: max state=closed pages (×100) to scan for the latest merged PRs when
992
+ // a burst of recently-closed *unmerged* PRs pushes real merges past page 1.
993
+ const MERGED_PAGE_CAP = 3;
994
+
995
+ // ─── #806 (#805 step 1): REST + ETag GitHub state fetcher ──────────────────
996
+ // The dashboard board was fed by `gh pr list`/`gh issue list --json
997
+ // reviewDecision,statusCheckRollup,...`, which are GraphQL-backed under the
998
+ // hood — draining GitHub's GraphQL points budget on every poll. This fetcher
999
+ // acquires the same state via plain REST (`gh api repos/...`) with conditional
1000
+ // If-None-Match requests, so steady-state polling costs ~0 (304s are free and
1001
+ // don't count against the budget). It feeds the existing _ghEndpointCache /
1002
+ // _graphqlCache, and emits a single fully-assembled snapshot per pass.
1003
+
1004
+ // ETag store: etagKey → { etag, data } (the RAW REST JSON, re-mapped per pass).
1005
+ const _etagStore = new Map();
1006
+ // Per-repo in-flight refresh: repo → Promise. Concurrent callers AWAIT the same
1007
+ // refresh (not just skip it) so a cold first load — where GitHubPanel fires the
1008
+ // four list endpoints in parallel — never races ahead of the populated cache.
1009
+ const _restRefreshing = new Map();
1010
+
1011
+ // Split a `gh api -i` response into its header block and JSON body. gh emits
1012
+ // HTTP headers (CRLF) then a blank line then the body.
1013
+ function _parseGhInclude(raw) {
1014
+ const sep = raw.search(/\r?\n\r?\n/);
1015
+ if (sep === -1) return { headerBlock: raw, body: "" };
1016
+ return {
1017
+ headerBlock: raw.slice(0, sep),
1018
+ body: raw.slice(sep).replace(/^\r?\n\r?\n/, ""),
1019
+ };
1020
+ }
1021
+
1022
+ // Extract the ETag header value (GitHub sends it as `Etag:`; match any case).
1023
+ function _extractEtag(headerBlock) {
1024
+ const m = headerBlock.match(/^etag:\s*(.+)$/im);
1025
+ return m ? m[1].trim() : null;
1026
+ }
1027
+
1028
+ // gh exits non-zero on a 304; the marker lands in stderr ("gh: HTTP 304") and
1029
+ // the status line in stdout when `-i` is used.
1030
+ function _is304Error(err) {
1031
+ const blob = `${(err && err.stderr) || ""}${(err && err.stdout) || ""}`;
1032
+ return /HTTP 304|304 Not Modified/.test(blob);
1033
+ }
1034
+
1035
+ // Conditional `gh api` GET. Reuses the stored ETag; on 304 returns the cached
1036
+ // payload (status "unchanged", zero budget cost). Never throws — a hard
1037
+ // failure returns the last good payload (if any) with status "error".
1038
+ async function ghApiConditional(etagKey, apiPath) {
1039
+ const prev = _etagStore.get(etagKey);
1040
+ const args = ["api", apiPath, "-i"];
1041
+ if (prev && prev.etag) args.push("-H", `If-None-Match: ${prev.etag}`);
1042
+ try {
1043
+ const { stdout } = await _execFileAsync("gh", args, { encoding: "utf-8", timeout: 15000, maxBuffer: GH_LIST_MAX_BUFFER });
1044
+ const { headerBlock, body } = _parseGhInclude(stdout);
1045
+ const etag = _extractEtag(headerBlock);
1046
+ let data = null;
1047
+ try { data = body ? JSON.parse(body) : null; } catch { data = null; }
1048
+ _etagStore.set(etagKey, { etag, data });
1049
+ return { status: "ok", data, changed: true };
1050
+ } catch (err) {
1051
+ if (_is304Error(err) && prev) {
1052
+ return { status: "unchanged", data: prev.data, changed: false };
1053
+ }
1054
+ return { status: "error", data: prev ? prev.data : null, changed: false };
1055
+ }
1056
+ }
1057
+
1058
+ // Coalesce concurrent async work by key: a caller arriving while a call for the
1059
+ // same key is in flight awaits the SAME promise (so it sees the result, not a
1060
+ // premature miss); a fresh call starts only after the prior one settles.
1061
+ function _coalesce(map, key, fn) {
1062
+ const inflight = map.get(key);
1063
+ if (inflight) return inflight;
1064
+ // Invoke eagerly so the work starts now; register before any await of fn can
1065
+ // resolve so same-tick concurrent callers join this exact promise.
1066
+ const p = (async () => fn())().finally(() => map.delete(key));
1067
+ map.set(key, p);
1068
+ return p;
1069
+ }
1070
+
1071
+ // Run `fn` over `items` with at most `limit` concurrent in flight (keeps the
1072
+ // per-PR fan-out from draining the core budget). Preserves input order.
1073
+ async function _mapLimited(items, limit, fn) {
1074
+ const results = new Array(items.length);
1075
+ let next = 0;
1076
+ async function worker() {
1077
+ while (next < items.length) {
1078
+ const idx = next++;
1079
+ results[idx] = await fn(items[idx], idx);
1080
+ }
1081
+ }
1082
+ const n = Math.max(1, Math.min(limit, items.length));
1083
+ await Promise.all(Array.from({ length: n }, worker));
1084
+ return results;
1085
+ }
1086
+
1087
+ // ── Canonical dashboard shape (pinned + tested; do NOT defer to "same as
1088
+ // GraphQL"). issue/PR `state` is UPPERCASE OPEN/CLOSED/MERGED — matches the
1089
+ // gh-CLI path and GitHubPanel.tsx's `state === "OPEN"` dot (the prior GraphQL
1090
+ // transform lowercased to "open", an inconsistency this pins). ──
1091
+ function restIssueToCanonical(it) {
1092
+ return {
1093
+ number: it.number,
1094
+ title: it.title,
1095
+ state: (it.state || "").toUpperCase(),
1096
+ url: it.html_url,
1097
+ labels: (it.labels || []).map((l) => ({ name: typeof l === "string" ? l : l.name })),
1098
+ assignees: (it.assignees || []).map((a) => ({ login: a.login })),
1099
+ createdAt: it.created_at,
1100
+ };
1101
+ }
1102
+
1103
+ function restClosedIssueToCanonical(it) {
1104
+ return {
1105
+ number: it.number,
1106
+ title: it.title,
1107
+ state: (it.state || "closed").toUpperCase(),
1108
+ url: it.html_url,
1109
+ closedAt: it.closed_at,
1110
+ };
1111
+ }
1112
+
1113
+ function restMergedPrToCanonical(p) {
1114
+ return {
1115
+ number: p.number,
1116
+ title: p.title,
1117
+ state: "MERGED",
1118
+ url: p.html_url,
1119
+ mergedAt: p.merged_at,
1120
+ author: p.user ? { login: p.user.login } : null,
1121
+ };
1122
+ }
1123
+
1124
+ // Base open-PR row; reviews/reviewDecision/statusCheckRollup are attached after
1125
+ // the per-PR sub-fetches.
1126
+ function restPullBaseToCanonical(p) {
1127
+ return {
1128
+ number: p.number,
1129
+ title: p.title,
1130
+ state: (p.state || "").toUpperCase(),
1131
+ url: p.html_url,
1132
+ author: p.user ? { login: p.user.login } : null,
1133
+ assignees: (p.assignees || []).map((a) => ({ login: a.login })),
1134
+ createdAt: p.created_at,
1135
+ };
1136
+ }
1137
+
1138
+ // Keep the FULL review list with bodies — re1/re2 share one GitHub author, so
1139
+ // #807/#810 attribute ROLE from body markers, not author-latest collapse.
1140
+ function mapReviews(restReviews) {
1141
+ return (restReviews || []).map((r) => ({
1142
+ state: r.state,
1143
+ author: r.user ? { login: r.user.login } : null,
1144
+ submittedAt: r.submitted_at,
1145
+ body: r.body || "",
1146
+ }));
1147
+ }
1148
+
1149
+ // Derive a reviewDecision from the raw review list (the per-PR reviews are
1150
+ // authoritative). Latest decision-affecting review per author wins; any
1151
+ // CHANGES_REQUESTED blocks, else any APPROVED approves, else REVIEW_REQUIRED.
1152
+ function deriveReviewDecision(reviews) {
1153
+ const byAuthor = new Map();
1154
+ const sorted = (reviews || []).slice().sort(
1155
+ (a, b) => (Date.parse(a && a.submittedAt) || 0) - (Date.parse(b && b.submittedAt) || 0),
1156
+ );
1157
+ for (const r of sorted) {
1158
+ const login = r && r.author && r.author.login;
1159
+ if (!login) continue;
1160
+ if (r.state === "APPROVED" || r.state === "CHANGES_REQUESTED" || r.state === "DISMISSED") {
1161
+ byAuthor.set(login, r.state);
1162
+ }
1163
+ }
1164
+ let approved = false;
1165
+ for (const s of byAuthor.values()) {
1166
+ if (s === "CHANGES_REQUESTED") return "CHANGES_REQUESTED";
1167
+ if (s === "APPROVED") approved = true;
1168
+ }
1169
+ return approved ? "APPROVED" : "REVIEW_REQUIRED";
1170
+ }
1171
+
1172
+ function _normCheckRunState(run) {
1173
+ if (run.status && run.status !== "completed") return "PENDING";
1174
+ switch ((run.conclusion || "").toLowerCase()) {
1175
+ case "success": return "SUCCESS";
1176
+ case "failure": case "timed_out": case "startup_failure": return "FAILURE";
1177
+ case "cancelled": case "action_required": case "stale": return "ERROR";
1178
+ case "neutral": case "skipped": return "SUCCESS";
1179
+ default: return "PENDING";
1180
+ }
1181
+ }
1182
+
1183
+ function _normStatusState(state) {
1184
+ switch ((state || "").toLowerCase()) {
1185
+ case "success": return "SUCCESS";
1186
+ case "failure": return "FAILURE";
1187
+ case "error": return "ERROR";
1188
+ default: return "PENDING";
1189
+ }
1190
+ }
1191
+
1192
+ // statusCheckRollup = check-runs AND the legacy combined-status, normalized to
1193
+ // the { state } array GitHubPanel.tsx's ciColor/ciLabel expect (SUCCESS /
1194
+ // FAILURE / ERROR / PENDING).
1195
+ function buildStatusCheckRollup(checkRunsResp, statusResp) {
1196
+ const rollup = [];
1197
+ const runs = (checkRunsResp && checkRunsResp.check_runs) || [];
1198
+ for (const r of runs) rollup.push({ state: _normCheckRunState(r) });
1199
+ const statuses = (statusResp && statusResp.statuses) || [];
1200
+ for (const s of statuses) rollup.push({ state: _normStatusState(s.state) });
1201
+ return rollup;
1202
+ }
1203
+
1204
+ // Fetch a repo's full board state via REST + ETag in one pass. Returns a single
1205
+ // fully-assembled snapshot (never section-by-section) plus a status the file
1206
+ // writer (#807) can trust: "ok" (something changed), "unchanged" (all 304s),
1207
+ // or "error" (could not assemble any list — never stamp this as fresh).
1208
+ // #828 P3: page through state=closed (newest-updated first) until we have
1209
+ // ≥RECENT_DISPLAY_LIMIT merged PRs or hit the page cap — a burst of recently-
1210
+ // closed *unmerged* PRs could otherwise push real merges past a single page and
1211
+ // under-report "Recently merged". Page 1 is fetched in parallel with the other
1212
+ // top lists and passed in; further pages are fetched on demand only when page 1
1213
+ // is FULL (more pages may exist) AND short on merges. `fetchPage(page)` returns
1214
+ // the same { status, data } shape as ghApiConditional. Pure control flow over
1215
+ // an injected fetcher → unit-testable without network.
1216
+ async function gatherClosedPrPages(firstPage, fetchPage) {
1217
+ const pages = [firstPage];
1218
+ const mergedCount = (r) => (Array.isArray(r && r.data) ? r.data : []).filter((p) => p.merged_at).length;
1219
+ let mergedSoFar = mergedCount(firstPage);
1220
+ let lastLen = Array.isArray(firstPage && firstPage.data) ? firstPage.data.length : 0;
1221
+ for (let page = 2; page <= MERGED_PAGE_CAP && mergedSoFar < RECENT_DISPLAY_LIMIT && lastLen === 100; page++) {
1222
+ const r = await fetchPage(page);
1223
+ pages.push(r);
1224
+ mergedSoFar += mergedCount(r);
1225
+ lastLen = Array.isArray(r && r.data) ? r.data.length : 0;
1226
+ }
1227
+ return pages;
1228
+ }
1229
+
1230
+ // The latest RECENT_DISPLAY_LIMIT merged PRs across the gathered closed-PR
1231
+ // pages, newest merge first. Pure → unit-testable.
1232
+ function selectRecentMergedPrs(pages) {
1233
+ return pages
1234
+ .flatMap((r) => (Array.isArray(r && r.data) ? r.data : []))
1235
+ .filter((p) => p.merged_at)
1236
+ .sort((a, b) => (Date.parse(b && b.merged_at) || 0) - (Date.parse(a && a.merged_at) || 0))
1237
+ .slice(0, RECENT_DISPLAY_LIMIT)
1238
+ .map(restMergedPrToCanonical);
1239
+ }
1240
+
1241
+ // #834: whether the closed-PR pagination reached the ACTUAL end of the list —
1242
+ // the ONLY proof that no further closed/merged PR exists beyond what we scanned.
1243
+ // True iff the LAST fetched page came back reliably (not an "error") AND had
1244
+ // < 100 items (a genuine final page; a full 100 means more may exist). This is
1245
+ // deliberately stricter than "the loop stopped": stopping early because the
1246
+ // display need was met (≥5 merged) or hitting MERGED_PAGE_CAP while pages were
1247
+ // still full does NOT prove completeness — those leave a full (===100) last
1248
+ // page, so this returns false and the caller falls back to by-number. A 304
1249
+ // carries the unchanged real data, so a short (<100) 304 page is a genuine end
1250
+ // too; only "error" pages (possibly stale/null data) can't prove it. Pure.
1251
+ function closedPagesComplete(pages) {
1252
+ const last = pages[pages.length - 1];
1253
+ return !!last && last.status !== "error" && Array.isArray(last.data) && last.data.length < 100;
1254
+ }
1255
+
1256
+ // #834: issue numbers linked by the team's `[#N]` PR-title convention across
1257
+ // ALL fetched closed-PR pages (not just the 5 displayed merged). Lets
1258
+ // progressFromSnapshot prove an OPEN issue has no closed/merged linked PR —
1259
+ // even one merged/closed outside the recent-5 window — before classifying it
1260
+ // queued. Pure → unit-testable.
1261
+ function closedPrIssueNumsFromPages(pages) {
1262
+ const re = /^\s*\[#(\d{1,7})\]/;
1263
+ const nums = [];
1264
+ for (const r of pages) {
1265
+ const arr = Array.isArray(r && r.data) ? r.data : [];
1266
+ for (const p of arr) {
1267
+ const m = ((p && p.title) || "").match(re);
1268
+ if (m) nums.push(parseInt(m[1], 10));
1269
+ }
1270
+ }
1271
+ return nums;
1272
+ }
1273
+
1274
+ async function githubStateFetcher(repo) {
1275
+ const [owner, name] = (repo || "").split("/");
1276
+ if (!owner || !name) return { status: "error", data: null };
1277
+ const base = `repos/${owner}/${name}`;
1278
+ let changed = 0;
1279
+
1280
+ const [openPullsR, openIssuesR, closedIssuesR, closedPulls1R] = await Promise.all([
1281
+ ghApiConditional(`${repo}#pulls-open`, `${base}/pulls?state=open&per_page=50&sort=updated&direction=desc`),
1282
+ ghApiConditional(`${repo}#issues-open`, `${base}/issues?state=open&per_page=50&sort=updated&direction=desc`),
1283
+ ghApiConditional(`${repo}#issues-closed`, `${base}/issues?state=closed&per_page=100&sort=updated&direction=desc`),
1284
+ ghApiConditional(`${repo}#pulls-closed`, `${base}/pulls?state=closed&per_page=100&sort=updated&direction=desc`),
1285
+ ]);
1286
+
1287
+ // #828 P3: extend the closed-PR window across pages only when needed (each
1288
+ // page conditional/ETag'd, so steady-state extra pages cost nothing on a 304).
1289
+ const closedPullPages = await gatherClosedPrPages(closedPulls1R, (page) =>
1290
+ ghApiConditional(`${repo}#pulls-closed-p${page}`, `${base}/pulls?state=closed&per_page=100&sort=updated&direction=desc&page=${page}`),
1291
+ );
1292
+
1293
+ let anyTopData = false;
1294
+ for (const r of [openPullsR, openIssuesR, closedIssuesR, ...closedPullPages]) {
1295
+ if (r.status === "ok") changed++;
1296
+ if (Array.isArray(r.data)) anyTopData = true;
1297
+ }
1298
+ // No list data at all (first run, every call failed) → don't fabricate a
1299
+ // snapshot; let callers keep serving whatever they already had.
1300
+ if (!anyTopData) return { status: "error", data: null };
1301
+
1302
+ // REST /issues includes PRs — drop anything carrying a pull_request key.
1303
+ const issues = (Array.isArray(openIssuesR.data) ? openIssuesR.data : [])
1304
+ .filter((it) => !it.pull_request)
1305
+ .map(restIssueToCanonical);
1306
+
1307
+ const closedIssues = (Array.isArray(closedIssuesR.data) ? closedIssuesR.data : [])
1308
+ .filter((it) => !it.pull_request)
1309
+ .sort((a, b) => (Date.parse(b && b.closed_at) || 0) - (Date.parse(a && a.closed_at) || 0))
1310
+ .slice(0, RECENT_DISPLAY_LIMIT)
1311
+ .map(restClosedIssueToCanonical);
1312
+
1313
+ const mergedPrs = selectRecentMergedPrs(closedPullPages);
1314
+
1315
+ // #828 P1: record whether the open-PR board window is PROVABLY complete — we
1316
+ // asked for 50 and got fewer, so we've seen EVERY open PR. progressFrom-
1317
+ // Snapshot relies on this to classify a no-matching-PR open issue as queued
1318
+ // without a by-number fetch; if exactly 50 came back the window may be
1319
+ // truncated and it must NOT guess.
1320
+ const openPrsWindowComplete = Array.isArray(openPullsR.data) && openPullsR.data.length < 50;
1321
+ // #834: queued-from-snapshot also requires proving NO closed/merged linked PR
1322
+ // exists. closedPrsWindowComplete = the closed-PR pagination reached an actual
1323
+ // <100 end (not just stopped early); closedPrIssueNums = every [#N] across ALL
1324
+ // scanned closed pages, so a PR merged/closed outside the recent-5 window
1325
+ // still blocks a false "queued". Both are consumed by progressFromSnapshot.
1326
+ const closedPrsWindowComplete = closedPagesComplete(closedPullPages);
1327
+ const closedPrIssueNums = closedPrIssueNumsFromPages(closedPullPages);
1328
+
1329
+ const openPullsRaw = Array.isArray(openPullsR.data) ? openPullsR.data : [];
1330
+ const prs = await _mapLimited(openPullsRaw, GH_MAX_CONCURRENT, async (p) => {
1331
+ const sha = p.head && p.head.sha;
1332
+ const [reviewsR, checkRunsR, statusR] = await Promise.all([
1333
+ ghApiConditional(`${repo}#reviews-${p.number}`, `${base}/pulls/${p.number}/reviews?per_page=100`),
1334
+ sha ? ghApiConditional(`${repo}#checkruns-${sha}`, `${base}/commits/${sha}/check-runs?per_page=100`) : Promise.resolve({ status: "error", data: null }),
1335
+ sha ? ghApiConditional(`${repo}#status-${sha}`, `${base}/commits/${sha}/status`) : Promise.resolve({ status: "error", data: null }),
1336
+ ]);
1337
+ if (reviewsR.status === "ok" || checkRunsR.status === "ok" || statusR.status === "ok") changed++;
1338
+ const reviews = mapReviews(Array.isArray(reviewsR.data) ? reviewsR.data : []);
1339
+ return {
1340
+ ...restPullBaseToCanonical(p),
1341
+ reviews,
1342
+ reviewDecision: deriveReviewDecision(reviews),
1343
+ statusCheckRollup: buildStatusCheckRollup(checkRunsR.data, statusR.data),
1344
+ };
1345
+ });
1346
+
1347
+ return {
1348
+ status: changed > 0 ? "ok" : "unchanged",
1349
+ data: { issues, prs, closedIssues, mergedPrs, openPrsWindowComplete, closedPrsWindowComplete, closedPrIssueNums },
1350
+ };
1351
+ }
1352
+
1353
+ // Refresh a single repo's REST snapshot into the shared caches. Coalesced: a
1354
+ // concurrent caller (e.g. the parallel cold-load of all four list endpoints, or
1355
+ // a background stale refresh overlapping an on-demand one) joins and awaits the
1356
+ // SAME in-flight pass, so by the time it resolves the slice caches are written.
1357
+ function refreshRepoRest(repo) {
1358
+ return _coalesce(_restRefreshing, repo, async () => {
1359
+ let result;
1360
+ try {
1361
+ const { status, data } = await githubStateFetcher(repo);
1362
+ if (data) {
1363
+ const now = Date.now();
1364
+ _graphqlCache.set(repo, { ts: now, ...data });
1365
+ _ghEndpointCache.set(`issues:${repo}`, { ts: now, data: data.issues, stale: false });
1366
+ _ghEndpointCache.set(`prs:${repo}`, { ts: now, data: data.prs, stale: false });
1367
+ _ghEndpointCache.set(`closed-issues:${repo}`, { ts: now, data: data.closedIssues, stale: false });
1368
+ _ghEndpointCache.set(`merged-prs:${repo}`, { ts: now, data: data.mergedPrs, stale: false });
1369
+ }
1370
+ result = { status, data };
1371
+ } catch {
1372
+ result = { status: "error", data: null };
1373
+ }
1374
+ // #807: the server is the sole author of GITHUB.md's machine sections —
1375
+ // regenerate it from this completed pass's snapshot (success or error so
1376
+ // staleCycles advances). Non-fatal; never affects the board cache result.
1377
+ try { syncGithubFilesForRepo(repo, result.status); } catch { /* non-fatal */ }
1378
+ return result;
1379
+ });
1380
+ }
1381
+
1382
+ // ─── #807 (#805 step 2): server-authored GITHUB.md ────────────────────────
1383
+ // Per-project GITHUB.md mirrors OVERNIGHT-QUEUE.md: the server is the sole
1384
+ // author of the machine sections (built from #806's structured snapshot),
1385
+ // agents read the file for discovery. Section bodies are injection-safe and
1386
+ // strictly parseable (parseGithub). The `## Notes` block is human/HEAD-writable
1387
+ // and preserved across regeneration.
1388
+
1389
+ // Per-project freshness: projectId → { generatedAt(ms), staleCycles }.
1390
+ const _githubMeta = new Map();
1391
+ // #807: FIXED freshness window for the live `_stale` signal — must NOT use
1392
+ // adaptiveTTL, which returns Infinity under critical rate-limit (the exact case
1393
+ // where no sync pass runs and staleness is unbounded). ~5 poll cycles.
1394
+ const GITHUB_FRESH_TTL_MS = 5 * 60_000;
1395
+
1396
+ // Live staleness for /api/github-parsed: stale if the file says so, if we have
1397
+ // no generatedAt, or if the snapshot is older than the FIXED window. Pure so
1398
+ // the rate-limit edge case (unbounded age) is regression-tested directly.
1399
+ // #827: a NaN ageMs (Date.parse of a malformed/corrupted `generatedAt`) must
1400
+ // count as stale/unknown — NOT fresh. Without the guard, `NaN > window` is
1401
+ // false and staleness would be hidden exactly when the file is suspect.
1402
+ function _githubLiveStale(headerStale, ageMs) {
1403
+ return headerStale || ageMs == null || Number.isNaN(ageMs) || ageMs > GITHUB_FRESH_TTL_MS;
1404
+ }
1405
+ const GITHUB_SECTION_HEADERS = {
1406
+ openIssues: "Open Issues",
1407
+ openPRs: "Open PRs",
1408
+ closedIssues: "Recently Closed Issues",
1409
+ mergedPrs: "Recently Merged PRs",
1410
+ };
1411
+
1412
+ // Collapse untrusted text to a single safe line so it can NEVER begin a new
1413
+ // `## ` section marker or break the strict list-line grammar. Also strips the
1414
+ // field delimiter (·) we use, and `[`/`]` that bound the assignees field.
1415
+ function _sanitizeInline(s) {
1416
+ return String(s == null ? "" : s)
1417
+ .replace(/[\r\n]+/g, " ")
1418
+ .replace(/[·\[\]]/g, " ")
1419
+ .replace(/\s+/g, " ")
1420
+ .trim();
1421
+ }
1422
+
1423
+ function _renderAssignees(assignees) {
1424
+ // Empty → "" so the rendered `[]` round-trips back to an empty array (rather
1425
+ // than a phantom assignee literally named "none").
1426
+ const logins = (assignees || []).map((a) => a && a.login).filter(Boolean).map(_sanitizeInline);
1427
+ return logins.map((l) => `@${l}`).join(", ");
1428
+ }
1429
+
1430
+ // One strict list line: `- [#N](url) · STATE · [assignees] · title`. The
1431
+ // free-text title is LAST (the parser reads it as the line tail), and url is
1432
+ // whitespace-free, so neither can corrupt the earlier fixed fields.
1433
+ function _renderListItem(item) {
1434
+ const url = _sanitizeInline(item.url).replace(/\s/g, "") || "-";
1435
+ const state = _sanitizeInline(item.state).toUpperCase() || "OPEN";
1436
+ return `- [#${item.number}](${url}) · ${state} · [${_renderAssignees(item.assignees)}] · ${_sanitizeInline(item.title)}`;
1437
+ }
1438
+
1439
+ function _renderSection(items, renderLine) {
1440
+ if (!items || items.length === 0) return "(none)";
1441
+ return items.map(renderLine).join("\n");
1442
+ }
1443
+
1444
+ // Latest review per body-marker ROLE (re1/re2) across the shared author's full
1445
+ // review list — GitHub login can't distinguish the reviewers (same account), so
1446
+ // we match RE1/RE2/Reviewer1/Reviewer2/T2a/T2b (and `## Verdict`→re1) prefixes
1447
+ // in the review BODY. A marker-less/ambiguous review is UNCOUNTED (never
1448
+ // over-counts). Mirrors GitHubPanel.tsx's body-marker logic.
1449
+ function attributeReviewsByRole(reviews) {
1450
+ const sorted = (reviews || []).slice().sort(
1451
+ (a, b) => (Date.parse(a && a.submittedAt) || 0) - (Date.parse(b && b.submittedAt) || 0),
1452
+ );
1453
+ const out = { re1: null, re2: null };
1454
+ for (const r of sorted) {
1455
+ const body = ((r && r.body) || "").trim();
1456
+ let role = null;
1457
+ if (/^(?:RE2|Reviewer2|T2b)\b/i.test(body)) role = "re2";
1458
+ else if (/^(?:RE1|Reviewer1|T2a)\b/i.test(body) || /^##\s*Verdict/i.test(body)) role = "re1";
1459
+ if (!role) continue; // ambiguous → uncounted
1460
+ out[role] = { state: r.state, submittedAt: r.submittedAt || null };
1461
+ }
1462
+ return out;
1463
+ }
1464
+
1465
+ function _renderReviewDetail(prs) {
1466
+ const lines = [];
1467
+ for (const pr of prs || []) {
1468
+ const roles = attributeReviewsByRole(pr.reviews);
1469
+ for (const role of ["re1", "re2"]) {
1470
+ const rv = roles[role];
1471
+ if (!rv) continue;
1472
+ lines.push(`- #${pr.number} · ${role}:${_sanitizeInline(rv.state).toUpperCase()} · ${_sanitizeInline(rv.submittedAt) || "-"}`);
1473
+ }
1474
+ }
1475
+ return lines.length ? lines.join("\n") : "(none)";
1476
+ }
1477
+
1478
+ // Build the full GITHUB.md from a structured snapshot. Pure (no I/O). `notesBody`
1479
+ // is the preserved `## Notes` text. `meta` = { generatedAt(ms), staleCycles }.
1480
+ function renderGithubMarkdown(projectName, repo, snapshot, meta, notesBody) {
1481
+ const snap = snapshot || {};
1482
+ const generatedAt = meta && meta.generatedAt ? new Date(meta.generatedAt).toISOString() : "never";
1483
+ const staleCycles = (meta && meta.staleCycles) || 0;
1484
+ const stale = staleCycles > 0 || !meta || !meta.generatedAt;
1485
+ return [
1486
+ `# ${projectName || ""} — GitHub State`,
1487
+ "",
1488
+ `> **Repo:** ${repo || ""}`,
1489
+ `> **Generated:** ${generatedAt} · staleCycles: ${staleCycles} · stale: ${stale}`,
1490
+ ">",
1491
+ "> Machine-generated by QuadWork — do NOT edit the sections below (overwritten",
1492
+ "> on every sync). Edit only `## Notes`.",
1493
+ "",
1494
+ "---",
1495
+ "",
1496
+ `## ${GITHUB_SECTION_HEADERS.openIssues}`,
1497
+ "",
1498
+ _renderSection(snap.issues, _renderListItem),
1499
+ "",
1500
+ "---",
1501
+ "",
1502
+ `## ${GITHUB_SECTION_HEADERS.openPRs}`,
1503
+ "",
1504
+ _renderSection(snap.prs, _renderListItem),
1505
+ "",
1506
+ "---",
1507
+ "",
1508
+ `## ${GITHUB_SECTION_HEADERS.closedIssues}`,
1509
+ "",
1510
+ _renderSection(snap.closedIssues, _renderListItem),
1511
+ "",
1512
+ "---",
1513
+ "",
1514
+ `## ${GITHUB_SECTION_HEADERS.mergedPrs}`,
1515
+ "",
1516
+ _renderSection(snap.mergedPrs, _renderListItem),
1517
+ "",
1518
+ "---",
1519
+ "",
1520
+ "## Review Detail",
1521
+ "",
1522
+ _renderReviewDetail(snap.prs),
1523
+ "",
1524
+ "---",
1525
+ "",
1526
+ "## Notes",
1527
+ "",
1528
+ (notesBody && notesBody.trim()) ? notesBody.trim() : "(none)",
1529
+ "",
1530
+ ].join("\n");
1531
+ }
1532
+
1533
+ const _GITHUB_DEFAULT_NOTES =
1534
+ "_Advisory only. The Review Detail above is body-marker attribution (re1/re2),\n" +
1535
+ "not merge-authoritative — Head re-checks live before merging. This section is\n" +
1536
+ "human/Head-writable and is preserved across regeneration._";
1537
+
1538
+ // Extract the existing `## Notes` body (preserved verbatim across regeneration).
1539
+ // Anchored to line start (multiline) so it matches the real `## Notes` SECTION,
1540
+ // not the `` `## Notes` `` reference in the header instructions. Notes is the
1541
+ // last section, so we capture to EOF and do NOT strip trailing `## ` lines —
1542
+ // that lets a human note contain its own `## ` headings without truncation.
1543
+ function _extractNotesBody(text) {
1544
+ const m = (text || "").match(/^##\s+Notes\b[^\n]*\n([\s\S]*)$/im);
1545
+ if (!m) return _GITHUB_DEFAULT_NOTES;
1546
+ const body = m[1].trim();
1547
+ return body || _GITHUB_DEFAULT_NOTES;
1548
+ }
1549
+
1550
+ // Write one project's GITHUB.md atomically (temp + rename) from a snapshot,
1551
+ // preserving its `## Notes`. status drives the freshness meta.
1552
+ function writeGithubFileFromSnapshot(projectId, projectName, repo, snapshot, status) {
1553
+ try {
1554
+ // #827: re-check idle here (reads config fresh) — syncGithubFilesForRepo
1555
+ // filtered on a config snapshot taken earlier, so an idle-flip concurrent
1556
+ // with a setup-seed/refresh pass could otherwise still write a parked
1557
+ // project's GITHUB.md. Defensive; idle projects must author nothing.
1558
+ if (isProjectIdle(projectId)) return;
1559
+ const dir = path.join(CONFIG_DIR, projectId);
1560
+ const filePath = path.join(dir, "GITHUB.md");
1561
+ // Cold + total failure with nothing cached: leave the seeded template be.
1562
+ if (!snapshot && status === "error") return;
1563
+ ensureSecureDir(dir);
1564
+
1565
+ let notesBody = _GITHUB_DEFAULT_NOTES;
1566
+ try { notesBody = _extractNotesBody(fs.readFileSync(filePath, "utf-8")); } catch { /* seed defaults */ }
1567
+
1568
+ const meta = _githubMeta.get(projectId) || { generatedAt: 0, staleCycles: 0 };
1569
+ if (status === "error") {
1570
+ meta.staleCycles += 1; // keep last generatedAt — never stamp a failed cycle fresh
1571
+ } else {
1572
+ meta.generatedAt = Date.now();
1573
+ meta.staleCycles = 0;
1574
+ }
1575
+ _githubMeta.set(projectId, meta);
1576
+
1577
+ const content = renderGithubMarkdown(projectName, repo, snapshot, meta, notesBody);
1578
+ const tmp = path.join(dir, `.GITHUB.md.tmp-${process.pid}`);
1579
+ fs.writeFileSync(tmp, content, { mode: 0o600 });
1580
+ fs.renameSync(tmp, filePath); // atomic — readers never see partial content
1581
+ } catch { /* non-fatal */ }
1582
+ }
1583
+
1584
+ // Regenerate GITHUB.md for every non-idle project bound to this repo, from the
1585
+ // last-good snapshot in _graphqlCache. Idle projects are never regenerated.
1586
+ function syncGithubFilesForRepo(repo, status) {
1587
+ let cfg;
1588
+ try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); } catch { return; }
1589
+ const snapshot = _graphqlCache.get(repo) || null;
1590
+ for (const p of cfg.projects || []) {
1591
+ if (p.repo !== repo || p.idle) continue;
1592
+ writeGithubFileFromSnapshot(p.id, p.name || p.id, repo, snapshot, status);
1593
+ }
1594
+ }
1595
+
1596
+ // ── Strict GITHUB.md parser (mirrors parseActiveBatch). Pure; section-isolating
1597
+ // (stops at the next `## `); list lines must match the exact grammar so prose
1598
+ // `#N` can't leak. Distinguishes a parse error (not our file) from an
1599
+ // empty-but-valid section. Exported for unit tests. ──
1600
+ const _GH_ITEM_RE = /^-\s+\[#(\d{1,7})\]\((\S+)\)\s+·\s+(OPEN|CLOSED|MERGED)\s+·\s+\[([^\]]*)\]\s+·\s+(.*)$/;
1601
+ const _GH_REVIEW_RE = /^-\s+#(\d{1,7})\s+·\s+(re1|re2):([A-Z_]+)\s+·\s+(\S+)$/;
1602
+ const _GH_FRESH_RE = /\*\*Generated:\*\*\s*(\S+)\s*·\s*staleCycles:\s*(\d+)\s*·\s*stale:\s*(true|false)/i;
1603
+
1604
+ function _ghSection(text, header) {
1605
+ const re = new RegExp(`##\\s+${header.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}\\b[\\s\\S]*?(?=\\n##\\s|$)`, "i");
1606
+ const m = text.match(re);
1607
+ return m ? m[0] : null;
1608
+ }
1609
+
1610
+ function _parseGhList(text, header) {
1611
+ const section = _ghSection(text, header);
1612
+ if (section == null) return null; // header missing → distinct from empty
1613
+ const items = [];
1614
+ for (const line of section.split("\n")) {
1615
+ const m = line.match(_GH_ITEM_RE);
1616
+ if (!m) continue; // prose / "(none)" / blanks are skipped
1617
+ const assignees = m[4].split(",").map((s) => s.trim().replace(/^@/, "")).filter(Boolean).map((login) => ({ login }));
1618
+ items.push({ number: parseInt(m[1], 10), url: m[2], state: m[3], assignees, title: m[5].trim() });
1619
+ }
1620
+ return items;
1621
+ }
1622
+
1623
+ function parseGithub(text) {
1624
+ if (typeof text !== "string" || !text.trim()) {
1625
+ return { ok: false, error: "empty or non-string input" };
1626
+ }
1627
+ const openIssues = _parseGhList(text, GITHUB_SECTION_HEADERS.openIssues);
1628
+ const openPRs = _parseGhList(text, GITHUB_SECTION_HEADERS.openPRs);
1629
+ const closedIssues = _parseGhList(text, GITHUB_SECTION_HEADERS.closedIssues);
1630
+ const mergedPrs = _parseGhList(text, GITHUB_SECTION_HEADERS.mergedPrs);
1631
+ // If none of the required machine headers exist, this isn't a GITHUB.md.
1632
+ if (openIssues == null && openPRs == null && closedIssues == null && mergedPrs == null) {
1633
+ return { ok: false, error: "no recognizable GITHUB.md sections" };
1634
+ }
1635
+
1636
+ // Review Detail → { [prNumber]: { re1?: {state,submittedAt}, re2?: {...} } }.
1637
+ const reviewDetail = {};
1638
+ const reviewSection = _ghSection(text, "Review Detail");
1639
+ if (reviewSection) {
1640
+ for (const line of reviewSection.split("\n")) {
1641
+ const m = line.match(_GH_REVIEW_RE);
1642
+ if (!m) continue;
1643
+ const n = parseInt(m[1], 10);
1644
+ (reviewDetail[n] || (reviewDetail[n] = {}))[m[2]] = { state: m[3], submittedAt: m[4] };
1645
+ }
1646
+ }
1647
+
1648
+ const fresh = text.match(_GH_FRESH_RE);
1649
+ return {
1650
+ ok: true,
1651
+ generatedAt: fresh && fresh[1] !== "never" ? fresh[1] : null,
1652
+ staleCycles: fresh ? parseInt(fresh[2], 10) : 0,
1653
+ stale: fresh ? fresh[3].toLowerCase() === "true" : true,
1654
+ openIssues: openIssues || [],
1655
+ openPRs: openPRs || [],
1656
+ closedIssues: closedIssues || [],
1657
+ mergedPrs: mergedPrs || [],
1658
+ reviewDetail,
1659
+ };
1660
+ }
952
1661
 
953
1662
  // Build and execute a batched GraphQL query for all configured projects.
954
1663
  // Returns a Map of repo → { issues, prs, closedIssues, mergedPrs }.
@@ -959,7 +1668,7 @@ async function fetchAllProjectsGraphQL() {
959
1668
  } catch {
960
1669
  return null;
961
1670
  }
962
- const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo));
1671
+ const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo) && !p.idle); // #812: skip idle (parked) projects
963
1672
  if (projects.length === 0) return null;
964
1673
 
965
1674
  // Build aliased repository fields — one per project.
@@ -985,7 +1694,7 @@ fragment repoFields on Repository {
985
1694
  nodes { number title url state closedAt }
986
1695
  }
987
1696
  openPRs: pullRequests(first: 50, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
988
- nodes { number title url state author { login } reviews(last: 100) { nodes { state author { login } submittedAt } } createdAt }
1697
+ nodes { number title url state author { login } reviews(last: 100) { nodes { state author { login } submittedAt body } } createdAt }
989
1698
  }
990
1699
  mergedPRs: pullRequests(first: ${RECENT_FETCH_LIMIT}, states: MERGED, orderBy: {field: UPDATED_AT, direction: DESC}) {
991
1700
  nodes { number title url state mergedAt author { login } }
@@ -995,7 +1704,7 @@ fragment repoFields on Repository {
995
1704
  try {
996
1705
  const { stdout } = await _execFileAsync("gh", [
997
1706
  "api", "graphql", "-f", `query=${query}`,
998
- ], { encoding: "utf-8", timeout: 15000 });
1707
+ ], { encoding: "utf-8", timeout: 15000, maxBuffer: GH_LIST_MAX_BUFFER });
999
1708
  const data = JSON.parse(stdout).data;
1000
1709
  if (!data) return null;
1001
1710
 
@@ -1006,10 +1715,13 @@ fragment repoFields on Repository {
1006
1715
  if (!repoData) continue;
1007
1716
 
1008
1717
  // Transform GraphQL nodes into the same shape as gh CLI JSON output.
1718
+ // #806: pin canonical uppercase state for emergency-fallback parity with
1719
+ // the REST path. reviewDecision/statusCheckRollup are absent here (the
1720
+ // GraphQL query doesn't fetch them) — acceptable for a degraded fallback.
1009
1721
  const issues = (repoData.openIssues?.nodes || []).map((n) => ({
1010
1722
  number: n.number,
1011
1723
  title: n.title,
1012
- state: n.state === "OPEN" ? "open" : n.state?.toLowerCase() || n.state,
1724
+ state: (n.state || "").toUpperCase(),
1013
1725
  url: n.url,
1014
1726
  labels: (n.labels?.nodes || []).map((l) => ({ name: l.name })),
1015
1727
  assignees: (n.assignees?.nodes || []).map((a) => ({ login: a.login })),
@@ -1019,7 +1731,7 @@ fragment repoFields on Repository {
1019
1731
  const prs = (repoData.openPRs?.nodes || []).map((n) => ({
1020
1732
  number: n.number,
1021
1733
  title: n.title,
1022
- state: n.state === "OPEN" ? "open" : n.state?.toLowerCase() || n.state,
1734
+ state: (n.state || "").toUpperCase(),
1023
1735
  url: n.url,
1024
1736
  author: n.author ? { login: n.author.login } : null,
1025
1737
  assignees: [],
@@ -1027,6 +1739,7 @@ fragment repoFields on Repository {
1027
1739
  state: r.state,
1028
1740
  author: r.author ? { login: r.author.login } : null,
1029
1741
  submittedAt: r.submittedAt,
1742
+ body: r.body || "",
1030
1743
  })),
1031
1744
  createdAt: n.createdAt,
1032
1745
  }));
@@ -1042,7 +1755,7 @@ fragment repoFields on Repository {
1042
1755
  .map((n) => ({
1043
1756
  number: n.number,
1044
1757
  title: n.title,
1045
- state: n.state?.toLowerCase() || "closed",
1758
+ state: (n.state || "CLOSED").toUpperCase(),
1046
1759
  url: n.url,
1047
1760
  closedAt: n.closedAt,
1048
1761
  }));
@@ -1058,7 +1771,7 @@ fragment repoFields on Repository {
1058
1771
  .map((n) => ({
1059
1772
  number: n.number,
1060
1773
  title: n.title,
1061
- state: n.state?.toLowerCase() || "merged",
1774
+ state: (n.state || "MERGED").toUpperCase(),
1062
1775
  url: n.url,
1063
1776
  mergedAt: n.mergedAt,
1064
1777
  author: n.author ? { login: n.author.login } : null,
@@ -1076,24 +1789,48 @@ fragment repoFields on Repository {
1076
1789
  // and on demand when a per-project endpoint has no cached data.
1077
1790
  async function refreshGraphQLCache() {
1078
1791
  if (_graphqlRefreshInFlight) return;
1079
- if (isRateLimited()) return; // don't burn quota when critically low
1792
+ // #806: the board state now comes from REST + ETag (githubStateFetcher), so
1793
+ // gate on the REST (core) budget. 304s are free, but the first/changed
1794
+ // fetches cost core — back off when REST is critically low and let consumers
1795
+ // serve stale. (Name kept so the module-scope auto-start stays untouched.)
1796
+ if (isRateLimited()) return;
1080
1797
  _graphqlRefreshInFlight = true;
1081
1798
  try {
1082
- const data = await fetchAllProjectsGraphQL();
1083
- if (data) {
1084
- const now = Date.now();
1085
- for (const [repo, repoData] of data) {
1086
- _graphqlCache.set(repo, { ts: now, ...repoData });
1087
- // Also populate the per-endpoint _ghEndpointCache so stale-while-
1088
- // revalidate and existing per-project endpoints pick up the data.
1089
- _ghEndpointCache.set(`issues:${repo}`, { ts: now, data: repoData.issues, stale: false });
1090
- _ghEndpointCache.set(`prs:${repo}`, { ts: now, data: repoData.prs, stale: false });
1091
- _ghEndpointCache.set(`closed-issues:${repo}`, { ts: now, data: repoData.closedIssues, stale: false });
1092
- _ghEndpointCache.set(`merged-prs:${repo}`, { ts: now, data: repoData.mergedPrs, stale: false });
1799
+ let cfg;
1800
+ try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); } catch { return; }
1801
+ const seen = new Set();
1802
+ const repos = [];
1803
+ for (const p of cfg.projects || []) {
1804
+ if (!p.repo || !REPO_RE.test(p.repo) || p.idle) continue; // #812: skip idle
1805
+ if (seen.has(p.repo)) continue;
1806
+ seen.add(p.repo);
1807
+ repos.push(p.repo);
1808
+ }
1809
+ const results = await _mapLimited(repos, GH_MAX_CONCURRENT, (repo) =>
1810
+ refreshRepoRest(repo).then((r) => ({ repo, status: r.status })),
1811
+ );
1812
+
1813
+ // #805/#806 emergency fallback: if REST could not assemble a snapshot for
1814
+ // some repos AND the GraphQL budget is healthy, backfill via the legacy
1815
+ // batched GraphQL query (gated on the GRAPHQL bucket, never REST).
1816
+ const failed = results.filter((r) => r.status === "error").map((r) => r.repo);
1817
+ if (failed.length > 0 && !isGraphqlRateLimited()) {
1818
+ const gql = await fetchAllProjectsGraphQL();
1819
+ if (gql) {
1820
+ const now = Date.now();
1821
+ for (const repo of failed) {
1822
+ const d = gql.get(repo);
1823
+ if (!d) continue;
1824
+ _graphqlCache.set(repo, { ts: now, ...d });
1825
+ _ghEndpointCache.set(`issues:${repo}`, { ts: now, data: d.issues, stale: false });
1826
+ _ghEndpointCache.set(`prs:${repo}`, { ts: now, data: d.prs, stale: false });
1827
+ _ghEndpointCache.set(`closed-issues:${repo}`, { ts: now, data: d.closedIssues, stale: false });
1828
+ _ghEndpointCache.set(`merged-prs:${repo}`, { ts: now, data: d.mergedPrs, stale: false });
1829
+ }
1093
1830
  }
1094
1831
  }
1095
1832
  } catch {
1096
- // Non-fatal — per-project endpoints still work via individual gh CLI.
1833
+ // Non-fatal — consumers serve last-known cache.
1097
1834
  } finally {
1098
1835
  _graphqlRefreshInFlight = false;
1099
1836
  }
@@ -1103,128 +1840,110 @@ async function refreshGraphQLCache() {
1103
1840
  let _graphqlPollTimer = null;
1104
1841
  function startGraphQLPolling() {
1105
1842
  if (_graphqlPollTimer) return;
1843
+ // #836: both the warm-up setTimeout and the steady-state setInterval are
1844
+ // .unref()'d so a test/import process can exit. The Express HTTP listener
1845
+ // keeps prod alive on its own; cadence is unchanged.
1106
1846
  // Initial fetch after a short delay (let rate-limit poll run first).
1107
- setTimeout(() => refreshGraphQLCache(), 2000);
1847
+ setTimeout(() => refreshGraphQLCache(), 2000).unref();
1108
1848
  _graphqlPollTimer = setInterval(refreshGraphQLCache, GRAPHQL_CACHE_TTL);
1849
+ _graphqlPollTimer.unref();
1109
1850
  }
1110
1851
 
1111
- // #703: Batched GraphQL for batch progress fetch all issue states +
1112
- // linked PRs in a single query instead of 2N individual gh calls.
1113
- async function fetchBatchProgressGraphQL(repo, issueNumbers) {
1114
- if (!issueNumbers || issueNumbers.length === 0) return null;
1115
- const [owner, name] = repo.split("/");
1116
- if (!owner || !name) return null;
1117
-
1118
- // Build aliased issue fields.
1119
- const issueFields = issueNumbers.map((n) =>
1120
- `issue${n}: issue(number: ${n}) {
1121
- number title state url
1122
- closedByPullRequestsReferences(first: 3) {
1123
- nodes { number state url merged reviews(last: 100) { nodes { state author { login } submittedAt } } }
1124
- }
1125
- }`
1126
- ).join("\n ");
1127
-
1128
- const query = `query {
1129
- repository(owner: "${owner}", name: "${name}") {
1130
- ${issueFields}
1131
- }
1132
- }`;
1133
-
1134
- try {
1135
- const { stdout } = await _execFileAsync("gh", [
1136
- "api", "graphql", "-f", `query=${query}`,
1137
- ], { encoding: "utf-8", timeout: 15000 });
1138
- const data = JSON.parse(stdout).data;
1139
- if (!data?.repository) return null;
1140
- return data.repository;
1141
- } catch {
1142
- return null; // fallback to individual gh CLI calls
1143
- }
1852
+ // ─── #810: batch progress sourced from the REST snapshot ──────────────────
1853
+ // #806's server cache already holds open PRs (with full reviews + bodies),
1854
+ // open/closed issues, and merged PRs. Batch progress is computed from that
1855
+ // snapshot in steady state no GraphQL — falling back to a targeted by-number
1856
+ // fetch only when an active-batch issue sits outside the board window.
1857
+
1858
+ // Per-ROLE approval count. re1/re2 share one GitHub login, so we attribute by
1859
+ // body-marker ROLE (attributeReviewsByRole, #807) and count roles whose LATEST
1860
+ // decision-affecting review is APPROVED — max 2 (re1, re2). NOT per-author.
1861
+ function countApprovedRoles(reviews) {
1862
+ const roles = attributeReviewsByRole(reviews);
1863
+ return ["re1", "re2"].filter((r) => roles[r] && roles[r].state === "APPROVED").length;
1144
1864
  }
1145
1865
 
1146
- // Convert a GraphQL batch progress issue node into the same progress
1147
- // row shape that progressForItemAsync produces.
1148
- function graphqlIssueToProgressRow(issueData) {
1149
- if (!issueData) return null;
1150
-
1151
- const linked = issueData.closedByPullRequestsReferences?.nodes || [];
1152
- const pr = linked.length > 0
1153
- ? linked.slice().sort((a, b) => (b.number || 0) - (a.number || 0))[0]
1154
- : null;
1155
-
1156
- // No linked PR — delegate to the existing buildNoPrRow helper.
1157
- if (!pr) {
1158
- return buildNoPrRow({
1159
- number: issueData.number,
1160
- title: issueData.title,
1161
- state: issueData.state,
1162
- url: issueData.url,
1163
- });
1164
- }
1165
-
1166
- const merged = pr.merged && issueData.state === "CLOSED";
1167
- if (merged) {
1866
+ // Compute issue `n`'s progress row from the snapshot (no network). Links
1867
+ // issue↔PR by the team's `[#<issue>]` PR-title convention. Returns a row when
1868
+ // the snapshot can PROVE the state: a matching in-window PR (in_review/approved/
1869
+ // ready/merged), or — #828 P1 / #834 — a queued OPEN issue when BOTH windows are
1870
+ // provably complete: openPrsWindowComplete (<50 open PRs → no open PR exists)
1871
+ // AND closedPrsWindowComplete (the closed-PR pagination reached an actual <100
1872
+ // end) with the issue absent from closedPrIssueNums (every [#N] across ALL
1873
+ // scanned closed pages, so a PR merged/closed even OUTSIDE the recent-5 window
1874
+ // is accounted for). Returns null otherwise (either window unproven, issue
1875
+ // absent, CLOSED-with-no-in-window-PR, merged-but-open, or a linked closed/
1876
+ // merged PR exists) a partial window can't prove absence of a linked PR, so
1877
+ // the caller does the authoritative by-number REST fetch (progressForItemRest).
1878
+ function progressFromSnapshot(snapshot, n) {
1879
+ if (!snapshot) return null;
1880
+ const titleRe = new RegExp(`^\\s*\\[#${n}\\]`);
1881
+ const openPr = (snapshot.prs || []).find((p) => titleRe.test(p.title || ""));
1882
+ const mergedPr = (snapshot.mergedPrs || []).find((p) => titleRe.test(p.title || ""));
1883
+ const openIssue = (snapshot.issues || []).find((i) => i.number === n);
1884
+ const closedIssue = (snapshot.closedIssues || []).find((i) => i.number === n);
1885
+ const title = (openIssue && openIssue.title) || (closedIssue && closedIssue.title) || `#${n}`;
1886
+
1887
+ // merged requires a matching merged PR in-window AND the issue CLOSED.
1888
+ if (mergedPr && closedIssue) {
1168
1889
  return {
1169
- issue_number: issueData.number,
1170
- title: issueData.title,
1171
- url: pr.url || issueData.url,
1172
- pr_number: pr.number,
1173
- status: "merged",
1174
- progress: 100,
1175
- label: "Merged ✓",
1890
+ issue_number: n, title: closedIssue.title, url: mergedPr.url || closedIssue.url,
1891
+ pr_number: mergedPr.number, status: "merged", progress: 100, label: "Merged ✓",
1176
1892
  };
1177
1893
  }
1894
+ // Matching open PR in-window → confident in_review/approved/ready.
1895
+ if (openPr) {
1896
+ const approvals = countApprovedRoles(openPr.reviews);
1897
+ if (approvals >= 2) return { issue_number: n, title, url: openPr.url, pr_number: openPr.number, status: "ready", progress: 80, label: `PR #${openPr.number} · 2 approvals · ready` };
1898
+ if (approvals === 1) return { issue_number: n, title, url: openPr.url, pr_number: openPr.number, status: "approved1", progress: 50, label: `PR #${openPr.number} · 1 approval` };
1899
+ return { issue_number: n, title, url: openPr.url, pr_number: openPr.number, status: "in_review", progress: 20, label: `PR #${openPr.number} · waiting on review` };
1900
+ }
1901
+ // No matching open/in-window-merged PR. #828 P1 / #834: classify queued from
1902
+ // the snapshot ONLY when absence of BOTH an open AND a closed/merged linked PR
1903
+ // is proven — openPrsWindowComplete (no open PR) AND closedPrsWindowComplete
1904
+ // (the closed-PR window genuinely ended) AND `n` not in closedPrIssueNums
1905
+ // (no [#N] across ANY scanned closed page, incl. merges past the recent-5
1906
+ // display window). Then an OPEN issue is genuinely queued — NO network. Any
1907
+ // gap (either window unproven, or a linked closed/merged PR exists) → null,
1908
+ // and the by-number REST fetch resolves it authoritatively.
1909
+ if (
1910
+ snapshot.openPrsWindowComplete &&
1911
+ snapshot.closedPrsWindowComplete &&
1912
+ openIssue &&
1913
+ !openPr &&
1914
+ !(snapshot.closedPrIssueNums || []).includes(n)
1915
+ ) {
1916
+ return buildNoPrRow({ number: n, title: openIssue.title, state: "OPEN", url: openIssue.url });
1917
+ }
1918
+ // Can't prove the issue has no linked PR (a window unproven / issue absent /
1919
+ // linked closed PR) — return null; the by-number REST fetch resolves it.
1920
+ return null;
1921
+ }
1178
1922
 
1179
- // Count distinct APPROVED reviews per author.
1180
- const reviews = (pr.reviews?.nodes || []).slice();
1181
- reviews.sort((a, b) => {
1182
- const ta = a?.submittedAt ? Date.parse(a.submittedAt) : 0;
1183
- const tb = b?.submittedAt ? Date.parse(b.submittedAt) : 0;
1184
- return ta - tb;
1185
- });
1186
- const latestByAuthor = new Map();
1187
- for (const r of reviews) {
1188
- const author = r?.author?.login || "";
1189
- if (!author) continue;
1190
- latestByAuthor.set(author, r.state);
1191
- }
1192
- let approvalCount = 0;
1193
- for (const state of latestByAuthor.values()) {
1194
- if (state === "APPROVED") approvalCount++;
1195
- }
1196
-
1197
- if (approvalCount >= 2) {
1198
- return {
1199
- issue_number: issueData.number,
1200
- title: issueData.title,
1201
- url: pr.url || issueData.url,
1202
- pr_number: pr.number,
1203
- status: "ready",
1204
- progress: 80,
1205
- label: `PR #${pr.number} · 2 approvals · ready`,
1206
- };
1207
- }
1208
- if (approvalCount === 1) {
1209
- return {
1210
- issue_number: issueData.number,
1211
- title: issueData.title,
1212
- url: pr.url || issueData.url,
1213
- pr_number: pr.number,
1214
- status: "approved1",
1215
- progress: 50,
1216
- label: `PR #${pr.number} · 1 approval`,
1217
- };
1218
- }
1219
- return {
1220
- issue_number: issueData.number,
1221
- title: issueData.title,
1222
- url: pr.url || issueData.url,
1223
- pr_number: pr.number,
1224
- status: "in_review",
1225
- progress: 20,
1226
- label: `PR #${pr.number} · waiting on review`,
1227
- };
1923
+ // Confirmed-complete state machine, per project. `complete` must hold across
1924
+ // TWO consecutive SUCCESSFUL fetch cycles with DISTINCT generatedAt (the
1925
+ // snapshot ts, which only advances on a real #806 refresh) before we confirm.
1926
+ // A stale/served-expired cycle keeps the same ts, and an errored item flips
1927
+ // complete=false so neither can confirm on its own. Consumers (server
1928
+ // auto-stop) gate stopTrigger/bridge-stop on the confirmed signal, never a
1929
+ // single transient `complete`.
1930
+ const _batchCompleteState = new Map(); // projectId -> { batchKey, ts, confirmed }
1931
+ // #828 P2: confirmation is BATCH-scoped, not just project-scoped. `batchKey`
1932
+ // identifies the batch (its number + issue set); when it changes, the streak
1933
+ // resets so a brand-new already-complete batch can never inherit the prior
1934
+ // batch's first cycle and confirm in a single tick (premature auto-stop). A new
1935
+ // batch must earn its own two distinct successful cycles.
1936
+ function evalBatchCompleteConfirmed(projectId, batchKey, complete, generatedAt) {
1937
+ let st = _batchCompleteState.get(projectId);
1938
+ if (!st || st.batchKey !== batchKey) {
1939
+ st = { batchKey, ts: null, confirmed: false };
1940
+ _batchCompleteState.set(projectId, st);
1941
+ }
1942
+ if (!complete) { st.ts = null; st.confirmed = false; return false; }
1943
+ if (generatedAt == null) return st.confirmed; // inconclusive cycle — no advance
1944
+ if (st.ts == null) { st.ts = generatedAt; st.confirmed = false; return false; }
1945
+ if (generatedAt !== st.ts) { st.ts = generatedAt; st.confirmed = true; return true; }
1946
+ return st.confirmed; // same snapshot re-served — no new cycle
1228
1947
  }
1229
1948
 
1230
1949
  // ─── /api/github/all — batched endpoint (#703) ────────────────────────────
@@ -1238,7 +1957,7 @@ router.get("/api/github/all", async (req, res) => {
1238
1957
  const anyStale = (() => {
1239
1958
  let cfg;
1240
1959
  try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); } catch { return true; }
1241
- const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo));
1960
+ const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo) && !p.idle); // #812: skip idle (parked) projects
1242
1961
  for (const p of projects) {
1243
1962
  const cached = _graphqlCache.get(p.repo);
1244
1963
  if (!cached || Date.now() - cached.ts > adaptiveTTL(GRAPHQL_CACHE_TTL)) return true;
@@ -1250,13 +1969,21 @@ router.get("/api/github/all", async (req, res) => {
1250
1969
  // Build response.
1251
1970
  let cfg;
1252
1971
  try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); } catch { return res.status(500).json({ error: "Config unreadable" }); }
1253
- const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo));
1972
+ const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo)); // #812: include idle projects — served stale below, never fetched
1254
1973
 
1255
1974
  const result = {};
1256
1975
  const fallbackNeeded = [];
1257
1976
  for (const p of projects) {
1258
1977
  if (projectFilter && p.id !== projectFilter) continue;
1259
1978
  const cached = _graphqlCache.get(p.repo);
1979
+ // #812: idle (parked) project — serve last-known data flagged _idle,
1980
+ // never trigger a fetch or fall back to gh.
1981
+ if (p.idle) {
1982
+ result[p.id] = cached
1983
+ ? { issues: cached.issues, prs: cached.prs, closedIssues: cached.closedIssues, mergedPrs: cached.mergedPrs, _idle: true }
1984
+ : { issues: [], prs: [], closedIssues: [], mergedPrs: [], _idle: true };
1985
+ continue;
1986
+ }
1260
1987
  if (cached) {
1261
1988
  result[p.id] = {
1262
1989
  issues: cached.issues,
@@ -1270,41 +1997,22 @@ router.get("/api/github/all", async (req, res) => {
1270
1997
  }
1271
1998
  }
1272
1999
 
1273
- // Fallback: fetch missing projects via individual gh CLI calls.
2000
+ // #806: fallback for still-missing repos goes through the REST+ETag snapshot
2001
+ // (refreshRepoRest), NOT `gh pr list`/`gh issue list`. Concurrency-limited so
2002
+ // a cold multi-project load doesn't drain the core budget.
1274
2003
  if (fallbackNeeded.length > 0 && !isRateLimited()) {
1275
- const fallbackResults = await Promise.allSettled(
1276
- fallbackNeeded.map(async (p) => {
1277
- const repo = p.repo;
1278
- const [issues, prs, closedIssues, mergedPrs] = await Promise.allSettled([
1279
- _execFileAsync("gh", ["issue", "list", "-R", repo, "--json", "number,title,state,assignees,labels,createdAt,url", "--limit", "50"], { encoding: "utf-8", timeout: 15000 }).then(({ stdout }) => JSON.parse(stdout)),
1280
- _execFileAsync("gh", ["pr", "list", "-R", repo, "--json", "number,title,state,author,assignees,reviewDecision,reviews,statusCheckRollup,url,createdAt", "--limit", "50"], { encoding: "utf-8", timeout: 15000 }).then(({ stdout }) => JSON.parse(stdout)),
1281
- _execFileAsync("gh", ["issue", "list", "-R", repo, "--state", "closed", "--json", "number,title,state,url,closedAt", "--limit", String(RECENT_FETCH_LIMIT)], { encoding: "utf-8", timeout: 15000 }).then(({ stdout }) => {
1282
- const items = JSON.parse(stdout);
1283
- return Array.isArray(items)
1284
- ? items.sort((a, b) => (Date.parse(b?.closedAt || 0)) - (Date.parse(a?.closedAt || 0))).slice(0, RECENT_DISPLAY_LIMIT)
1285
- : items;
1286
- }),
1287
- _execFileAsync("gh", ["pr", "list", "-R", repo, "--state", "merged", "--json", "number,title,state,url,mergedAt,author", "--limit", String(RECENT_FETCH_LIMIT)], { encoding: "utf-8", timeout: 15000 }).then(({ stdout }) => {
1288
- const items = JSON.parse(stdout);
1289
- return Array.isArray(items)
1290
- ? items.sort((a, b) => (Date.parse(b?.mergedAt || 0)) - (Date.parse(a?.mergedAt || 0))).slice(0, RECENT_DISPLAY_LIMIT)
1291
- : items;
1292
- }),
1293
- ]);
1294
- return {
1295
- id: p.id,
1296
- issues: issues.status === "fulfilled" ? issues.value : [],
1297
- prs: prs.status === "fulfilled" ? prs.value : [],
1298
- closedIssues: closedIssues.status === "fulfilled" ? closedIssues.value : [],
1299
- mergedPrs: mergedPrs.status === "fulfilled" ? mergedPrs.value : [],
1300
- _fallback: true,
1301
- };
1302
- }),
1303
- );
1304
- for (const r of fallbackResults) {
1305
- if (r.status === "fulfilled") {
1306
- result[r.value.id] = r.value;
1307
- }
2004
+ await _mapLimited(fallbackNeeded, GH_MAX_CONCURRENT, (p) => refreshRepoRest(p.repo));
2005
+ for (const p of fallbackNeeded) {
2006
+ const cached = _graphqlCache.get(p.repo);
2007
+ result[p.id] = cached
2008
+ ? {
2009
+ issues: cached.issues,
2010
+ prs: cached.prs,
2011
+ closedIssues: cached.closedIssues,
2012
+ mergedPrs: cached.mergedPrs,
2013
+ _fallback: true,
2014
+ }
2015
+ : { issues: [], prs: [], closedIssues: [], mergedPrs: [], _fallback: true };
1308
2016
  }
1309
2017
  }
1310
2018
 
@@ -1312,86 +2020,71 @@ router.get("/api/github/all", async (req, res) => {
1312
2020
  });
1313
2021
 
1314
2022
  // ─── Per-project endpoints (backward compat, served from shared cache) ────
1315
-
1316
- router.get("/api/github/issues", (req, res) => {
1317
- const repo = getRepo(req.query.project || "");
2023
+ //
2024
+ // #806: all four lists are slices of the single REST+ETag snapshot built by
2025
+ // githubStateFetcher (closed/merged already sorted newest-first and capped to
2026
+ // RECENT_DISPLAY_LIMIT there). On a warm cache we serve instantly; cold misses
2027
+ // fetch the whole snapshot once via refreshRepoRest — no `gh pr list`/`gh issue
2028
+ // list` (those are GraphQL-backed). Idle projects never fetch.
2029
+ async function serveGithubList(req, res, kind) {
2030
+ const project = req.query.project || "";
2031
+ const repo = getRepo(project);
1318
2032
  if (!repo) return res.status(400).json({ error: "No repo configured for project" });
1319
- cachedGhEndpoint(
1320
- `issues:${repo}`,
1321
- ["issue", "list", "-R", repo, "--json", "number,title,state,assignees,labels,createdAt,url", "--limit", "50"],
1322
- res,
1323
- );
1324
- });
2033
+ const cacheKey = `${kind}:${repo}`;
2034
+ const cached = _ghEndpointCache.get(cacheKey);
1325
2035
 
1326
- router.get("/api/github/prs", (req, res) => {
1327
- const repo = getRepo(req.query.project || "");
1328
- if (!repo) return res.status(400).json({ error: "No repo configured for project" });
1329
- cachedGhEndpoint(
1330
- `prs:${repo}`,
1331
- ["pr", "list", "-R", repo, "--json", "number,title,state,author,assignees,reviewDecision,reviews,statusCheckRollup,url,createdAt", "--limit", "50"],
1332
- res,
1333
- );
1334
- });
2036
+ // #812: a parked (idle) project must never initiate a fetch.
2037
+ if (isProjectIdle(project)) {
2038
+ res.set("X-QuadWork-Idle", "1");
2039
+ return res.json(cached ? cached.data : []);
2040
+ }
1335
2041
 
1336
- // #411 / quadwork#281: recently closed issues + merged PRs for the
1337
- // "Recently closed" / "Recently merged" sub-sections under each
1338
- // list in GitHubPanel. Limit 5 items each, ordered by closedAt
1339
- // descending so the freshest activity sits at the top.
1340
- // gh CLI's default ordering for `issue list --state closed` and
1341
- // `pr list --state merged` is createdAt-desc, not closedAt/mergedAt-desc,
1342
- // so a stale-but-recently-closed item can sit below a fresh-but-
1343
- // older one. We pull a wider window and re-sort by close/merge time
1344
- // before truncating to 5 to honor #281's "newest first" requirement.
1345
-
1346
- router.get("/api/github/closed-issues", (req, res) => {
1347
- const repo = getRepo(req.query.project || "");
1348
- if (!repo) return res.status(400).json({ error: "No repo configured for project" });
1349
- cachedGhEndpoint(
1350
- `closed-issues:${repo}`,
1351
- ["issue", "list", "-R", repo, "--state", "closed", "--json", "number,title,state,url,closedAt", "--limit", String(RECENT_FETCH_LIMIT)],
1352
- res,
1353
- {
1354
- transform: (items) =>
1355
- Array.isArray(items)
1356
- ? items
1357
- .slice()
1358
- .sort((a, b) => {
1359
- const ta = a && a.closedAt ? Date.parse(a.closedAt) : 0;
1360
- const tb = b && b.closedAt ? Date.parse(b.closedAt) : 0;
1361
- return tb - ta;
1362
- })
1363
- .slice(0, RECENT_DISPLAY_LIMIT)
1364
- : items,
1365
- },
1366
- );
1367
- });
2042
+ // #824: these four endpoints serve a BARE ARRAY (cached.data is an array).
2043
+ // Stale/rate-limited state must be signalled via a response header — mirroring
2044
+ // the X-QuadWork-Idle pattern above never by spreading the array into an
2045
+ // object (which yields {"0":…,"_stale":true} and trips GitHubPanel's
2046
+ // Array.isArray guard, blanking the board exactly when it should show
2047
+ // last-known data).
2048
+ //
2049
+ // Freshness is measured against the BASE TTL, not adaptiveTTL: under critical
2050
+ // rate-limit adaptiveTTL is Infinity, so an age<ttl check would treat an
2051
+ // arbitrarily-old cache as fresh and skip the staleness signal entirely.
2052
+ const baseFresh = cached && Date.now() - cached.ts < GH_ENDPOINT_CACHE_TTL;
2053
+
2054
+ // Critically REST-rate-limited serve whatever we have (even expired) without
2055
+ // revalidating. MUST precede the cache-hit branch below: adaptiveTTL is
2056
+ // Infinity here, so that branch would otherwise return first and an expired
2057
+ // array would go out with no X-QuadWork-Stale / X-QuadWork-Rate-Limited.
2058
+ if (isRateLimited() && cached) {
2059
+ res.set("X-QuadWork-Rate-Limited", "1");
2060
+ if (!baseFresh || cached.stale) res.set("X-QuadWork-Stale", "1");
2061
+ return res.json(cached.data);
2062
+ }
2063
+ // Fresh enough (within the adaptive window) → serve as-is.
2064
+ if (cached && Date.now() - cached.ts < adaptiveTTL(GH_ENDPOINT_CACHE_TTL)) {
2065
+ if (cached.stale) res.set("X-QuadWork-Stale", "1");
2066
+ return res.json(cached.data);
2067
+ }
2068
+ // Stale-while-revalidate: serve stale now, refresh the snapshot in background.
2069
+ if (cached) {
2070
+ refreshRepoRest(repo);
2071
+ res.set("X-QuadWork-Stale", "1");
2072
+ return res.json(cached.data);
2073
+ }
2074
+ // Cold: assemble the snapshot synchronously, then serve this slice.
2075
+ await refreshRepoRest(repo);
2076
+ const fresh = _ghEndpointCache.get(cacheKey);
2077
+ if (fresh) return res.json(fresh.data);
2078
+ return res.status(502).json({ error: "gh call failed" });
2079
+ }
1368
2080
 
1369
- router.get("/api/github/merged-prs", (req, res) => {
1370
- const repo = getRepo(req.query.project || "");
1371
- if (!repo) return res.status(400).json({ error: "No repo configured for project" });
1372
- // gh pr list with `--state merged` filters server-side so we
1373
- // don't have to pull every closed PR and discard the un-merged
1374
- // ones (closed-without-merge). Same fetch-wider-then-sort
1375
- // strategy as closed-issues so the newest merge always wins.
1376
- cachedGhEndpoint(
1377
- `merged-prs:${repo}`,
1378
- ["pr", "list", "-R", repo, "--state", "merged", "--json", "number,title,state,url,mergedAt,author", "--limit", String(RECENT_FETCH_LIMIT)],
1379
- res,
1380
- {
1381
- transform: (items) =>
1382
- Array.isArray(items)
1383
- ? items
1384
- .slice()
1385
- .sort((a, b) => {
1386
- const ta = a && a.mergedAt ? Date.parse(a.mergedAt) : 0;
1387
- const tb = b && b.mergedAt ? Date.parse(b.mergedAt) : 0;
1388
- return tb - ta;
1389
- })
1390
- .slice(0, RECENT_DISPLAY_LIMIT)
1391
- : items,
1392
- },
1393
- );
1394
- });
2081
+ router.get("/api/github/issues", (req, res) => serveGithubList(req, res, "issues"));
2082
+ router.get("/api/github/prs", (req, res) => serveGithubList(req, res, "prs"));
2083
+ // #411 / quadwork#281: "Recently closed" / "Recently merged" — newest-first,
2084
+ // capped to 5 in githubStateFetcher (REST default ordering isn't close/merge
2085
+ // time, so the snapshot re-sorts by closedAt/mergedAt before truncating).
2086
+ router.get("/api/github/closed-issues", (req, res) => serveGithubList(req, res, "closed-issues"));
2087
+ router.get("/api/github/merged-prs", (req, res) => serveGithubList(req, res, "merged-prs"));
1395
2088
 
1396
2089
  // #413 / quadwork#282: Current Batch Progress panel.
1397
2090
  //
@@ -1466,15 +2159,10 @@ async function checkBatchSnapshotFreshness(repo, snapshot) {
1466
2159
  }
1467
2160
  const first = snapshot.issueNumbers[0];
1468
2161
  try {
1469
- await ghJsonExecAsync([
1470
- "issue",
1471
- "view",
1472
- String(first),
1473
- "-R",
1474
- repo,
1475
- "--json",
1476
- "number",
1477
- ]);
2162
+ // #828 P1: REST, not `gh issue view` (GraphQL). We only need existence, so
2163
+ // the cheap REST issue endpoint suffices and keeps the whole batch-progress
2164
+ // path off GraphQL.
2165
+ await ghJsonExecAsync(["api", `repos/${repo}/issues/${first}`, "--jq", ".number"]);
1478
2166
  return "fresh";
1479
2167
  } catch (err) {
1480
2168
  // gh surfaces a 404 via stderr text on a non-zero exit. Only
@@ -1482,7 +2170,7 @@ async function checkBatchSnapshotFreshness(repo, snapshot) {
1482
2170
  // count as genuinely gone; anything else (network, auth,
1483
2171
  // timeout) is transient and must NOT delete the snapshot.
1484
2172
  const msg = String((err && (err.stderr || err.message)) || "").toLowerCase();
1485
- if (msg.includes("could not resolve") || msg.includes("not found") || msg.includes("no issue")) {
2173
+ if (msg.includes("could not resolve") || msg.includes("not found") || msg.includes("http 404")) {
1486
2174
  return "gone";
1487
2175
  }
1488
2176
  return "unknown";
@@ -1586,22 +2274,22 @@ function parseActiveBatch(queueText) {
1586
2274
  // progress fetcher. Wraps node's execFile in a promise.
1587
2275
  //
1588
2276
  // THROWS on subprocess failure (non-zero exit, timeout, JSON parse,
1589
- // network) so progressForItemAsync can decide which subset of
2277
+ // network) so progressForItemRest can decide which subset of
1590
2278
  // failures should bubble up to the Promise.allSettled "fetch failed"
1591
2279
  // row vs. which should fall through to a softer state. The previous
1592
2280
  // catch-all-and-return-null contract collapsed real subprocess
1593
2281
  // errors into the "not found" branch, making the new failure-row
1594
2282
  // fallback unreachable for genuine command failures (t2a review).
1595
2283
  async function ghJsonExecAsync(args) {
1596
- const { stdout } = await _execFileAsync("gh", args, { encoding: "utf-8", timeout: 10000 });
2284
+ const { stdout } = await _execFileAsync("gh", args, { encoding: "utf-8", timeout: 10000, maxBuffer: GH_LIST_MAX_BUFFER });
1597
2285
  return JSON.parse(stdout);
1598
2286
  }
1599
2287
 
1600
2288
  // #350: pure helper for the "no linked PR" branch of
1601
- // progressForItemAsync. Takes the issue JSON (shape: { number,
2289
+ // progressForItemRest. Takes the issue JSON (shape: { number,
1602
2290
  // title, state, url, ... }) and returns the batch-progress row
1603
- // for an item that has no closedByPullRequestsReferences. Exported
1604
- // from module.exports below for unit tests — no other callers.
2291
+ // for an item that has no linked PR. Exported from module.exports
2292
+ // below for unit tests — no other callers.
1605
2293
  function buildNoPrRow(issue) {
1606
2294
  if (issue && issue.state === "CLOSED") {
1607
2295
  return {
@@ -1623,61 +2311,79 @@ function buildNoPrRow(issue) {
1623
2311
  };
1624
2312
  }
1625
2313
 
1626
- async function progressForItemAsync(repo, issueNumber) {
1627
- // Pull issue state + linked PRs in one call. closedByPullRequestsReferences
1628
- // is gh's serializer for the GraphQL `closedByPullRequestsReferences`
1629
- // edge only present when a PR with `Fixes #N` / `Closes #N`
1630
- // (or the link UI) targets the issue.
1631
- // Issue fetch is the load-bearing call if gh can't read the
1632
- // issue at all (404, network, auth, timeout) we can't compute a
1633
- // meaningful progress row. Let the rejection propagate to the
1634
- // route's Promise.allSettled so the operator sees a single
1635
- // "fetch failed" row instead of a misleading "queued" entry.
1636
- const issue = await ghJsonExecAsync([
1637
- "issue",
1638
- "view",
1639
- String(issueNumber),
1640
- "-R",
1641
- repo,
1642
- "--json",
1643
- "number,title,state,url,closedByPullRequestsReferences",
1644
- ]);
1645
- const linked = Array.isArray(issue.closedByPullRequestsReferences)
1646
- ? issue.closedByPullRequestsReferences
1647
- : [];
1648
- // Pick the freshest linked PR (highest number) if there are multiple.
1649
- const pr = linked.length > 0
1650
- ? linked.slice().sort((a, b) => (b.number || 0) - (a.number || 0))[0]
1651
- : null;
1652
- // No linked PR. #350: before falling into the "queued" bucket,
1653
- // honor the issue's own state a CLOSED issue with no linked
1654
- // PR is fully done (superseded, not planned, runbook-only, etc.)
1655
- // and should render at 100% with a label instead of a
1656
- // misleading "0% · queued" row. Only truly OPEN issues with no
1657
- // linked PR are still queued.
1658
- if (!pr) {
2314
+ // #828 P1: find the PR linked to issue `n` by the team's `[#N]` PR-title
2315
+ // convention, via the REST Search API (1 search point) — NOT GraphQL. gh's
2316
+ // `-X GET -f q=…` URL-encodes the query, so we never hand-concatenate the repo
2317
+ // or number into the URL. Search tokenises loosely (and `is:pr in:title N`
2318
+ // would also surface `[#807]` for n=80), so we re-apply the strict `^\s*\[#N\]`
2319
+ // regex the snapshot matcher uses. Returns the freshest matching PR number (or
2320
+ // null), treating a search failure as "no linked PR found".
2321
+ // Pure: pick the freshest PR (highest number) whose title matches the STRICT
2322
+ // `^\s*\[#N\]` convention from a set of REST search items. Guards `[#807]` from
2323
+ // matching n=80 even though Search's loose `in:title 80` would surface it.
2324
+ // Returns the PR number or null. Exported for unit tests.
2325
+ function pickLinkedPrFromSearch(items, n) {
2326
+ const titleRe = new RegExp(`^\\s*\\[#${n}\\]`);
2327
+ const matches = (Array.isArray(items) ? items : []).filter((it) => titleRe.test((it && it.title) || ""));
2328
+ if (matches.length === 0) return null;
2329
+ return matches.slice().sort((a, b) => (b.number || 0) - (a.number || 0))[0].number;
2330
+ }
2331
+
2332
+ // The actual REST Search call, split out so findLinkedPrByTitle's
2333
+ // success-vs-failure distinction is unit-testable via an injected executor.
2334
+ // `gh api -X GET -f q=…` URL-encodes the query (we never hand-concatenate repo
2335
+ // or number into the URL). THROWS (propagates) on any Search failure.
2336
+ async function _searchLinkedPrItems(repo, n) {
2337
+ const res = await ghJsonExecAsync(["api", "-X", "GET", "search/issues", "-f", `q=repo:${repo} is:pr in:title ${n}`]);
2338
+ return Array.isArray(res && res.items) ? res.items : [];
2339
+ }
2340
+
2341
+ // Find the PR linked to issue `n` by the strict `[#N]` title convention via the
2342
+ // REST Search API (1 search point) NOT GraphQL. Returns the freshest matching
2343
+ // PR number, or null ONLY when Search SUCCEEDED with zero strict matches
2344
+ // (authoritative "no linked PR"). #828/RE1: a Search FAILURE (rate-limit /
2345
+ // network / parse) is NOT proof of no PR it must NOT collapse to null, or an
2346
+ // out-of-window OPEN issue with a real PR would render queued (and a CLOSED one
2347
+ // with a merged PR would render closed). So failure THROWS, and the caller
2348
+ // (progressForItemRest, awaiting without a catch) drops the item to the
2349
+ // non-authoritative "fetch failed" row, retried on the next cache miss.
2350
+ async function findLinkedPrByTitle(repo, n, search = _searchLinkedPrItems) {
2351
+ const items = await search(repo, n);
2352
+ return pickLinkedPrFromSearch(items, n);
2353
+ }
2354
+
2355
+ // Authoritative by-number resolution for an active-batch issue the board
2356
+ // snapshot can't prove (outside / truncated window, or absent). #828 P1: REST +
2357
+ // Search ONLY — zero `gh issue view` / `gh pr view` (GraphQL). Bucket mapping
2358
+ // matches progressFromSnapshot exactly.
2359
+ async function progressForItemRest(repo, issueNumber) {
2360
+ // Load-bearing call: the issue's own state via REST. If gh can't read it at
2361
+ // all (404, network, auth, timeout) we can't compute a meaningful row — let
2362
+ // the rejection propagate to the route's Promise.allSettled "fetch failed"
2363
+ // row instead of emitting a misleading "queued" entry.
2364
+ const raw = await ghJsonExecAsync(["api", `repos/${repo}/issues/${issueNumber}`]);
2365
+ const issue = {
2366
+ number: raw.number,
2367
+ title: raw.title,
2368
+ state: (raw.state || "").toUpperCase(),
2369
+ url: raw.html_url,
2370
+ };
2371
+ // Throws (→ route's "fetch failed" row) if Search itself fails, so a
2372
+ // could-not-determine is NEVER mistaken for proof of no linked PR. A null
2373
+ // here means Search SUCCEEDED with zero strict [#N] matches — authoritative.
2374
+ const prNumber = await findLinkedPrByTitle(repo, issueNumber);
2375
+ // No linked PR (authoritative). #350: honor the issue's own state — a CLOSED
2376
+ // issue with no linked PR is fully done (superseded/not-planned/runbook) →
2377
+ // 100% ✓; only a truly OPEN issue with no PR is queued.
2378
+ if (prNumber == null) {
1659
2379
  return buildNoPrRow(issue);
1660
2380
  }
1661
- // Re-fetch the PR to get reviewDecision + reviews + state, since
1662
- // the issue's closedByPullRequestsReferences edge only carries
1663
- // number/state/url. The PR fetch is intentionally soft: if gh
1664
- // glitches on this single call we still know the PR exists (we
1665
- // got the link from the issue) and can render a partial
1666
- // "in_review" row, which is more useful than dropping the whole
1667
- // item to "fetch failed". A persistent failure here will still
1668
- // surface on the next cache miss because the issue fetch above
1669
- // is the load-bearing one that controls the per-item rejection.
2381
+ // REST-fetch the PR for state + reviews. Soft: a glitch on these single calls
2382
+ // still lets us render a partial in_review row (we know the PR exists) rather
2383
+ // than dropping the whole item to "fetch failed".
1670
2384
  let prData = null;
1671
2385
  try {
1672
- prData = await ghJsonExecAsync([
1673
- "pr",
1674
- "view",
1675
- String(pr.number),
1676
- "-R",
1677
- repo,
1678
- "--json",
1679
- "number,state,url,reviewDecision,reviews",
1680
- ]);
2386
+ prData = await ghJsonExecAsync(["api", `repos/${repo}/pulls/${prNumber}`]);
1681
2387
  } catch {
1682
2388
  // soft fall-through to the in_review row below
1683
2389
  }
@@ -1685,78 +2391,44 @@ async function progressForItemAsync(repo, issueNumber) {
1685
2391
  return {
1686
2392
  issue_number: issue.number,
1687
2393
  title: issue.title,
1688
- url: pr.url || issue.url,
1689
- pr_number: pr.number,
2394
+ url: issue.url,
2395
+ pr_number: prNumber,
1690
2396
  status: "in_review",
1691
2397
  progress: 20,
1692
- label: `PR #${pr.number} · waiting on review`,
2398
+ label: `PR #${prNumber} · waiting on review`,
1693
2399
  };
1694
2400
  }
1695
- const merged = prData.state === "MERGED" && issue.state === "CLOSED";
1696
- if (merged) {
2401
+ let reviews = [];
2402
+ try {
2403
+ const rv = await ghJsonExecAsync(["api", `repos/${repo}/pulls/${prNumber}/reviews?per_page=100`]);
2404
+ reviews = mapReviews(Array.isArray(rv) ? rv : []);
2405
+ } catch {
2406
+ // soft — reviews stay [], so the row degrades to in_review, never "failed"
2407
+ }
2408
+ const url = prData.html_url || issue.url;
2409
+ // REST: a merged PR is state "closed" with `merged: true`. merged requires
2410
+ // the issue CLOSED too (mirrors progressFromSnapshot and the prior path).
2411
+ if (prData.merged === true && issue.state === "CLOSED") {
1697
2412
  return {
1698
2413
  issue_number: issue.number,
1699
2414
  title: issue.title,
1700
- url: prData.url || issue.url,
2415
+ url,
1701
2416
  pr_number: prData.number,
1702
2417
  status: "merged",
1703
2418
  progress: 100,
1704
2419
  label: "Merged ✓",
1705
2420
  };
1706
2421
  }
1707
- // Count distinct APPROVED reviews per author so a stale APPROVED
1708
- // followed by REQUEST_CHANGES doesn't double-count. Sort by
1709
- // submittedAt ascending first so the Map's "last write wins"
1710
- // genuinely lands on the freshest review per author — gh's
1711
- // current ordering is chronological in practice but undocumented,
1712
- // so the explicit sort keeps us safe if that ever changes.
1713
- const reviews = Array.isArray(prData.reviews) ? prData.reviews.slice() : [];
1714
- reviews.sort((a, b) => {
1715
- const ta = (a && a.submittedAt) ? Date.parse(a.submittedAt) : 0;
1716
- const tb = (b && b.submittedAt) ? Date.parse(b.submittedAt) : 0;
1717
- return ta - tb;
1718
- });
1719
- const latestByAuthor = new Map();
1720
- for (const r of reviews) {
1721
- const author = (r && r.author && r.author.login) || "";
1722
- if (!author) continue;
1723
- latestByAuthor.set(author, r.state);
1724
- }
1725
- let approvalCount = 0;
1726
- for (const state of latestByAuthor.values()) {
1727
- if (state === "APPROVED") approvalCount++;
1728
- }
2422
+ // #810: count APPROVED reviews per body-marker ROLE (re1/re2 share one login),
2423
+ // latest decision-affecting review per role — no stale double-count.
2424
+ const approvalCount = countApprovedRoles(reviews);
1729
2425
  if (approvalCount >= 2) {
1730
- return {
1731
- issue_number: issue.number,
1732
- title: issue.title,
1733
- url: prData.url || issue.url,
1734
- pr_number: prData.number,
1735
- status: "ready",
1736
- progress: 80,
1737
- label: `PR #${prData.number} · 2 approvals · ready`,
1738
- };
2426
+ return { issue_number: issue.number, title: issue.title, url, pr_number: prData.number, status: "ready", progress: 80, label: `PR #${prData.number} · 2 approvals · ready` };
1739
2427
  }
1740
2428
  if (approvalCount === 1) {
1741
- return {
1742
- issue_number: issue.number,
1743
- title: issue.title,
1744
- url: prData.url || issue.url,
1745
- pr_number: prData.number,
1746
- status: "approved1",
1747
- progress: 50,
1748
- label: `PR #${prData.number} · 1 approval`,
1749
- };
2429
+ return { issue_number: issue.number, title: issue.title, url, pr_number: prData.number, status: "approved1", progress: 50, label: `PR #${prData.number} · 1 approval` };
1750
2430
  }
1751
- return {
1752
- issue_number: issue.number,
1753
- title: issue.title,
1754
- url: prData.url || issue.url,
1755
- pr_number: prData.number,
1756
- status: "in_review",
1757
- progress: 20,
1758
- label: `PR #${prData.number} · waiting on review`,
1759
- };
2431
+ return { issue_number: issue.number, title: issue.title, url, pr_number: prData.number, status: "in_review", progress: 20, label: `PR #${prData.number} · waiting on review` };
1760
2432
  }
1761
2433
 
1762
2434
  function summarizeItems(items) {
@@ -1784,37 +2456,104 @@ function summarizeItems(items) {
1784
2456
  return parts.join(" · ");
1785
2457
  }
1786
2458
 
1787
- router.get("/api/batch-active", (req, res) => {
2459
+ // #839: derive the sidebar heartbeat-active flag from a batch-progress payload
2460
+ // so the pulse means "batch actively in progress", not just "Active Batch
2461
+ // section non-empty". Once `complete` is true (every item merged/closed —
2462
+ // see getOrComputeBatchProgress at items.every(...merged||closed)), the pulse
2463
+ // must stop even if Head leaves a parked/blocked ticket lingering in the
2464
+ // Active Batch section. Returns null when there's no progress payload (an
2465
+ // undefined input — current callers always pass a value), defensively
2466
+ // treated as "not active" at the route.
2467
+ function isBatchActiveFromProgress(progress) {
2468
+ if (!progress) return null;
2469
+ const items = Array.isArray(progress.items) ? progress.items : [];
2470
+ return items.length > 0 && !progress.complete;
2471
+ }
2472
+
2473
+ router.get("/api/batch-active", async (req, res) => {
1788
2474
  const projectId = req.query.project;
1789
2475
  if (!projectId) return res.status(400).json({ error: "Missing project" });
1790
2476
  if (!getRepo(projectId)) return res.status(400).json({ error: "No repo configured for project" });
1791
- const queuePath = path.join(CONFIG_DIR, projectId, "OVERNIGHT-QUEUE.md");
1792
- let active = false;
1793
- try {
1794
- const text = fs.readFileSync(queuePath, "utf-8");
1795
- const { issueNumbers } = parseActiveBatch(text);
1796
- active = issueNumbers.length > 0;
1797
- } catch {}
1798
- return res.json({ active });
2477
+
2478
+ // #839 (re1 follow-up on #844): share the same compute path that
2479
+ // /api/batch-progress uses so the completion-aware answer is available on
2480
+ // cache miss too, not only after the panel has been opened once. The
2481
+ // sidebar's 30s poll on all projects stays cheap because (a) the shared
2482
+ // BATCH_PROGRESS_TTL_MS cache absorbs most hits, (b) idle projects skip the
2483
+ // compute entirely, and (c) progressFromSnapshot resolves items from the
2484
+ // already-polled #806 GraphQL snapshot — REST fallback only fires for
2485
+ // items outside the recent window.
2486
+ const data = await getOrComputeBatchProgress(projectId);
2487
+ const active = isBatchActiveFromProgress(data);
2488
+ return res.json({ active: active === null ? false : active });
1799
2489
  });
1800
2490
 
1801
- router.get("/api/batch-progress", async (req, res) => {
2491
+ // #807: parsed view of the server-authored GITHUB.md. Single source of truth is
2492
+ // the file, so this endpoint and file-reading agents see identical data. Same
2493
+ // project/config guard as batch-active (a traversal id isn't in config →
2494
+ // getRepo null → 400). Surfaces freshness + a distinct parse-error result.
2495
+ router.get("/api/github-parsed", (req, res) => {
1802
2496
  const projectId = req.query.project;
1803
2497
  if (!projectId) return res.status(400).json({ error: "Missing project" });
2498
+ if (!getRepo(projectId)) return res.status(400).json({ error: "No repo configured for project" });
2499
+ const ghPath = path.join(CONFIG_DIR, projectId, "GITHUB.md");
2500
+ let text;
2501
+ try {
2502
+ text = fs.readFileSync(ghPath, "utf-8");
2503
+ } catch {
2504
+ return res.json({ ok: false, error: "GITHUB.md not found" });
2505
+ }
2506
+ const parsed = parseGithub(text);
2507
+ if (!parsed.ok) return res.json(parsed);
2508
+ // Live staleness: even when the file isn't regenerated (e.g. rate-limited →
2509
+ // no new sync pass), age must stay visible. Compare against a FIXED window,
2510
+ // never adaptiveTTL (which is Infinity under critical rate-limit and would
2511
+ // hide unbounded staleness exactly when it matters).
2512
+ // #827: a malformed `generatedAt` → Date.parse NaN. Normalize to null so the
2513
+ // response field is a clean "unknown age" (not NaN), and _githubLiveStale
2514
+ // also treats NaN/null as stale rather than hiding staleness on a suspect file.
2515
+ const rawAge = parsed.generatedAt ? Date.now() - Date.parse(parsed.generatedAt) : null;
2516
+ const ageMs = Number.isNaN(rawAge) ? null : rawAge;
2517
+ return res.json({
2518
+ ...parsed,
2519
+ ageMs,
2520
+ _stale: _githubLiveStale(parsed.stale, ageMs),
2521
+ });
2522
+ });
1804
2523
 
2524
+ // #839 (re1 follow-up on #844): shared compute path for /api/batch-progress
2525
+ // and /api/batch-active. Returns the JSON body either route would send for a
2526
+ // successful response, or null to signal "no repo configured for project"
2527
+ // (caller's 400). Hits/populates _batchProgressCache so concurrent panel +
2528
+ // sidebar polls share the work via the BATCH_PROGRESS_TTL_MS cache.
2529
+ async function getOrComputeBatchProgress(projectId) {
1805
2530
  const cached = _batchProgressCache.get(projectId);
2531
+ // #812: parked (idle) project — never run batch-progress gh/GraphQL calls,
2532
+ // and ALWAYS flag the payload _idle (even on a fresh cache hit), so the
2533
+ // endpoint contract is consistent regardless of cache freshness. This must
2534
+ // precede the fresh-cache and rate-limit returns below.
2535
+ if (isProjectIdle(projectId)) {
2536
+ if (cached) return { ...cached.data, _idle: true };
2537
+ return { batch_number: null, items: [], summary: "", complete: false, completeConfirmed: false, _idle: true };
2538
+ }
1806
2539
  const batchTTL = adaptiveTTL(BATCH_PROGRESS_TTL_MS);
1807
2540
  if (cached && Date.now() - cached.ts < batchTTL) {
1808
- return res.json(cached.data);
2541
+ return cached.data;
1809
2542
  }
1810
2543
  // #554: if critically rate-limited, serve stale cache instead of
1811
2544
  // firing N gh calls per batch item.
1812
2545
  if (isRateLimited() && cached) {
1813
- return res.json({ ...cached.data, _stale: true, _rateLimited: true });
2546
+ return { ...cached.data, _stale: true, _rateLimited: true };
2547
+ }
2548
+ // #802: GraphQL budget exhausted (separate from REST) — prefer serving the
2549
+ // existing cache (even stale) over the GraphQL query below. With no cache we
2550
+ // fall through to the REST gh-CLI fallback path (gated on its own bucket).
2551
+ if (isGraphqlRateLimited() && cached) {
2552
+ return { ...cached.data, _stale: true, _rateLimited: true };
1814
2553
  }
1815
2554
 
1816
2555
  const repo = getRepo(projectId);
1817
- if (!repo) return res.status(400).json({ error: "No repo configured for project" });
2556
+ if (!repo) return null;
1818
2557
 
1819
2558
  const queuePath = path.join(CONFIG_DIR, projectId, "OVERNIGHT-QUEUE.md");
1820
2559
  let queueText = "";
@@ -1853,55 +2592,56 @@ router.get("/api/batch-progress", async (req, res) => {
1853
2592
  // snapshot-aware helper so merged items stay visible after Head
1854
2593
  // moves them from Active Batch to Done, until a new batch starts.
1855
2594
  const { batchNumber, issueNumbers } = resolveDisplayedBatch(queueText, projectId, { queueReadOk });
2595
+ // #828 P2: batch identity = number + issue set. evalBatchCompleteConfirmed
2596
+ // resets its streak when this changes, so a new already-complete batch can't
2597
+ // inherit the prior batch's first cycle and confirm in one tick.
2598
+ const batchKey = `${batchNumber}::${[...issueNumbers].sort((a, b) => a - b).join(",")}`;
1856
2599
  if (issueNumbers.length === 0) {
1857
- const data = { batch_number: batchNumber, items: [], summary: "", complete: false };
2600
+ evalBatchCompleteConfirmed(projectId, batchKey, false, null); // reset any complete streak
2601
+ const data = { batch_number: batchNumber, items: [], summary: "", complete: false, completeConfirmed: false };
1858
2602
  _batchProgressCache.set(projectId, { ts: Date.now(), data });
1859
- return res.json(data);
1860
- }
1861
-
1862
- // #703: Try batched GraphQL first — one query for all batch items.
1863
- // Falls back to individual gh CLI calls (the #416 parallel approach)
1864
- // if GraphQL fails.
1865
- let items;
1866
- const graphqlData = await fetchBatchProgressGraphQL(repo, issueNumbers);
1867
- if (graphqlData) {
1868
- items = issueNumbers.map((n) => {
1869
- const issueNode = graphqlData[`issue${n}`];
1870
- const row = issueNode ? graphqlIssueToProgressRow(issueNode) : null;
1871
- return row || {
1872
- issue_number: n,
1873
- title: `#${n} (fetch failed)`,
1874
- url: null,
1875
- status: "unknown",
1876
- progress: 0,
1877
- label: "fetch failed",
1878
- };
1879
- });
1880
- } else {
1881
- // Fallback: #416 parallel individual gh CLI calls.
1882
- const settled = await Promise.allSettled(
1883
- issueNumbers.map((n) => progressForItemAsync(repo, n)),
1884
- );
1885
- items = settled.map((r, i) => {
1886
- if (r.status === "fulfilled") return r.value;
1887
- return {
1888
- issue_number: issueNumbers[i],
1889
- title: `#${issueNumbers[i]} (fetch failed)`,
1890
- url: null,
1891
- status: "unknown",
1892
- progress: 0,
1893
- label: "fetch failed",
1894
- };
1895
- });
2603
+ return data;
1896
2604
  }
2605
+
2606
+ // #810: compute each item from the #806 REST snapshot (no GraphQL in steady
2607
+ // state); #828 P1: fall back to a targeted by-number REST/Search fetch
2608
+ // (progressForItemRest, no GraphQL) only for active-batch issues the snapshot
2609
+ // can't prove (outside / truncated window, or absent).
2610
+ const snapshot = _graphqlCache.get(repo) || null;
2611
+ const settled = await Promise.allSettled(
2612
+ issueNumbers.map(async (n) => progressFromSnapshot(snapshot, n) || progressForItemRest(repo, n)),
2613
+ );
2614
+ const items = settled.map((r, i) => {
2615
+ if (r.status === "fulfilled") return r.value;
2616
+ return {
2617
+ issue_number: issueNumbers[i],
2618
+ title: `#${issueNumbers[i]} (fetch failed)`,
2619
+ url: null,
2620
+ status: "unknown",
2621
+ progress: 0,
2622
+ label: "fetch failed",
2623
+ };
2624
+ });
1897
2625
  const summary = summarizeItems(items);
1898
2626
  // #350: treat CLOSED-without-PR items as complete alongside merged
1899
2627
  // so batches that mix runbook/superseded closes with real PRs
1900
2628
  // still flip to the COMPLETE state once everything is done.
1901
2629
  const complete = items.length > 0 && items.every((it) => it.status === "merged" || it.status === "closed");
1902
- const data = { batch_number: batchNumber, items, summary, complete };
2630
+ // #810: confirm `complete` across two distinct successful snapshot cycles
2631
+ // before any auto-stop consumer may act on it (never auto-stop on a single
2632
+ // transient/stale complete). Keyed on the snapshot's ts as the cycle marker.
2633
+ const completeConfirmed = evalBatchCompleteConfirmed(projectId, batchKey, complete, snapshot ? snapshot.ts : null);
2634
+ const data = { batch_number: batchNumber, items, summary, complete, completeConfirmed };
1903
2635
  _batchProgressCache.set(projectId, { ts: Date.now(), data });
1904
- res.json(data);
2636
+ return data;
2637
+ }
2638
+
2639
+ router.get("/api/batch-progress", async (req, res) => {
2640
+ const projectId = req.query.project;
2641
+ if (!projectId) return res.status(400).json({ error: "Missing project" });
2642
+ const data = await getOrComputeBatchProgress(projectId);
2643
+ if (data === null) return res.status(400).json({ error: "No repo configured for project" });
2644
+ return res.json(data);
1905
2645
  });
1906
2646
 
1907
2647
  // #445: Memory section (agent-memory butler integration) removed.
@@ -2171,6 +2911,7 @@ router.post("/api/setup", (req, res) => {
2171
2911
  // below no-ops when the file is already present, so this
2172
2912
  // can't clobber Head/user edits.
2173
2913
  writeOvernightQueueFileSafe(id, cfg.projects.find((p) => p.id === id)?.name || id, cfg.projects.find((p) => p.id === id)?.repo || "");
2914
+ writeGithubFileSafe(id, cfg.projects.find((p) => p.id === id)?.name || id, cfg.projects.find((p) => p.id === id)?.repo || "");
2174
2915
  return res.json({ ok: true, message: "Project already in config" });
2175
2916
  }
2176
2917
  // Match CLI wizard agent structure: { cwd, command, auto_approve, mcp_inject }
@@ -2210,6 +2951,8 @@ router.post("/api/setup", (req, res) => {
2210
2951
  // Batch 25 / #204: seed the per-project OVERNIGHT-QUEUE.md at
2211
2952
  // ~/.quadwork/{id}/OVERNIGHT-QUEUE.md.
2212
2953
  writeOvernightQueueFileSafe(id, name || id, repo);
2954
+ // #807: seed the per-project GITHUB.md alongside it.
2955
+ writeGithubFileSafe(id, name || id, repo);
2213
2956
 
2214
2957
  return res.json({ ok: true });
2215
2958
  }
@@ -2218,6 +2961,399 @@ router.post("/api/setup", (req, res) => {
2218
2961
  }
2219
2962
  });
2220
2963
 
2964
+ // ─── Re-seed agents from current templates (#845) ──────────────────────────
2965
+
2966
+ // Split an AGENTS.md into the leading "intro" text (above the first H2) plus
2967
+ // one block per `## H2` heading. Lines that begin with `## ` (exactly two
2968
+ // hashes + whitespace) start a new block; H1 / H3+ stay inside the current
2969
+ // block so subsections nested under an H2 are not orphaned.
2970
+ function _splitAgentsMdSections(text) {
2971
+ const blocks = [];
2972
+ let intro = [];
2973
+ let current = null;
2974
+ for (const line of String(text || "").split("\n")) {
2975
+ const m = line.match(/^##\s+(.+?)\s*$/);
2976
+ if (m) {
2977
+ if (current) blocks.push(current);
2978
+ current = { heading: m[1], body: "" };
2979
+ } else if (current) {
2980
+ current.body += (current.body ? "\n" : "") + line;
2981
+ } else {
2982
+ intro.push(line);
2983
+ }
2984
+ }
2985
+ if (current) blocks.push(current);
2986
+ return { intro: intro.join("\n"), blocks };
2987
+ }
2988
+
2989
+ // Merge a fresh seed template with operator-added sections from the existing
2990
+ // worktree AGENTS.md. The fresh template wins for any H2 heading it defines
2991
+ // (so canonical role/workflow/communication sections always reflect current
2992
+ // rules), and any H2 block in the existing file whose heading is NOT in the
2993
+ // template is appended at the end under a marker comment. Heading comparison
2994
+ // is case-insensitive and whitespace-normalized.
2995
+ //
2996
+ // Returns { content, preservedHeadings }. If the existing content is empty
2997
+ // or has no custom sections, returns the fresh template verbatim.
2998
+ function reseedAgentsMd(existingContent, freshContent) {
2999
+ if (!existingContent || !existingContent.trim()) {
3000
+ return { content: freshContent, preservedHeadings: [] };
3001
+ }
3002
+ const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
3003
+ const fresh = _splitAgentsMdSections(freshContent);
3004
+ const existing = _splitAgentsMdSections(existingContent);
3005
+ const freshHeadings = new Set(fresh.blocks.map((b) => norm(b.heading)));
3006
+ const preserved = existing.blocks.filter((b) => !freshHeadings.has(norm(b.heading)));
3007
+ if (preserved.length === 0) {
3008
+ return { content: freshContent, preservedHeadings: [] };
3009
+ }
3010
+ const trailer =
3011
+ "\n\n<!-- Operator notes preserved from prior AGENTS.md (#845) -->\n\n" +
3012
+ preserved
3013
+ .map((b) => `## ${b.heading}\n${b.body}`.replace(/\s+$/, ""))
3014
+ .join("\n\n");
3015
+ const base = freshContent.replace(/\s+$/, "");
3016
+ return {
3017
+ content: base + trailer + "\n",
3018
+ preservedHeadings: preserved.map((b) => b.heading),
3019
+ };
3020
+ }
3021
+
3022
+ // #854: Extract the reviewer token path from a worktree's existing AGENTS.md
3023
+ // so the re-seed substitution preserves a custom path instead of resetting
3024
+ // it to the default. The re1/re2 seed renders:
3025
+ //
3026
+ // export GH_TOKEN=$(cat <path>)
3027
+ //
3028
+ // Common operator-edited variations are recognized:
3029
+ // • optional `export ` prefix
3030
+ // • optional outer `"$(cat …)"` double-quote wrapping
3031
+ // • optional inner quoting of the path (single or double quotes)
3032
+ // • whitespace tolerated between tokens
3033
+ //
3034
+ // Returns the raw extracted path (quotes stripped, whitespace trimmed) or
3035
+ // `null` when no GH_TOKEN line is present or the line doesn't use the
3036
+ // `$(cat …)` form. Pure — no I/O.
3037
+ function _extractReviewerTokenPath(existingContent) {
3038
+ if (!existingContent || typeof existingContent !== "string") return null;
3039
+ // First find a GH_TOKEN line. We anchor on the literal name so an operator
3040
+ // comment about token paths can't accidentally seed a stale path.
3041
+ const lineMatch = existingContent.match(/^[ \t]*(?:export[ \t]+)?GH_TOKEN[ \t]*=.*$/m);
3042
+ if (!lineMatch) return null;
3043
+ // Then pull the path out of `$(cat …)` on that line. Non-greedy capture
3044
+ // stops at the first `)` after `cat`, which is the correct boundary for
3045
+ // any well-formed command substitution.
3046
+ const catMatch = lineMatch[0].match(/\$\(\s*cat\s+(.+?)\s*\)/);
3047
+ if (!catMatch) return null;
3048
+ let raw = catMatch[1].trim();
3049
+ if (
3050
+ (raw.startsWith('"') && raw.endsWith('"')) ||
3051
+ (raw.startsWith("'") && raw.endsWith("'"))
3052
+ ) {
3053
+ raw = raw.slice(1, -1);
3054
+ }
3055
+ return raw || null;
3056
+ }
3057
+
3058
+ // #855: Map legacy agent config keys to the canonical seed-template names.
3059
+ // Older projects (and operator-renamed agents) may use keys like
3060
+ // `reviewer1` / `reviewer2` / `t1..t3`; the seed templates only exist for
3061
+ // the canonical `head/re1/re2/dev` slugs. Any key not in this table is
3062
+ // assumed canonical (so future agent slugs work without a code change).
3063
+ const LEGACY_AGENT_KEY_TO_CANONICAL = Object.freeze({
3064
+ t1: "head",
3065
+ t2a: "re1",
3066
+ t2b: "re2",
3067
+ t3: "dev",
3068
+ reviewer1: "re1",
3069
+ reviewer2: "re2",
3070
+ });
3071
+
3072
+ function _canonicalAgentSlug(agentKey) {
3073
+ return LEGACY_AGENT_KEY_TO_CANONICAL[agentKey] || agentKey;
3074
+ }
3075
+
3076
+ // #855: Resolve the per-agent re-seed targets for a project. Pulls the
3077
+ // worktree path from `project.agents[key].cwd` (the source of truth — the
3078
+ // agent process itself spawns there) rather than reconstructing
3079
+ // `${dirName}-${key}`, so legacy worktree names like `*-reviewer1` are
3080
+ // covered. When a project has no `agents` map at all (very old config),
3081
+ // falls back to the canonical sibling layout. Pure — no I/O.
3082
+ function _resolveReseedTargets(project) {
3083
+ const workingDir = project?.working_dir;
3084
+ if (!workingDir) return [];
3085
+ const dirName = path.basename(workingDir);
3086
+ const parentDir = path.dirname(workingDir);
3087
+ const agentsCfg = project.agents && typeof project.agents === "object" ? project.agents : null;
3088
+ const keys = agentsCfg ? Object.keys(agentsCfg) : ["head", "re1", "re2", "dev"];
3089
+ return keys.map((agentKey) => {
3090
+ const canonical = _canonicalAgentSlug(agentKey);
3091
+ const configuredCwd = agentsCfg?.[agentKey]?.cwd;
3092
+ const wtDir = configuredCwd || path.join(parentDir, `${dirName}-${canonical}`);
3093
+ return { agentKey, canonical, wtDir };
3094
+ });
3095
+ }
3096
+
3097
+ // Operator-triggered re-seed of a project's worktree AGENTS.md files from
3098
+ // the current `templates/seeds/*.AGENTS.md` templates, using the same
3099
+ // placeholder substitution as the setup wizard's `seed-files` step. New
3100
+ // seed content (e.g. the #809 GITHUB.md discovery instructions) takes
3101
+ // effect on the NEXT agent restart — this endpoint only rewrites the file.
3102
+ //
3103
+ // Guard: refuses to run while the project's batch is active, so a re-seed
3104
+ // can't drop new instructions on agents in the middle of a task. Pass
3105
+ // `{ force: true }` to override (operator escape hatch — the file is only
3106
+ // re-read on next spawn, so even a forced re-seed is harmless to a live
3107
+ // session, but the guard makes the safe path obvious).
3108
+ router.post("/api/projects/:project/reseed-agents", async (req, res) => {
3109
+ const projectId = req.params.project;
3110
+ const body = req.body || {};
3111
+ const force = body.force === true;
3112
+ let cfg;
3113
+ try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); }
3114
+ catch { return res.status(500).json({ ok: false, error: "Failed to read config" }); }
3115
+ const project = cfg.projects?.find((p) => p.id === projectId);
3116
+ if (!project) return res.status(404).json({ ok: false, error: "Project not found" });
3117
+ const workingDir = project.working_dir;
3118
+ if (!workingDir) return res.status(400).json({ ok: false, error: "Project has no working_dir" });
3119
+
3120
+ if (!force) {
3121
+ // re1 review on #845: fail CLOSED when batch state can't be computed.
3122
+ // Falling through to a re-seed when the gate is uncertain re-introduces
3123
+ // the exact mid-batch rewrite the gate exists to prevent. Three cases
3124
+ // route to the same "unknown" 503 (operator can retry or pass force:true):
3125
+ // 1. getOrComputeBatchProgress throws (network/IO)
3126
+ // 2. getOrComputeBatchProgress returns null (no repo / no progress data)
3127
+ // 3. isBatchActiveFromProgress returns null (no items payload)
3128
+ let active;
3129
+ try {
3130
+ const progress = await getOrComputeBatchProgress(projectId);
3131
+ active = isBatchActiveFromProgress(progress);
3132
+ } catch {
3133
+ return res.status(503).json({
3134
+ ok: false,
3135
+ error: "Could not verify batch state — aborting to avoid a mid-batch re-seed. Retry, or pass force:true to override.",
3136
+ batchUnknown: true,
3137
+ });
3138
+ }
3139
+ if (active === null) {
3140
+ return res.status(503).json({
3141
+ ok: false,
3142
+ error: "Batch state unknown (no progress data) — aborting to avoid a mid-batch re-seed. Retry, or pass force:true to override.",
3143
+ batchUnknown: true,
3144
+ });
3145
+ }
3146
+ if (active) {
3147
+ return res.status(409).json({
3148
+ ok: false,
3149
+ error: "Batch is active — re-seed deferred to avoid mid-batch instruction drift. Pass force:true to override; new seed content only takes effect on next agent restart.",
3150
+ batchActive: true,
3151
+ });
3152
+ }
3153
+ }
3154
+
3155
+ let result;
3156
+ try {
3157
+ result = _performReseedWrites(project, cfg, {
3158
+ reviewerUser: body.reviewerUser,
3159
+ reviewerTokenPath: body.reviewerTokenPath,
3160
+ });
3161
+ } catch (err) {
3162
+ return res.status(500).json({ ok: false, error: err.message });
3163
+ }
3164
+ return res.json({
3165
+ ok: true,
3166
+ reseeded: result.reseeded,
3167
+ skipped: result.skipped,
3168
+ preserved: result.preserved,
3169
+ note: "Re-seeded files take effect on next agent restart.",
3170
+ });
3171
+ });
3172
+
3173
+ // #856: Per-target write loop, extracted so the manual route handler and the
3174
+ // auto-reseed startup hook share one implementation. Throws on missing seed
3175
+ // template (the route handler maps that to a 500). Returns the same shape
3176
+ // the endpoint returns so the auto-reseed log can include per-project stats.
3177
+ //
3178
+ // `opts.reviewerUser` and `opts.reviewerTokenPath` are operator overrides:
3179
+ // when set they win over the per-agent extraction / cfg fallback. Auto-reseed
3180
+ // passes neither so each project uses the same priority chain manual re-seed
3181
+ // uses (per-agent extraction → cfg → default).
3182
+ function _performReseedWrites(project, cfg, opts = {}) {
3183
+ const workingDir = project.working_dir;
3184
+ const dirName = path.basename(workingDir);
3185
+ const reviewerUser = opts.reviewerUser ?? cfg.reviewer_github_user ?? "";
3186
+ const defaultReviewerTokenPath = path.join(os.homedir(), ".quadwork", "reviewer-token");
3187
+
3188
+ // #855: walk the project's configured agents (resolved by `cwd`) instead of
3189
+ // reconstructing sibling paths. Legacy keys (`reviewer1` etc.) still pull
3190
+ // the canonical seed template via `_canonicalAgentSlug`.
3191
+ const targets = _resolveReseedTargets(project);
3192
+ const reseeded = [];
3193
+ const preserved = {};
3194
+ const skipped = [];
3195
+ for (const { agentKey, canonical, wtDir } of targets) {
3196
+ if (!fs.existsSync(wtDir)) { skipped.push(`${agentKey} (no worktree)`); continue; }
3197
+ const seedSrc = path.join(TEMPLATES_DIR, "seeds", `${canonical}.AGENTS.md`);
3198
+ if (!fs.existsSync(seedSrc)) {
3199
+ throw new Error(`Missing seed template: templates/seeds/${canonical}.AGENTS.md`);
3200
+ }
3201
+
3202
+ // #854: Read the existing AGENTS.md FIRST so the per-agent token path
3203
+ // resolution can see what the operator currently has. Resolution order:
3204
+ // 1. explicit opts override (`opts.reviewerTokenPath`)
3205
+ // 2. extracted from this worktree's existing AGENTS.md
3206
+ // 3. project/global config (`cfg.reviewer_token_path`)
3207
+ // 4. default `~/.quadwork/reviewer-token`
3208
+ // Head/dev seeds have no GH_TOKEN line, so (2) is `null` for them and
3209
+ // they fall through to the default — the substituted placeholder is
3210
+ // unused in those templates anyway.
3211
+ const agentsMd = path.join(wtDir, "AGENTS.md");
3212
+ let existing = "";
3213
+ if (fs.existsSync(agentsMd)) {
3214
+ try { existing = fs.readFileSync(agentsMd, "utf-8"); } catch { existing = ""; }
3215
+ }
3216
+ const tokenPath =
3217
+ opts.reviewerTokenPath
3218
+ || _extractReviewerTokenPath(existing)
3219
+ || cfg.reviewer_token_path
3220
+ || defaultReviewerTokenPath;
3221
+
3222
+ let freshContent = fs.readFileSync(seedSrc, "utf-8");
3223
+ freshContent = freshContent
3224
+ .replace(/\{\{reviewer_github_user\}\}/g, reviewerUser)
3225
+ .replace(/\{\{reviewer_token_path\}\}/g, tokenPath)
3226
+ .replace(/\{\{project_name\}\}/g, dirName);
3227
+
3228
+ const merged = reseedAgentsMd(existing, freshContent);
3229
+ fs.writeFileSync(agentsMd, merged.content);
3230
+ reseeded.push(`${agentKey}/AGENTS.md`);
3231
+ if (merged.preservedHeadings.length > 0) {
3232
+ preserved[agentKey] = merged.preservedHeadings;
3233
+ }
3234
+ }
3235
+ return { reseeded, skipped, preserved };
3236
+ }
3237
+
3238
+ // ─── #856: Auto-reseed on version upgrade ────────────────────────────────
3239
+ //
3240
+ // On every server startup we compare the current package version against a
3241
+ // per-project completion record in `~/.quadwork/reseed-state.json`. Any
3242
+ // project that hasn't been re-seeded for the current version gets re-seeded
3243
+ // from the current templates so existing users pick up new seed instructions
3244
+ // (e.g. GITHUB.md discovery) without ever clicking the manual button.
3245
+ //
3246
+ // The per-project state is load-bearing: a project deferred mid-batch stays
3247
+ // pending in the state file so the very next startup (or any future safe-
3248
+ // boundary hook) retries it. Using a single global "last run version"
3249
+ // marker would strand any deferred project — once the marker advanced past
3250
+ // the upgrade version, the deferred project would never be re-seeded.
3251
+
3252
+ const RESEED_STATE_PATH = path.join(os.homedir(), ".quadwork", "reseed-state.json");
3253
+ const PACKAGE_JSON_PATH = path.join(__dirname, "..", "package.json");
3254
+
3255
+ function _readPackageVersion(pkgPath = PACKAGE_JSON_PATH) {
3256
+ try {
3257
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
3258
+ return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
3259
+ } catch { return "0.0.0"; }
3260
+ }
3261
+
3262
+ function _loadReseedState(statePath = RESEED_STATE_PATH) {
3263
+ try {
3264
+ const parsed = JSON.parse(fs.readFileSync(statePath, "utf-8"));
3265
+ const map = parsed && typeof parsed === "object" && parsed !== null && parsed.completedByProjectVersion;
3266
+ const completed = map && typeof map === "object" && !Array.isArray(map) ? { ...map } : {};
3267
+ // Drop non-string version values so a corrupt write can't poison the
3268
+ // up-to-date check (which is a string equality).
3269
+ for (const k of Object.keys(completed)) {
3270
+ if (typeof completed[k] !== "string") delete completed[k];
3271
+ }
3272
+ return { completedByProjectVersion: completed };
3273
+ } catch { return { completedByProjectVersion: {} }; }
3274
+ }
3275
+
3276
+ function _saveReseedState(state, statePath = RESEED_STATE_PATH) {
3277
+ try {
3278
+ const dir = path.dirname(statePath);
3279
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
3280
+ } catch {}
3281
+ // Single fs.writeFileSync — write-replace is atomic enough for this
3282
+ // boot-time-only writer (no concurrent writer exists).
3283
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
3284
+ }
3285
+
3286
+ async function autoReseedOnStartup(cfg, opts = {}) {
3287
+ const version = opts.version || _readPackageVersion();
3288
+ const statePath = opts.statePath || RESEED_STATE_PATH;
3289
+ const log = opts.log || ((m) => console.log(m));
3290
+ // Dependency-injected for tests so we don't have to spin up gh.
3291
+ const getProgress = opts.getProgress || getOrComputeBatchProgress;
3292
+ const isActiveFromProgress = opts.isActiveFromProgress || isBatchActiveFromProgress;
3293
+ const performWrites = opts.performWrites || _performReseedWrites;
3294
+
3295
+ const state = _loadReseedState(statePath);
3296
+ const decisions = [];
3297
+ const projects = (cfg?.projects || []).filter((p) => p && p.id && p.working_dir);
3298
+
3299
+ for (const project of projects) {
3300
+ if (state.completedByProjectVersion[project.id] === version) {
3301
+ decisions.push({ projectId: project.id, action: "skip", reason: "already current" });
3302
+ continue;
3303
+ }
3304
+
3305
+ // Fail-closed batch gate, same contract as the manual /reseed-agents
3306
+ // route (#853): a throw OR a null result both mean "we cannot prove the
3307
+ // batch is idle, so we MUST defer rather than risk mid-batch instruction
3308
+ // drift". The deferred state stays pending so the next startup retries.
3309
+ let active;
3310
+ try {
3311
+ const progress = await getProgress(project.id);
3312
+ active = isActiveFromProgress(progress);
3313
+ } catch (err) {
3314
+ decisions.push({ projectId: project.id, action: "deferred", reason: `batch-state check threw: ${err.message}` });
3315
+ log(`[reseed] ${project.id}: deferred — batch state unknown (${err.message}); will retry on next startup`);
3316
+ continue;
3317
+ }
3318
+ if (active === null) {
3319
+ decisions.push({ projectId: project.id, action: "deferred", reason: "batch state unknown" });
3320
+ log(`[reseed] ${project.id}: deferred — batch state unknown; will retry on next startup`);
3321
+ continue;
3322
+ }
3323
+ if (active === true) {
3324
+ decisions.push({ projectId: project.id, action: "deferred", reason: "batch active" });
3325
+ log(`[reseed] ${project.id}: deferred — batch active; will retry on next startup`);
3326
+ continue;
3327
+ }
3328
+
3329
+ let result;
3330
+ try {
3331
+ result = performWrites(project, cfg, {});
3332
+ } catch (err) {
3333
+ decisions.push({ projectId: project.id, action: "error", error: err.message });
3334
+ log(`[reseed] ${project.id}: ERROR — ${err.message}; state NOT advanced, will retry on next startup`);
3335
+ continue;
3336
+ }
3337
+
3338
+ state.completedByProjectVersion[project.id] = version;
3339
+ try {
3340
+ _saveReseedState(state, statePath);
3341
+ } catch (err) {
3342
+ // If we can't persist, the project will re-seed again next boot.
3343
+ // Idempotent (file rewrite + #845 merger) so this is annoying-but-safe.
3344
+ log(`[reseed] ${project.id}: WARN — could not persist reseed-state (${err.message}); will re-seed next startup`);
3345
+ }
3346
+ decisions.push({
3347
+ projectId: project.id, action: "reseeded",
3348
+ reseeded: result.reseeded, skipped: result.skipped, preserved: result.preserved,
3349
+ });
3350
+ log(`[reseed] ${project.id}: reseeded for v${version} — wrote ${result.reseeded.length} file(s)` +
3351
+ (result.skipped.length ? `, skipped ${result.skipped.length}` : ""));
3352
+ }
3353
+
3354
+ return { version, decisions };
3355
+ }
3356
+
2221
3357
  // ─── Rename ────────────────────────────────────────────────────────────────
2222
3358
 
2223
3359
  function replaceInFile(filePath, oldStr, newStr) {
@@ -2749,9 +3885,91 @@ module.exports.parseActiveBatch = parseActiveBatch;
2749
3885
  // summarizeItems for the batch-progress fixture test.
2750
3886
  module.exports.buildNoPrRow = buildNoPrRow;
2751
3887
  module.exports.summarizeItems = summarizeItems;
3888
+ // #810: expose batch-progress-from-snapshot helpers for unit tests.
3889
+ module.exports.progressFromSnapshot = progressFromSnapshot;
3890
+ module.exports.countApprovedRoles = countApprovedRoles;
3891
+ module.exports.evalBatchCompleteConfirmed = evalBatchCompleteConfirmed;
2752
3892
  // #693: expose normalizeMentions for unit tests
2753
3893
  module.exports.normalizeMentions = normalizeMentions;
2754
3894
  // #714: expose for file-chat integration
2755
3895
  module.exports.getProjectChatMode = getProjectChatMode;
2756
3896
  // #730: PTY dispatch callback setter
2757
3897
  module.exports.setPtyDispatchCallback = setPtyDispatchCallback;
3898
+ // #802: expose the GraphQL rate-limit bucket + predicates for unit tests.
3899
+ // No production callers outside this file.
3900
+ module.exports._graphqlRateLimit = _graphqlRateLimit;
3901
+ module.exports.isGraphqlRateLimited = isGraphqlRateLimited;
3902
+ module.exports.isGraphqlRateLow = isGraphqlRateLow;
3903
+ // #806: expose the canonical-shape transforms for unit tests + the fetcher
3904
+ // entry point (#807's file writer consumes the assembled snapshot).
3905
+ module.exports.githubStateFetcher = githubStateFetcher;
3906
+ module.exports._coalesce = _coalesce;
3907
+ // #837: expose ghApiConditional + the shared list-fetch maxBuffer constant for
3908
+ // the >1MB closed-PR page regression test.
3909
+ module.exports.ghApiConditional = ghApiConditional;
3910
+ module.exports.GH_LIST_MAX_BUFFER = GH_LIST_MAX_BUFFER;
3911
+ // #839: expose the completion-aware sidebar-heartbeat helper for unit tests.
3912
+ module.exports.isBatchActiveFromProgress = isBatchActiveFromProgress;
3913
+ // #845: expose the AGENTS.md re-seed merger so the placeholder substitution +
3914
+ // custom-section preservation contract is exercisable without spinning up a
3915
+ // real worktree. Pure function; no production callers outside this file.
3916
+ module.exports.reseedAgentsMd = reseedAgentsMd;
3917
+ // #855: expose the per-agent target resolver + legacy-key map so the legacy
3918
+ // config (`reviewer1` / `reviewer2` / `t1..t3`) → canonical-template mapping
3919
+ // is exercisable without spinning up a real worktree. Pure helpers; no
3920
+ // production callers outside this file.
3921
+ module.exports._resolveReseedTargets = _resolveReseedTargets;
3922
+ module.exports._canonicalAgentSlug = _canonicalAgentSlug;
3923
+ // #854: expose the GH_TOKEN path extractor so the parse forms (`export`,
3924
+ // double-quoted, inner-quoted, etc.) are exercisable without a temp fs.
3925
+ module.exports._extractReviewerTokenPath = _extractReviewerTokenPath;
3926
+ // #856: expose the auto-reseed startup hook + supporting state/version
3927
+ // helpers. server/index.js calls `autoReseedOnStartup` after config is
3928
+ // loaded; the helpers are exposed for unit tests + the integration test
3929
+ // (state corruption, version round-trip, per-project decision matrix).
3930
+ module.exports.autoReseedOnStartup = autoReseedOnStartup;
3931
+ module.exports._loadReseedState = _loadReseedState;
3932
+ module.exports._saveReseedState = _saveReseedState;
3933
+ module.exports._readPackageVersion = _readPackageVersion;
3934
+ module.exports._performReseedWrites = _performReseedWrites;
3935
+ module.exports.RESEED_STATE_PATH = RESEED_STATE_PATH;
3936
+ // #839 (re1 follow-up): expose the shared compute path plus the caches it
3937
+ // reads so the cache-miss completed-batch case is exercisable end-to-end.
3938
+ module.exports.getOrComputeBatchProgress = getOrComputeBatchProgress;
3939
+ module.exports._batchProgressCache = _batchProgressCache;
3940
+ module.exports._graphqlCache = _graphqlCache;
3941
+ module.exports.restIssueToCanonical = restIssueToCanonical;
3942
+ module.exports.restClosedIssueToCanonical = restClosedIssueToCanonical;
3943
+ module.exports.restMergedPrToCanonical = restMergedPrToCanonical;
3944
+ module.exports.restPullBaseToCanonical = restPullBaseToCanonical;
3945
+ module.exports.mapReviews = mapReviews;
3946
+ module.exports.deriveReviewDecision = deriveReviewDecision;
3947
+ module.exports.buildStatusCheckRollup = buildStatusCheckRollup;
3948
+ // #807: expose the GITHUB.md parser/renderer + role attribution for unit tests.
3949
+ // No production callers outside this file.
3950
+ module.exports.parseGithub = parseGithub;
3951
+ module.exports.renderGithubMarkdown = renderGithubMarkdown;
3952
+ module.exports.attributeReviewsByRole = attributeReviewsByRole;
3953
+ module.exports._githubLiveStale = _githubLiveStale;
3954
+ module.exports._extractNotesBody = _extractNotesBody;
3955
+ module.exports.GITHUB_FRESH_TTL_MS = GITHUB_FRESH_TTL_MS;
3956
+ // #824: expose the bare-array list handler + its cache and the REST rate-limit
3957
+ // bucket so the regression test can drive the stale/rate-limited paths and
3958
+ // assert the body stays a JSON array (never an object). No production callers
3959
+ // outside this file.
3960
+ module.exports.serveGithubList = serveGithubList;
3961
+ module.exports._ghEndpointCache = _ghEndpointCache;
3962
+ module.exports._rateLimit = _rateLimit;
3963
+ // #828: expose the merged-PR pagination helpers + the REST-search linked-PR
3964
+ // picker for unit tests. No production callers outside this file.
3965
+ module.exports.gatherClosedPrPages = gatherClosedPrPages;
3966
+ module.exports.selectRecentMergedPrs = selectRecentMergedPrs;
3967
+ // #834: closed-PR window completeness + linked-issue extraction, for the
3968
+ // queued-from-snapshot fix. No production callers outside this file.
3969
+ module.exports.closedPagesComplete = closedPagesComplete;
3970
+ module.exports.closedPrIssueNumsFromPages = closedPrIssueNumsFromPages;
3971
+ module.exports.pickLinkedPrFromSearch = pickLinkedPrFromSearch;
3972
+ module.exports.findLinkedPrByTitle = findLinkedPrByTitle;
3973
+ // #827: expose the GITHUB.md writer for the idle-no-op regression test. No
3974
+ // production callers outside this file.
3975
+ module.exports.writeGithubFileFromSnapshot = writeGithubFileFromSnapshot;