quadwork 2.1.0 → 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/{0ud0uv.699had.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 +89 -17
  68. package/server/routes.js +1672 -510
  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/0pfyuhd8ccue..css +0 -2
  79. package/out/_next/static/chunks/0q4bm04c1jl_3.js +0 -1
  80. package/out/_next/static/chunks/0zk4tzycn0w4g.js +0 -25
  81. /package/out/_next/static/{vvtpLPTwziTD3klXH46MU → h8gr2UEtEQkyXBVa2J0z9}/_buildManifest.js +0 -0
  82. /package/out/_next/static/{vvtpLPTwziTD3klXH46MU → h8gr2UEtEQkyXBVa2J0z9}/_clientMiddlewareManifest.js +0 -0
  83. /package/out/_next/static/{vvtpLPTwziTD3klXH46MU → 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,80 +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, idle } = {}) {
124
- const cached = _ghEndpointCache.get(cacheKey);
125
- // #812: a parked (idle) project must never initiate a gh fetch. Serve
126
- // whatever we last cached, or an empty list — never call gh. The list
127
- // endpoints return a bare array (client contract), so signal idle via a
128
- // response header rather than mutating the JSON shape.
129
- if (idle) {
130
- res.set("X-QuadWork-Idle", "1");
131
- return res.json(cached ? cached.data : []);
132
- }
133
- const ttl = adaptiveTTL(GH_ENDPOINT_CACHE_TTL);
134
- if (cached && Date.now() - cached.ts < ttl) {
135
- return res.json(cached.stale ? { ...cached.data, _stale: true } : cached.data);
136
- }
137
- // If critically rate-limited, serve whatever we have (even expired)
138
- if (isRateLimited() && cached) {
139
- return res.json({ ...cached.data, _stale: true, _rateLimited: true });
140
- }
141
- // #698: stale-while-revalidate — if we have stale data, serve it
142
- // immediately and enqueue a background refresh. The queue caps
143
- // concurrent gh calls to GH_MAX_CONCURRENT to prevent burst traffic.
144
- if (cached) {
145
- _ghEnqueueRefresh(cacheKey, ghArgs, transform);
146
- return res.json({ ...cached.data, _stale: true });
147
- }
148
- // No cached data at all — must fetch synchronously for first load
149
- try {
150
- const out = execFileSync("gh", ghArgs, { encoding: "utf-8", timeout: 15000 });
151
- let data = JSON.parse(out);
152
- if (transform) data = transform(data);
153
- _ghEndpointCache.set(cacheKey, { ts: Date.now(), data, stale: false });
154
- res.json(data);
155
- } catch (err) {
156
- res.status(502).json({ error: "gh call failed", detail: err.message });
157
- }
158
- }
159
135
 
160
136
  const DEFAULT_CONFIG = {
161
137
  port: 8400,
@@ -248,6 +224,23 @@ function writeOvernightQueueFileSafe(projectId, projectName, repo) {
248
224
  } catch { /* non-fatal */ }
249
225
  }
250
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
+
251
244
  function getProjectMaxHops(projectId) {
252
245
  if (!projectId) return 30;
253
246
  const cfg = readConfigFile();
@@ -286,16 +279,20 @@ router.get("/api/chat", (req, res) => {
286
279
  // Bare "head", "dev", "re1", "re2" become "@head", "@dev", "@re1", "@re2".
287
280
  // Already-prefixed mentions are not double-prefixed; suffixed names like
288
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.
289
285
  const MENTION_AGENT_NAMES = ["head", "dev", "re1", "re2"];
290
- function normalizeMentions(text) {
286
+ function normalizeMentions(text, skipName) {
291
287
  if (typeof text !== "string" || !text) return text || "";
288
+ const names = skipName ? MENTION_AGENT_NAMES.filter((n) => n !== skipName) : MENTION_AGENT_NAMES;
292
289
  const preserved = [];
293
290
  const ph = "\x00CODE\x00";
294
291
  let safe = text.replace(/```[\s\S]*?```|`[^`]+`/g, (m) => {
295
292
  preserved.push(m);
296
293
  return ph;
297
294
  });
298
- safe = MENTION_AGENT_NAMES.reduce(
295
+ safe = names.reduce(
299
296
  (t, name) =>
300
297
  t.replace(new RegExp(`(?<![@\\w])\\b${name}\\b(?![\\w-])`, "gi"), (match, offset, str) => {
301
298
  const before = str.slice(Math.max(0, offset - 20), offset);
@@ -718,17 +715,21 @@ router.post("/api/chat", (req, res) => {
718
715
  const shimToken = req.headers["x-chat-token"];
719
716
  const bridgeSender = req.headers["x-bridge-sender"];
720
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;
721
721
  if (shimSender && shimToken) {
722
722
  if (!fileChat.validateShimToken(projectId, shimSender, shimToken)) {
723
723
  return res.status(403).json({ error: "Invalid shim token" });
724
724
  }
725
725
  sender = shimSender;
726
+ selfMentionSkip = shimSender;
726
727
  } else if (bridgeSender && isLocalhost(req.ip)) {
727
728
  sender = bridgeSender;
728
729
  }
729
730
  const msg = fileChat.appendMessage(projectId, {
730
731
  sender,
731
- text: normalizeMentions(text),
732
+ text: normalizeMentions(text, selfMentionSkip),
732
733
  channel: req.body?.channel || "general",
733
734
  type: "message",
734
735
  });
@@ -874,12 +875,14 @@ router.get("/api/projects", async (req, res) => {
874
875
  }
875
876
  if (REPO_RE.test(p.repo)) {
876
877
  try {
877
- const [prs, recentPrs] = await Promise.allSettled([
878
- ghJsonExecAsync(["pr", "list", "-R", p.repo, "--json", "number", "--limit", "100"]),
879
- 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`),
880
883
  ]);
881
- if (prs.status === "fulfilled") openPrs = prs.value.length;
882
- 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;
883
886
  } catch {}
884
887
  }
885
888
  const hasAgents = p.agents && Object.keys(p.agents).length > 0;
@@ -973,11 +976,11 @@ function isProjectIdle(projectId) {
973
976
  }
974
977
  }
975
978
 
976
- // ─── #703: Batched GraphQL layer ──────────────────────────────────────────
977
- // Instead of spawning individual `gh issue list` / `gh pr list` subprocesses
978
- // per project per endpoint, we fetch ALL configured projects' GitHub data in
979
- // a single GraphQL query. The per-project endpoints read from this shared
980
- // cache, falling back to individual gh CLI calls if GraphQL fails.
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.
981
984
 
982
985
  const _graphqlCache = new Map(); // repo → { ts, issues, prs, closedIssues, mergedPrs }
983
986
  const GRAPHQL_CACHE_TTL = 60_000; // same as GH_ENDPOINT_CACHE_TTL
@@ -985,6 +988,676 @@ let _graphqlRefreshInFlight = false;
985
988
 
986
989
  const RECENT_FETCH_LIMIT = 20;
987
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
+ }
988
1661
 
989
1662
  // Build and execute a batched GraphQL query for all configured projects.
990
1663
  // Returns a Map of repo → { issues, prs, closedIssues, mergedPrs }.
@@ -1021,7 +1694,7 @@ fragment repoFields on Repository {
1021
1694
  nodes { number title url state closedAt }
1022
1695
  }
1023
1696
  openPRs: pullRequests(first: 50, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
1024
- 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 }
1025
1698
  }
1026
1699
  mergedPRs: pullRequests(first: ${RECENT_FETCH_LIMIT}, states: MERGED, orderBy: {field: UPDATED_AT, direction: DESC}) {
1027
1700
  nodes { number title url state mergedAt author { login } }
@@ -1031,7 +1704,7 @@ fragment repoFields on Repository {
1031
1704
  try {
1032
1705
  const { stdout } = await _execFileAsync("gh", [
1033
1706
  "api", "graphql", "-f", `query=${query}`,
1034
- ], { encoding: "utf-8", timeout: 15000 });
1707
+ ], { encoding: "utf-8", timeout: 15000, maxBuffer: GH_LIST_MAX_BUFFER });
1035
1708
  const data = JSON.parse(stdout).data;
1036
1709
  if (!data) return null;
1037
1710
 
@@ -1042,10 +1715,13 @@ fragment repoFields on Repository {
1042
1715
  if (!repoData) continue;
1043
1716
 
1044
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.
1045
1721
  const issues = (repoData.openIssues?.nodes || []).map((n) => ({
1046
1722
  number: n.number,
1047
1723
  title: n.title,
1048
- state: n.state === "OPEN" ? "open" : n.state?.toLowerCase() || n.state,
1724
+ state: (n.state || "").toUpperCase(),
1049
1725
  url: n.url,
1050
1726
  labels: (n.labels?.nodes || []).map((l) => ({ name: l.name })),
1051
1727
  assignees: (n.assignees?.nodes || []).map((a) => ({ login: a.login })),
@@ -1055,7 +1731,7 @@ fragment repoFields on Repository {
1055
1731
  const prs = (repoData.openPRs?.nodes || []).map((n) => ({
1056
1732
  number: n.number,
1057
1733
  title: n.title,
1058
- state: n.state === "OPEN" ? "open" : n.state?.toLowerCase() || n.state,
1734
+ state: (n.state || "").toUpperCase(),
1059
1735
  url: n.url,
1060
1736
  author: n.author ? { login: n.author.login } : null,
1061
1737
  assignees: [],
@@ -1063,6 +1739,7 @@ fragment repoFields on Repository {
1063
1739
  state: r.state,
1064
1740
  author: r.author ? { login: r.author.login } : null,
1065
1741
  submittedAt: r.submittedAt,
1742
+ body: r.body || "",
1066
1743
  })),
1067
1744
  createdAt: n.createdAt,
1068
1745
  }));
@@ -1078,7 +1755,7 @@ fragment repoFields on Repository {
1078
1755
  .map((n) => ({
1079
1756
  number: n.number,
1080
1757
  title: n.title,
1081
- state: n.state?.toLowerCase() || "closed",
1758
+ state: (n.state || "CLOSED").toUpperCase(),
1082
1759
  url: n.url,
1083
1760
  closedAt: n.closedAt,
1084
1761
  }));
@@ -1094,7 +1771,7 @@ fragment repoFields on Repository {
1094
1771
  .map((n) => ({
1095
1772
  number: n.number,
1096
1773
  title: n.title,
1097
- state: n.state?.toLowerCase() || "merged",
1774
+ state: (n.state || "MERGED").toUpperCase(),
1098
1775
  url: n.url,
1099
1776
  mergedAt: n.mergedAt,
1100
1777
  author: n.author ? { login: n.author.login } : null,
@@ -1112,24 +1789,48 @@ fragment repoFields on Repository {
1112
1789
  // and on demand when a per-project endpoint has no cached data.
1113
1790
  async function refreshGraphQLCache() {
1114
1791
  if (_graphqlRefreshInFlight) return;
1115
- 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;
1116
1797
  _graphqlRefreshInFlight = true;
1117
1798
  try {
1118
- const data = await fetchAllProjectsGraphQL();
1119
- if (data) {
1120
- const now = Date.now();
1121
- for (const [repo, repoData] of data) {
1122
- _graphqlCache.set(repo, { ts: now, ...repoData });
1123
- // Also populate the per-endpoint _ghEndpointCache so stale-while-
1124
- // revalidate and existing per-project endpoints pick up the data.
1125
- _ghEndpointCache.set(`issues:${repo}`, { ts: now, data: repoData.issues, stale: false });
1126
- _ghEndpointCache.set(`prs:${repo}`, { ts: now, data: repoData.prs, stale: false });
1127
- _ghEndpointCache.set(`closed-issues:${repo}`, { ts: now, data: repoData.closedIssues, stale: false });
1128
- _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
+ }
1129
1830
  }
1130
1831
  }
1131
1832
  } catch {
1132
- // Non-fatal — per-project endpoints still work via individual gh CLI.
1833
+ // Non-fatal — consumers serve last-known cache.
1133
1834
  } finally {
1134
1835
  _graphqlRefreshInFlight = false;
1135
1836
  }
@@ -1139,128 +1840,110 @@ async function refreshGraphQLCache() {
1139
1840
  let _graphqlPollTimer = null;
1140
1841
  function startGraphQLPolling() {
1141
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.
1142
1846
  // Initial fetch after a short delay (let rate-limit poll run first).
1143
- setTimeout(() => refreshGraphQLCache(), 2000);
1847
+ setTimeout(() => refreshGraphQLCache(), 2000).unref();
1144
1848
  _graphqlPollTimer = setInterval(refreshGraphQLCache, GRAPHQL_CACHE_TTL);
1849
+ _graphqlPollTimer.unref();
1145
1850
  }
1146
1851
 
1147
- // #703: Batched GraphQL for batch progress fetch all issue states +
1148
- // linked PRs in a single query instead of 2N individual gh calls.
1149
- async function fetchBatchProgressGraphQL(repo, issueNumbers) {
1150
- if (!issueNumbers || issueNumbers.length === 0) return null;
1151
- const [owner, name] = repo.split("/");
1152
- if (!owner || !name) return null;
1153
-
1154
- // Build aliased issue fields.
1155
- const issueFields = issueNumbers.map((n) =>
1156
- `issue${n}: issue(number: ${n}) {
1157
- number title state url
1158
- closedByPullRequestsReferences(first: 3) {
1159
- nodes { number state url merged reviews(last: 100) { nodes { state author { login } submittedAt } } }
1160
- }
1161
- }`
1162
- ).join("\n ");
1163
-
1164
- const query = `query {
1165
- repository(owner: "${owner}", name: "${name}") {
1166
- ${issueFields}
1167
- }
1168
- }`;
1169
-
1170
- try {
1171
- const { stdout } = await _execFileAsync("gh", [
1172
- "api", "graphql", "-f", `query=${query}`,
1173
- ], { encoding: "utf-8", timeout: 15000 });
1174
- const data = JSON.parse(stdout).data;
1175
- if (!data?.repository) return null;
1176
- return data.repository;
1177
- } catch {
1178
- return null; // fallback to individual gh CLI calls
1179
- }
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;
1180
1864
  }
1181
1865
 
1182
- // Convert a GraphQL batch progress issue node into the same progress
1183
- // row shape that progressForItemAsync produces.
1184
- function graphqlIssueToProgressRow(issueData) {
1185
- if (!issueData) return null;
1186
-
1187
- const linked = issueData.closedByPullRequestsReferences?.nodes || [];
1188
- const pr = linked.length > 0
1189
- ? linked.slice().sort((a, b) => (b.number || 0) - (a.number || 0))[0]
1190
- : null;
1191
-
1192
- // No linked PR — delegate to the existing buildNoPrRow helper.
1193
- if (!pr) {
1194
- return buildNoPrRow({
1195
- number: issueData.number,
1196
- title: issueData.title,
1197
- state: issueData.state,
1198
- url: issueData.url,
1199
- });
1200
- }
1201
-
1202
- const merged = pr.merged && issueData.state === "CLOSED";
1203
- 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) {
1204
1889
  return {
1205
- issue_number: issueData.number,
1206
- title: issueData.title,
1207
- url: pr.url || issueData.url,
1208
- pr_number: pr.number,
1209
- status: "merged",
1210
- progress: 100,
1211
- 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 ✓",
1212
1892
  };
1213
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
+ }
1214
1922
 
1215
- // Count distinct APPROVED reviews per author.
1216
- const reviews = (pr.reviews?.nodes || []).slice();
1217
- reviews.sort((a, b) => {
1218
- const ta = a?.submittedAt ? Date.parse(a.submittedAt) : 0;
1219
- const tb = b?.submittedAt ? Date.parse(b.submittedAt) : 0;
1220
- return ta - tb;
1221
- });
1222
- const latestByAuthor = new Map();
1223
- for (const r of reviews) {
1224
- const author = r?.author?.login || "";
1225
- if (!author) continue;
1226
- latestByAuthor.set(author, r.state);
1227
- }
1228
- let approvalCount = 0;
1229
- for (const state of latestByAuthor.values()) {
1230
- if (state === "APPROVED") approvalCount++;
1231
- }
1232
-
1233
- if (approvalCount >= 2) {
1234
- return {
1235
- issue_number: issueData.number,
1236
- title: issueData.title,
1237
- url: pr.url || issueData.url,
1238
- pr_number: pr.number,
1239
- status: "ready",
1240
- progress: 80,
1241
- label: `PR #${pr.number} · 2 approvals · ready`,
1242
- };
1243
- }
1244
- if (approvalCount === 1) {
1245
- return {
1246
- issue_number: issueData.number,
1247
- title: issueData.title,
1248
- url: pr.url || issueData.url,
1249
- pr_number: pr.number,
1250
- status: "approved1",
1251
- progress: 50,
1252
- label: `PR #${pr.number} · 1 approval`,
1253
- };
1254
- }
1255
- return {
1256
- issue_number: issueData.number,
1257
- title: issueData.title,
1258
- url: pr.url || issueData.url,
1259
- pr_number: pr.number,
1260
- status: "in_review",
1261
- progress: 20,
1262
- label: `PR #${pr.number} · waiting on review`,
1263
- };
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
1264
1947
  }
1265
1948
 
1266
1949
  // ─── /api/github/all — batched endpoint (#703) ────────────────────────────
@@ -1314,41 +1997,22 @@ router.get("/api/github/all", async (req, res) => {
1314
1997
  }
1315
1998
  }
1316
1999
 
1317
- // 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.
1318
2003
  if (fallbackNeeded.length > 0 && !isRateLimited()) {
1319
- const fallbackResults = await Promise.allSettled(
1320
- fallbackNeeded.map(async (p) => {
1321
- const repo = p.repo;
1322
- const [issues, prs, closedIssues, mergedPrs] = await Promise.allSettled([
1323
- _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)),
1324
- _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)),
1325
- _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 }) => {
1326
- const items = JSON.parse(stdout);
1327
- return Array.isArray(items)
1328
- ? items.sort((a, b) => (Date.parse(b?.closedAt || 0)) - (Date.parse(a?.closedAt || 0))).slice(0, RECENT_DISPLAY_LIMIT)
1329
- : items;
1330
- }),
1331
- _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 }) => {
1332
- const items = JSON.parse(stdout);
1333
- return Array.isArray(items)
1334
- ? items.sort((a, b) => (Date.parse(b?.mergedAt || 0)) - (Date.parse(a?.mergedAt || 0))).slice(0, RECENT_DISPLAY_LIMIT)
1335
- : items;
1336
- }),
1337
- ]);
1338
- return {
1339
- id: p.id,
1340
- issues: issues.status === "fulfilled" ? issues.value : [],
1341
- prs: prs.status === "fulfilled" ? prs.value : [],
1342
- closedIssues: closedIssues.status === "fulfilled" ? closedIssues.value : [],
1343
- mergedPrs: mergedPrs.status === "fulfilled" ? mergedPrs.value : [],
1344
- _fallback: true,
1345
- };
1346
- }),
1347
- );
1348
- for (const r of fallbackResults) {
1349
- if (r.status === "fulfilled") {
1350
- result[r.value.id] = r.value;
1351
- }
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 };
1352
2016
  }
1353
2017
  }
1354
2018
 
@@ -1356,90 +2020,71 @@ router.get("/api/github/all", async (req, res) => {
1356
2020
  });
1357
2021
 
1358
2022
  // ─── Per-project endpoints (backward compat, served from shared cache) ────
1359
-
1360
- router.get("/api/github/issues", (req, res) => {
1361
- 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);
1362
2032
  if (!repo) return res.status(400).json({ error: "No repo configured for project" });
1363
- cachedGhEndpoint(
1364
- `issues:${repo}`,
1365
- ["issue", "list", "-R", repo, "--json", "number,title,state,assignees,labels,createdAt,url", "--limit", "50"],
1366
- res,
1367
- { idle: isProjectIdle(req.query.project || "") },
1368
- );
1369
- });
2033
+ const cacheKey = `${kind}:${repo}`;
2034
+ const cached = _ghEndpointCache.get(cacheKey);
1370
2035
 
1371
- router.get("/api/github/prs", (req, res) => {
1372
- const repo = getRepo(req.query.project || "");
1373
- if (!repo) return res.status(400).json({ error: "No repo configured for project" });
1374
- cachedGhEndpoint(
1375
- `prs:${repo}`,
1376
- ["pr", "list", "-R", repo, "--json", "number,title,state,author,assignees,reviewDecision,reviews,statusCheckRollup,url,createdAt", "--limit", "50"],
1377
- res,
1378
- { idle: isProjectIdle(req.query.project || "") },
1379
- );
1380
- });
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
+ }
1381
2041
 
1382
- // #411 / quadwork#281: recently closed issues + merged PRs for the
1383
- // "Recently closed" / "Recently merged" sub-sections under each
1384
- // list in GitHubPanel. Limit 5 items each, ordered by closedAt
1385
- // descending so the freshest activity sits at the top.
1386
- // gh CLI's default ordering for `issue list --state closed` and
1387
- // `pr list --state merged` is createdAt-desc, not closedAt/mergedAt-desc,
1388
- // so a stale-but-recently-closed item can sit below a fresh-but-
1389
- // older one. We pull a wider window and re-sort by close/merge time
1390
- // before truncating to 5 to honor #281's "newest first" requirement.
1391
-
1392
- router.get("/api/github/closed-issues", (req, res) => {
1393
- const repo = getRepo(req.query.project || "");
1394
- if (!repo) return res.status(400).json({ error: "No repo configured for project" });
1395
- cachedGhEndpoint(
1396
- `closed-issues:${repo}`,
1397
- ["issue", "list", "-R", repo, "--state", "closed", "--json", "number,title,state,url,closedAt", "--limit", String(RECENT_FETCH_LIMIT)],
1398
- res,
1399
- {
1400
- idle: isProjectIdle(req.query.project || ""),
1401
- transform: (items) =>
1402
- Array.isArray(items)
1403
- ? items
1404
- .slice()
1405
- .sort((a, b) => {
1406
- const ta = a && a.closedAt ? Date.parse(a.closedAt) : 0;
1407
- const tb = b && b.closedAt ? Date.parse(b.closedAt) : 0;
1408
- return tb - ta;
1409
- })
1410
- .slice(0, RECENT_DISPLAY_LIMIT)
1411
- : items,
1412
- },
1413
- );
1414
- });
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
+ }
1415
2080
 
1416
- router.get("/api/github/merged-prs", (req, res) => {
1417
- const repo = getRepo(req.query.project || "");
1418
- if (!repo) return res.status(400).json({ error: "No repo configured for project" });
1419
- // gh pr list with `--state merged` filters server-side so we
1420
- // don't have to pull every closed PR and discard the un-merged
1421
- // ones (closed-without-merge). Same fetch-wider-then-sort
1422
- // strategy as closed-issues so the newest merge always wins.
1423
- cachedGhEndpoint(
1424
- `merged-prs:${repo}`,
1425
- ["pr", "list", "-R", repo, "--state", "merged", "--json", "number,title,state,url,mergedAt,author", "--limit", String(RECENT_FETCH_LIMIT)],
1426
- res,
1427
- {
1428
- idle: isProjectIdle(req.query.project || ""),
1429
- transform: (items) =>
1430
- Array.isArray(items)
1431
- ? items
1432
- .slice()
1433
- .sort((a, b) => {
1434
- const ta = a && a.mergedAt ? Date.parse(a.mergedAt) : 0;
1435
- const tb = b && b.mergedAt ? Date.parse(b.mergedAt) : 0;
1436
- return tb - ta;
1437
- })
1438
- .slice(0, RECENT_DISPLAY_LIMIT)
1439
- : items,
1440
- },
1441
- );
1442
- });
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"));
1443
2088
 
1444
2089
  // #413 / quadwork#282: Current Batch Progress panel.
1445
2090
  //
@@ -1514,15 +2159,10 @@ async function checkBatchSnapshotFreshness(repo, snapshot) {
1514
2159
  }
1515
2160
  const first = snapshot.issueNumbers[0];
1516
2161
  try {
1517
- await ghJsonExecAsync([
1518
- "issue",
1519
- "view",
1520
- String(first),
1521
- "-R",
1522
- repo,
1523
- "--json",
1524
- "number",
1525
- ]);
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"]);
1526
2166
  return "fresh";
1527
2167
  } catch (err) {
1528
2168
  // gh surfaces a 404 via stderr text on a non-zero exit. Only
@@ -1530,7 +2170,7 @@ async function checkBatchSnapshotFreshness(repo, snapshot) {
1530
2170
  // count as genuinely gone; anything else (network, auth,
1531
2171
  // timeout) is transient and must NOT delete the snapshot.
1532
2172
  const msg = String((err && (err.stderr || err.message)) || "").toLowerCase();
1533
- 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")) {
1534
2174
  return "gone";
1535
2175
  }
1536
2176
  return "unknown";
@@ -1634,22 +2274,22 @@ function parseActiveBatch(queueText) {
1634
2274
  // progress fetcher. Wraps node's execFile in a promise.
1635
2275
  //
1636
2276
  // THROWS on subprocess failure (non-zero exit, timeout, JSON parse,
1637
- // network) so progressForItemAsync can decide which subset of
2277
+ // network) so progressForItemRest can decide which subset of
1638
2278
  // failures should bubble up to the Promise.allSettled "fetch failed"
1639
2279
  // row vs. which should fall through to a softer state. The previous
1640
2280
  // catch-all-and-return-null contract collapsed real subprocess
1641
2281
  // errors into the "not found" branch, making the new failure-row
1642
2282
  // fallback unreachable for genuine command failures (t2a review).
1643
2283
  async function ghJsonExecAsync(args) {
1644
- 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 });
1645
2285
  return JSON.parse(stdout);
1646
2286
  }
1647
2287
 
1648
2288
  // #350: pure helper for the "no linked PR" branch of
1649
- // progressForItemAsync. Takes the issue JSON (shape: { number,
2289
+ // progressForItemRest. Takes the issue JSON (shape: { number,
1650
2290
  // title, state, url, ... }) and returns the batch-progress row
1651
- // for an item that has no closedByPullRequestsReferences. Exported
1652
- // 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.
1653
2293
  function buildNoPrRow(issue) {
1654
2294
  if (issue && issue.state === "CLOSED") {
1655
2295
  return {
@@ -1671,61 +2311,79 @@ function buildNoPrRow(issue) {
1671
2311
  };
1672
2312
  }
1673
2313
 
1674
- async function progressForItemAsync(repo, issueNumber) {
1675
- // Pull issue state + linked PRs in one call. closedByPullRequestsReferences
1676
- // is gh's serializer for the GraphQL `closedByPullRequestsReferences`
1677
- // edge only present when a PR with `Fixes #N` / `Closes #N`
1678
- // (or the link UI) targets the issue.
1679
- // Issue fetch is the load-bearing call if gh can't read the
1680
- // issue at all (404, network, auth, timeout) we can't compute a
1681
- // meaningful progress row. Let the rejection propagate to the
1682
- // route's Promise.allSettled so the operator sees a single
1683
- // "fetch failed" row instead of a misleading "queued" entry.
1684
- const issue = await ghJsonExecAsync([
1685
- "issue",
1686
- "view",
1687
- String(issueNumber),
1688
- "-R",
1689
- repo,
1690
- "--json",
1691
- "number,title,state,url,closedByPullRequestsReferences",
1692
- ]);
1693
- const linked = Array.isArray(issue.closedByPullRequestsReferences)
1694
- ? issue.closedByPullRequestsReferences
1695
- : [];
1696
- // Pick the freshest linked PR (highest number) if there are multiple.
1697
- const pr = linked.length > 0
1698
- ? linked.slice().sort((a, b) => (b.number || 0) - (a.number || 0))[0]
1699
- : null;
1700
- // No linked PR. #350: before falling into the "queued" bucket,
1701
- // honor the issue's own state a CLOSED issue with no linked
1702
- // PR is fully done (superseded, not planned, runbook-only, etc.)
1703
- // and should render at 100% with a label instead of a
1704
- // misleading "0% · queued" row. Only truly OPEN issues with no
1705
- // linked PR are still queued.
1706
- 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) {
1707
2379
  return buildNoPrRow(issue);
1708
2380
  }
1709
- // Re-fetch the PR to get reviewDecision + reviews + state, since
1710
- // the issue's closedByPullRequestsReferences edge only carries
1711
- // number/state/url. The PR fetch is intentionally soft: if gh
1712
- // glitches on this single call we still know the PR exists (we
1713
- // got the link from the issue) and can render a partial
1714
- // "in_review" row, which is more useful than dropping the whole
1715
- // item to "fetch failed". A persistent failure here will still
1716
- // surface on the next cache miss because the issue fetch above
1717
- // 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".
1718
2384
  let prData = null;
1719
2385
  try {
1720
- prData = await ghJsonExecAsync([
1721
- "pr",
1722
- "view",
1723
- String(pr.number),
1724
- "-R",
1725
- repo,
1726
- "--json",
1727
- "number,state,url,reviewDecision,reviews",
1728
- ]);
2386
+ prData = await ghJsonExecAsync(["api", `repos/${repo}/pulls/${prNumber}`]);
1729
2387
  } catch {
1730
2388
  // soft fall-through to the in_review row below
1731
2389
  }
@@ -1733,78 +2391,44 @@ async function progressForItemAsync(repo, issueNumber) {
1733
2391
  return {
1734
2392
  issue_number: issue.number,
1735
2393
  title: issue.title,
1736
- url: pr.url || issue.url,
1737
- pr_number: pr.number,
2394
+ url: issue.url,
2395
+ pr_number: prNumber,
1738
2396
  status: "in_review",
1739
2397
  progress: 20,
1740
- label: `PR #${pr.number} · waiting on review`,
2398
+ label: `PR #${prNumber} · waiting on review`,
1741
2399
  };
1742
2400
  }
1743
- const merged = prData.state === "MERGED" && issue.state === "CLOSED";
1744
- 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") {
1745
2412
  return {
1746
2413
  issue_number: issue.number,
1747
2414
  title: issue.title,
1748
- url: prData.url || issue.url,
2415
+ url,
1749
2416
  pr_number: prData.number,
1750
2417
  status: "merged",
1751
2418
  progress: 100,
1752
2419
  label: "Merged ✓",
1753
2420
  };
1754
2421
  }
1755
- // Count distinct APPROVED reviews per author so a stale APPROVED
1756
- // followed by REQUEST_CHANGES doesn't double-count. Sort by
1757
- // submittedAt ascending first so the Map's "last write wins"
1758
- // genuinely lands on the freshest review per author — gh's
1759
- // current ordering is chronological in practice but undocumented,
1760
- // so the explicit sort keeps us safe if that ever changes.
1761
- const reviews = Array.isArray(prData.reviews) ? prData.reviews.slice() : [];
1762
- reviews.sort((a, b) => {
1763
- const ta = (a && a.submittedAt) ? Date.parse(a.submittedAt) : 0;
1764
- const tb = (b && b.submittedAt) ? Date.parse(b.submittedAt) : 0;
1765
- return ta - tb;
1766
- });
1767
- const latestByAuthor = new Map();
1768
- for (const r of reviews) {
1769
- const author = (r && r.author && r.author.login) || "";
1770
- if (!author) continue;
1771
- latestByAuthor.set(author, r.state);
1772
- }
1773
- let approvalCount = 0;
1774
- for (const state of latestByAuthor.values()) {
1775
- if (state === "APPROVED") approvalCount++;
1776
- }
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);
1777
2425
  if (approvalCount >= 2) {
1778
- return {
1779
- issue_number: issue.number,
1780
- title: issue.title,
1781
- url: prData.url || issue.url,
1782
- pr_number: prData.number,
1783
- status: "ready",
1784
- progress: 80,
1785
- label: `PR #${prData.number} · 2 approvals · ready`,
1786
- };
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` };
1787
2427
  }
1788
2428
  if (approvalCount === 1) {
1789
- return {
1790
- issue_number: issue.number,
1791
- title: issue.title,
1792
- url: prData.url || issue.url,
1793
- pr_number: prData.number,
1794
- status: "approved1",
1795
- progress: 50,
1796
- label: `PR #${prData.number} · 1 approval`,
1797
- };
2429
+ return { issue_number: issue.number, title: issue.title, url, pr_number: prData.number, status: "approved1", progress: 50, label: `PR #${prData.number} · 1 approval` };
1798
2430
  }
1799
- return {
1800
- issue_number: issue.number,
1801
- title: issue.title,
1802
- url: prData.url || issue.url,
1803
- pr_number: prData.number,
1804
- status: "in_review",
1805
- progress: 20,
1806
- label: `PR #${prData.number} · waiting on review`,
1807
- };
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` };
1808
2432
  }
1809
2433
 
1810
2434
  function summarizeItems(items) {
@@ -1832,45 +2456,104 @@ function summarizeItems(items) {
1832
2456
  return parts.join(" · ");
1833
2457
  }
1834
2458
 
1835
- 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) => {
1836
2474
  const projectId = req.query.project;
1837
2475
  if (!projectId) return res.status(400).json({ error: "Missing project" });
1838
2476
  if (!getRepo(projectId)) return res.status(400).json({ error: "No repo configured for project" });
1839
- const queuePath = path.join(CONFIG_DIR, projectId, "OVERNIGHT-QUEUE.md");
1840
- let active = false;
1841
- try {
1842
- const text = fs.readFileSync(queuePath, "utf-8");
1843
- const { issueNumbers } = parseActiveBatch(text);
1844
- active = issueNumbers.length > 0;
1845
- } catch {}
1846
- 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 });
1847
2489
  });
1848
2490
 
1849
- 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) => {
1850
2496
  const projectId = req.query.project;
1851
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
+ });
1852
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) {
1853
2530
  const cached = _batchProgressCache.get(projectId);
1854
2531
  // #812: parked (idle) project — never run batch-progress gh/GraphQL calls,
1855
2532
  // and ALWAYS flag the payload _idle (even on a fresh cache hit), so the
1856
2533
  // endpoint contract is consistent regardless of cache freshness. This must
1857
2534
  // precede the fresh-cache and rate-limit returns below.
1858
2535
  if (isProjectIdle(projectId)) {
1859
- if (cached) return res.json({ ...cached.data, _idle: true });
1860
- return res.json({ batch_number: null, items: [], summary: "", complete: false, _idle: true });
2536
+ if (cached) return { ...cached.data, _idle: true };
2537
+ return { batch_number: null, items: [], summary: "", complete: false, completeConfirmed: false, _idle: true };
1861
2538
  }
1862
2539
  const batchTTL = adaptiveTTL(BATCH_PROGRESS_TTL_MS);
1863
2540
  if (cached && Date.now() - cached.ts < batchTTL) {
1864
- return res.json(cached.data);
2541
+ return cached.data;
1865
2542
  }
1866
2543
  // #554: if critically rate-limited, serve stale cache instead of
1867
2544
  // firing N gh calls per batch item.
1868
2545
  if (isRateLimited() && cached) {
1869
- 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 };
1870
2553
  }
1871
2554
 
1872
2555
  const repo = getRepo(projectId);
1873
- if (!repo) return res.status(400).json({ error: "No repo configured for project" });
2556
+ if (!repo) return null;
1874
2557
 
1875
2558
  const queuePath = path.join(CONFIG_DIR, projectId, "OVERNIGHT-QUEUE.md");
1876
2559
  let queueText = "";
@@ -1909,55 +2592,56 @@ router.get("/api/batch-progress", async (req, res) => {
1909
2592
  // snapshot-aware helper so merged items stay visible after Head
1910
2593
  // moves them from Active Batch to Done, until a new batch starts.
1911
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(",")}`;
1912
2599
  if (issueNumbers.length === 0) {
1913
- 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 };
1914
2602
  _batchProgressCache.set(projectId, { ts: Date.now(), data });
1915
- return res.json(data);
1916
- }
1917
-
1918
- // #703: Try batched GraphQL first — one query for all batch items.
1919
- // Falls back to individual gh CLI calls (the #416 parallel approach)
1920
- // if GraphQL fails.
1921
- let items;
1922
- const graphqlData = await fetchBatchProgressGraphQL(repo, issueNumbers);
1923
- if (graphqlData) {
1924
- items = issueNumbers.map((n) => {
1925
- const issueNode = graphqlData[`issue${n}`];
1926
- const row = issueNode ? graphqlIssueToProgressRow(issueNode) : null;
1927
- return row || {
1928
- issue_number: n,
1929
- title: `#${n} (fetch failed)`,
1930
- url: null,
1931
- status: "unknown",
1932
- progress: 0,
1933
- label: "fetch failed",
1934
- };
1935
- });
1936
- } else {
1937
- // Fallback: #416 parallel individual gh CLI calls.
1938
- const settled = await Promise.allSettled(
1939
- issueNumbers.map((n) => progressForItemAsync(repo, n)),
1940
- );
1941
- items = settled.map((r, i) => {
1942
- if (r.status === "fulfilled") return r.value;
1943
- return {
1944
- issue_number: issueNumbers[i],
1945
- title: `#${issueNumbers[i]} (fetch failed)`,
1946
- url: null,
1947
- status: "unknown",
1948
- progress: 0,
1949
- label: "fetch failed",
1950
- };
1951
- });
2603
+ return data;
1952
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
+ });
1953
2625
  const summary = summarizeItems(items);
1954
2626
  // #350: treat CLOSED-without-PR items as complete alongside merged
1955
2627
  // so batches that mix runbook/superseded closes with real PRs
1956
2628
  // still flip to the COMPLETE state once everything is done.
1957
2629
  const complete = items.length > 0 && items.every((it) => it.status === "merged" || it.status === "closed");
1958
- 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 };
1959
2635
  _batchProgressCache.set(projectId, { ts: Date.now(), data });
1960
- 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);
1961
2645
  });
1962
2646
 
1963
2647
  // #445: Memory section (agent-memory butler integration) removed.
@@ -2227,6 +2911,7 @@ router.post("/api/setup", (req, res) => {
2227
2911
  // below no-ops when the file is already present, so this
2228
2912
  // can't clobber Head/user edits.
2229
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 || "");
2230
2915
  return res.json({ ok: true, message: "Project already in config" });
2231
2916
  }
2232
2917
  // Match CLI wizard agent structure: { cwd, command, auto_approve, mcp_inject }
@@ -2266,6 +2951,8 @@ router.post("/api/setup", (req, res) => {
2266
2951
  // Batch 25 / #204: seed the per-project OVERNIGHT-QUEUE.md at
2267
2952
  // ~/.quadwork/{id}/OVERNIGHT-QUEUE.md.
2268
2953
  writeOvernightQueueFileSafe(id, name || id, repo);
2954
+ // #807: seed the per-project GITHUB.md alongside it.
2955
+ writeGithubFileSafe(id, name || id, repo);
2269
2956
 
2270
2957
  return res.json({ ok: true });
2271
2958
  }
@@ -2274,6 +2961,399 @@ router.post("/api/setup", (req, res) => {
2274
2961
  }
2275
2962
  });
2276
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
+
2277
3357
  // ─── Rename ────────────────────────────────────────────────────────────────
2278
3358
 
2279
3359
  function replaceInFile(filePath, oldStr, newStr) {
@@ -2805,9 +3885,91 @@ module.exports.parseActiveBatch = parseActiveBatch;
2805
3885
  // summarizeItems for the batch-progress fixture test.
2806
3886
  module.exports.buildNoPrRow = buildNoPrRow;
2807
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;
2808
3892
  // #693: expose normalizeMentions for unit tests
2809
3893
  module.exports.normalizeMentions = normalizeMentions;
2810
3894
  // #714: expose for file-chat integration
2811
3895
  module.exports.getProjectChatMode = getProjectChatMode;
2812
3896
  // #730: PTY dispatch callback setter
2813
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;