forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5

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 (196) hide show
  1. package/AGENTS.md +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +21 -1
  5. package/bin/forge.js +16 -369
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +9 -4
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -7,8 +7,8 @@
7
7
  * reversible side-effects used by the PR shepherd:
8
8
  * - `readState` → `gh pr view --json ...`
9
9
  * - `readRequiredChecks` → `gh api repos/{o}/{r}/branches/{base}/protection/required_status_checks`
10
- * - `readDivergence` → `git rev-list --left-right --count {baseRef}...HEAD`
11
- * - `detectConflicts` → `git merge-tree --write-tree {baseRef} HEAD` (predict-only)
10
+ * - `readDivergence` → `git rev-list --left-right --count {baseRef}...{headRef}`
11
+ * - `detectConflicts` → `git merge-tree --write-tree {baseRef} {headRef}` (predict-only)
12
12
  * - `rerunFailedChecks` → `gh run rerun <id> --failed`
13
13
  * - `replyToThread` → shell-out to `.claude/scripts/review-resolve.sh reply` (reply ONLY,
14
14
  * never resolve — resolution stays with the semantic `/review` agent)
@@ -25,6 +25,49 @@
25
25
 
26
26
  const { execFileSync } = require('node:child_process');
27
27
 
28
+ const GITHUB_ACTOR_TYPENAMES = new Set([
29
+ 'Bot', 'EnterpriseUserAccount', 'Mannequin', 'Organization', 'User',
30
+ ]);
31
+ const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
32
+ const POSITIVE_PR_NUMBER = /^[1-9][0-9]*$/;
33
+ const CHECK_RUN_STATUSES = new Set(['QUEUED', 'IN_PROGRESS', 'COMPLETED', 'WAITING', 'PENDING', 'REQUESTED']);
34
+ const CHECK_RUN_CONCLUSIONS = new Set([
35
+ '', 'SUCCESS', 'FAILURE', 'NEUTRAL', 'CANCELLED', 'SKIPPED', 'TIMED_OUT',
36
+ 'ACTION_REQUIRED', 'STALE', 'STARTUP_FAILURE',
37
+ ]);
38
+ const STATUS_CONTEXT_STATES = new Set(['ERROR', 'EXPECTED', 'FAILURE', 'PENDING', 'SUCCESS']);
39
+ const MERGE_STATE_STATUSES = new Set([
40
+ 'BEHIND', 'BLOCKED', 'CLEAN', 'DIRTY', 'DRAFT', 'HAS_HOOKS', 'UNKNOWN', 'UNSTABLE',
41
+ ]);
42
+ const PULL_REQUEST_REVIEW_STATES = new Set([
43
+ 'APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING',
44
+ ]);
45
+
46
+ function canonicalPrNumber(value) {
47
+ const raw = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value;
48
+ return typeof raw === 'string' && POSITIVE_PR_NUMBER.test(raw) ? Number(raw) : null;
49
+ }
50
+
51
+ function validRollupNode(check) {
52
+ if (!check || typeof check !== 'object' || Array.isArray(check)) return false;
53
+ if (check.__typename === 'CheckRun') {
54
+ const status = String(check.status || '').toUpperCase();
55
+ const conclusion = String(check.conclusion || '').toUpperCase();
56
+ return typeof check.name === 'string' && Boolean(check.name.trim())
57
+ && CHECK_RUN_STATUSES.has(status)
58
+ && Object.prototype.hasOwnProperty.call(check, 'conclusion')
59
+ && (check.conclusion === null || typeof check.conclusion === 'string')
60
+ && CHECK_RUN_CONCLUSIONS.has(conclusion)
61
+ && (status === 'COMPLETED' ? Boolean(conclusion) : !conclusion);
62
+ }
63
+ if (check.__typename === 'StatusContext') {
64
+ return typeof check.context === 'string' && Boolean(check.context.trim())
65
+ && typeof check.state === 'string'
66
+ && STATUS_CONTEXT_STATES.has(check.state.toUpperCase());
67
+ }
68
+ return false;
69
+ }
70
+
28
71
  // NOTE: `reviewThreads` is NOT a valid `gh pr view --json` field — requesting it
29
72
  // makes `gh` exit non-zero ("Unknown JSON field"), which crashed readState on every
30
73
  // real PR. Review threads are read separately via GraphQL in readComments().
@@ -78,13 +121,17 @@ class PrStateAdapter {
78
121
  this.id = options.id || 'pr-state-adapter';
79
122
  this.kind = 'pr-state';
80
123
  this.name = options.name || this.id;
124
+ // windowsHide keeps the shepherd's per-poll gh/git calls from flashing a
125
+ // console window on Windows when the watcher runs detached (issue 931e7924).
81
126
  const defaultRunner = (cmd, args, opts = {}) => execFileSync(cmd, args, {
82
127
  encoding: 'utf8',
83
128
  timeout: options.timeout || 30000,
129
+ windowsHide: true,
84
130
  ...(opts.cwd ? { cwd: opts.cwd } : {}),
85
131
  });
86
132
  this._gh = options.gh || defaultRunner;
87
133
  this._git = options.git || defaultRunner;
134
+ this.lastProtectionStatus = null;
88
135
  }
89
136
 
90
137
  /**
@@ -96,80 +143,220 @@ class PrStateAdapter {
96
143
  async readState(pr) {
97
144
  const raw = this._gh('gh', ['pr', 'view', String(pr), '--json', PR_VIEW_FIELDS]);
98
145
  const data = JSON.parse(raw || '{}');
99
- const rollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [];
146
+ const lifecycleReadable = typeof data.state === 'string'
147
+ && ['OPEN', 'MERGED', 'CLOSED'].includes(data.state.toUpperCase())
148
+ && typeof data.isDraft === 'boolean';
149
+ const mergeStateStatus = typeof data.mergeStateStatus === 'string'
150
+ ? data.mergeStateStatus.toUpperCase()
151
+ : '';
152
+ const mergeStateReadable = MERGE_STATE_STATUSES.has(mergeStateStatus)
153
+ && mergeStateStatus !== 'UNKNOWN';
154
+ const rollupReadable = Array.isArray(data.statusCheckRollup)
155
+ && data.statusCheckRollup.every(validRollupNode);
156
+ const providerEvidenceReadable = lifecycleReadable && mergeStateReadable && rollupReadable
157
+ && typeof data.headRefOid === 'string' && FULL_HEAD_SHA.test(data.headRefOid);
158
+ const rollup = rollupReadable ? data.statusCheckRollup : [];
100
159
  return {
101
- headSha: data.headRefOid || '',
102
- state: String(data.state || 'OPEN').toUpperCase(),
160
+ headSha: typeof data.headRefOid === 'string' ? data.headRefOid : '',
161
+ state: lifecycleReadable ? data.state.toUpperCase() : 'UNKNOWN',
103
162
  mergeable: data.mergeable || 'UNKNOWN',
104
- mergeStateStatus: data.mergeStateStatus || 'UNKNOWN',
105
- // PR-level merge blockers (surfaced by the pull signal). `reviewDecision` is
106
- // '' when no review is required; normalized to null so consumers can treat
107
- // "not required" and "unknown" uniformly. `isDraft` blocks merge outright.
163
+ mergeStateStatus: mergeStateReadable ? mergeStateStatus : 'UNKNOWN',
108
164
  reviewDecision: data.reviewDecision || null,
109
- isDraft: Boolean(data.isDraft),
110
- // The rollup mixes two GraphQL types: a CheckRun (name/status/conclusion/
111
- // detailsUrl) and a legacy commit StatusContext (context/state/targetUrl —
112
- // Vercel/Netlify/other deploy+quality bots). Both are normalized into ONE
113
- // shape here so the failing/pending classification treats them identically;
114
- // `state` fills the conclusion slot for a StatusContext (SUCCESS/FAILURE/
115
- // ERROR/PENDING) and `targetUrl` fills the details link.
116
- checks: rollup.map((check) => ({
117
- name: check.name || check.context || '',
118
- status: check.status || check.state || '',
119
- conclusion: check.conclusion || check.state || '',
120
- databaseId: check.databaseId,
121
- detailsUrl: check.detailsUrl || check.targetUrl,
122
- })),
123
- // gh pr view cannot return review threads; the shepherd reads them via
124
- // readComments() (GraphQL). Kept for return-shape stability, always empty here.
165
+ isDraft: typeof data.isDraft === 'boolean' ? data.isDraft : null,
166
+ providerEvidenceReadable,
167
+ checks: rollup.map((check) => {
168
+ const statusContext = check.__typename === 'StatusContext';
169
+ const state = String(check.state || '').toUpperCase();
170
+ return {
171
+ name: check.name || check.context || '',
172
+ status: statusContext
173
+ ? (['SUCCESS', 'FAILURE', 'ERROR'].includes(state) ? 'COMPLETED' : 'IN_PROGRESS')
174
+ : String(check.status).toUpperCase(),
175
+ conclusion: statusContext ? state : String(check.conclusion || '').toUpperCase(),
176
+ databaseId: check.databaseId,
177
+ detailsUrl: check.detailsUrl || check.targetUrl,
178
+ };
179
+ }),
125
180
  threads: [],
126
181
  };
127
182
  }
128
183
 
129
184
  /**
130
- * Read the branch-protection required-checks set.
185
+ * Read the required-checks set for a PR, with a two-source strategy so the
186
+ * verdict is not permanently UNKNOWN in CI.
131
187
  *
132
- * Returns `null` when the protection endpoint is unreadable (e.g. 403
133
- * insufficient scope, the branch is not protected, or the payload shape is
134
- * unexpected) so the caller can escalate rather than guess. Re-throws non-auth
135
- * errors.
188
+ * 1. **Branch protection** (`.../protection/required_status_checks`) the
189
+ * authoritative set, but this REST endpoint needs repo `Administration:read`,
190
+ * which GitHub Actions' `GITHUB_TOKEN` can NEVER hold (administration is not a
191
+ * grantable `permissions:` scope). So in CI this ALWAYS 403/404s and the set
192
+ * was permanently null → verdict UNKNOWN on every PR.
193
+ * 2. **statusCheckRollup `isRequired`** (GraphQL, on the PR head commit) — the
194
+ * fallback. It is readable with the plain PR-read scope the Actions token DOES
195
+ * hold (it is what `gh pr checks --required` uses) and it covers BOTH classic
196
+ * branch protection AND repository rulesets.
136
197
  *
137
- * @param {{ owner: string, repo: string, base: string }} ctx
198
+ * Rollup `isRequired` data cannot prove the complete protected set because a
199
+ * required context that never ran is absent. `lastRequiredSource` is therefore
200
+ * `'protection'` only when an authoritative set is returned, and `null` when
201
+ * protection is unreadable.
202
+ * Re-throws non-auth protection errors (unchanged).
203
+ *
204
+ * @param {{ owner: string, repo: string, base: string, pr?: string|number }} ctx
138
205
  * @returns {Promise<string[] | null>}
139
206
  */
140
- async readRequiredChecks({ owner, repo, base }) {
207
+ async readRequiredCheckPolicy({ owner, repo, base }) {
208
+ this.lastRequiredSource = null;
209
+ const fromProtection = this._readProtectionRequired({ owner, repo, base });
210
+ if (Array.isArray(fromProtection)) {
211
+ this.lastRequiredSource = 'protection';
212
+ return fromProtection;
213
+ }
214
+ return null;
215
+ }
216
+
217
+ async readRequiredChecks({ owner, repo, base, pr }) {
218
+ const fromProtection = await this.readRequiredCheckPolicy({ owner, repo, base });
219
+ if (Array.isArray(fromProtection)) return [...new Set(fromProtection.map((entry) => entry.context))];
220
+ if (this.lastProtectionStatus !== 'unavailable') return null;
221
+ // Rollup `isRequired` is retained only as diagnostic evidence. It is not the
222
+ // authoritative policy and therefore cannot authorize merge readiness.
223
+ this._readRollupRequired({ owner, repo, pr });
224
+ return null;
225
+ }
226
+
227
+ /**
228
+ * Branch-protection required set, or `null` when unreadable (auth/scope/
229
+ * not-protected/unexpected shape). Re-throws non-auth errors so a genuine
230
+ * outage is not silently masked. Split out so `readRequiredChecks` can fall
231
+ * back cleanly.
232
+ *
233
+ * @param {{ owner: string, repo: string, base: string }} ctx
234
+ * @returns {string[] | null}
235
+ */
236
+ _readProtectionRequired({ owner, repo, base }) {
237
+ this.lastProtectionStatus = 'malformed';
141
238
  const apiPath = `repos/${owner}/${repo}/branches/${encodeURIComponent(base)}/protection/required_status_checks`;
142
239
  try {
143
240
  const raw = this._gh('gh', ['api', apiPath]);
144
241
  const data = JSON.parse(raw || '{}');
145
- if (Array.isArray(data.contexts)) return data.contexts;
146
- if (Array.isArray(data.checks)) return data.checks.map((c) => c.context).filter(Boolean);
242
+ const hasContexts = Object.prototype.hasOwnProperty.call(data, 'contexts');
243
+ const hasChecks = Object.prototype.hasOwnProperty.call(data, 'checks');
244
+ if ((hasContexts && !Array.isArray(data.contexts))
245
+ || (hasChecks && !Array.isArray(data.checks))) return null;
246
+ const normalizeContexts = (items) => {
247
+ if (!Array.isArray(items)) return null;
248
+ if (items.some((item) => typeof item !== 'string' || item.trim().length === 0)) return null;
249
+ return [...new Set(items)];
250
+ };
251
+ const contexts = normalizeContexts(data.contexts);
252
+ let checks = null;
253
+ if (Array.isArray(data.checks)) {
254
+ if (data.checks.some((item) => !item || typeof item.context !== 'string'
255
+ || item.context.length === 0
256
+ || !Object.prototype.hasOwnProperty.call(item, 'app_id')
257
+ || (item.app_id !== null
258
+ && (!Number.isInteger(item.app_id) || item.app_id <= 0)))) return null;
259
+ const deduped = new Map();
260
+ const identityByContext = new Map();
261
+ for (const item of data.checks) {
262
+ const entry = { context: item.context, appId: item.app_id };
263
+ const identity = entry.appId === null ? '*' : String(entry.appId);
264
+ if (identityByContext.has(entry.context)
265
+ && identityByContext.get(entry.context) !== identity) return null;
266
+ identityByContext.set(entry.context, identity);
267
+ deduped.set(`${entry.context}\u0000${identity}`, entry);
268
+ }
269
+ checks = [...deduped.values()];
270
+ }
271
+ if (contexts && checks) {
272
+ const contextNames = [...contexts].sort((a, b) => a.localeCompare(b));
273
+ const checkNames = [...new Set(checks.map((entry) => entry.context))].sort((a, b) => a.localeCompare(b));
274
+ if (contextNames.length !== checkNames.length
275
+ || contextNames.some((value, index) => value !== checkNames[index])) return null;
276
+ this.lastProtectionStatus = 'authoritative';
277
+ return checks;
278
+ }
279
+ if (contexts) {
280
+ this.lastProtectionStatus = 'authoritative';
281
+ return contexts.map((context) => ({ context, appId: null }));
282
+ }
283
+ if (checks) {
284
+ this.lastProtectionStatus = 'authoritative';
285
+ return checks;
286
+ }
147
287
  // Unexpected/changed payload shape — treat as unreadable, not "no required
148
288
  // checks", so merge readiness is never computed from bad data.
149
289
  return null;
150
290
  } catch (error) {
151
291
  const auth = classifyAuthError(error);
152
292
  if (auth) {
153
- // Unreadable protection (auth/scope/not-protected) — caller escalates.
293
+ this.lastProtectionStatus = 'unavailable';
154
294
  return null;
155
295
  }
156
296
  throw error;
157
297
  }
158
298
  }
159
299
 
300
+ /**
301
+ * Fallback required set from the PR head commit's `statusCheckRollup`, reading
302
+ * per-context `isRequired(pullRequestNumber:)` via GraphQL. Readable with plain
303
+ * PR-read scope (unlike branch protection). Returns the deduped names of every
304
+ * required CheckRun/StatusContext, `[]` when the rollup is readable but nothing
305
+ * is required, or `null` when the rollup itself is unreadable (fail-closed).
306
+ *
307
+ * @param {{ owner: string, repo: string, pr?: string|number }} ctx
308
+ * @returns {string[] | null}
309
+ */
310
+ _readRollupRequired({ owner, repo, pr }) {
311
+ const prNum = canonicalPrNumber(pr);
312
+ if (prNum === null) return null;
313
+ const query = [
314
+ 'query($after:String){repository(owner:"', owner, '",name:"', repo, '"){',
315
+ 'pullRequest(number:', prNum, '){headRef{target{... on Commit{statusCheckRollup{',
316
+ 'contexts(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{__typename ',
317
+ '... on CheckRun{name isRequired(pullRequestNumber:', prNum, ')} ',
318
+ '... on StatusContext{context isRequired(pullRequestNumber:', prNum, ')}}}}}}}}}}',
319
+ ].join('');
320
+ try {
321
+ const projected = this._paginateConnection(
322
+ (after) => {
323
+ const args = ['api', 'graphql', '-f', `query=${query}`];
324
+ if (after) args.push('-f', `after=${after}`);
325
+ const data = this._parseGraphqlResponse(this._gh('gh', args));
326
+ return data?.data?.repository?.pullRequest?.headRef?.target
327
+ ?.statusCheckRollup?.contexts;
328
+ },
329
+ (node) => {
330
+ if (!['CheckRun', 'StatusContext'].includes(node.__typename)
331
+ || typeof node.isRequired !== 'boolean') {
332
+ throw new Error('Rollup required-check node is malformed');
333
+ }
334
+ const name = node.__typename === 'CheckRun' ? node.name : node.context;
335
+ if (typeof name !== 'string' || !name.trim()) {
336
+ throw new Error('Rollup required-check node has no context identity');
337
+ }
338
+ return node.isRequired ? name : null;
339
+ },
340
+ );
341
+ return [...new Set(projected.filter(Boolean))];
342
+ } catch {
343
+ return null;
344
+ }
345
+ }
346
+
160
347
  /**
161
348
  * Read ahead/behind divergence against the base ref.
162
349
  *
163
350
  * `cwd` is threaded through to the git runner so divergence is computed
164
351
  * against the target worktree/checkout, not the process directory.
165
352
  *
166
- * @param {{ baseRef: string, cwd?: string }} ctx
353
+ * @param {{ baseRef: string, cwd?: string, headRef?: string }} ctx
167
354
  * @returns {Promise<{ behind: number, ahead: number }>}
168
355
  */
169
- async readDivergence({ baseRef, cwd }) {
356
+ async readDivergence({ baseRef, cwd, headRef = 'HEAD' }) {
170
357
  const out = this._git(
171
358
  'git',
172
- ['rev-list', '--left-right', '--count', `${baseRef}...HEAD`],
359
+ ['rev-list', '--left-right', '--count', `${baseRef}...${headRef}`],
173
360
  cwd ? { cwd } : undefined,
174
361
  );
175
362
  const [behindRaw = '0', aheadRaw = '0'] = String(out).trim().split(/\s+/);
@@ -191,7 +378,8 @@ class PrStateAdapter {
191
378
 
192
379
  /**
193
380
  * Fetch the base ref from its remote so a subsequent `readDivergence`/
194
- * `detectConflicts` compares HEAD against the CURRENT `origin/<base>`, not a
381
+ * `detectConflicts` compares the selected PR head (or legacy `HEAD` default)
382
+ * against the CURRENT `origin/<base>`, not a
195
383
  * stale local remote-tracking ref (audit A6: a stale ref reports a false
196
384
  * `behind=0` / false "no conflict"). `baseRef` is `<remote>/<branch>` (e.g.
197
385
  * `origin/master`); the remote and branch are split back out for `git fetch`.
@@ -231,6 +419,8 @@ class PrStateAdapter {
231
419
  * @returns {Promise<object[]>}
232
420
  */
233
421
  async readComments({ owner, repo, pr }) {
422
+ const number = canonicalPrNumber(pr);
423
+ if (!number) throw new Error('PR selector must be one canonical positive decimal number');
234
424
  // `id`/`path`/`line` are surfaced so a consumer (e.g. the monitor bundle in
235
425
  // lib/pr-bundle.js) can hand an agent the thread id to resolve and the
236
426
  // file/line to act on — not just the body. Added fields are backward
@@ -239,22 +429,26 @@ class PrStateAdapter {
239
429
  // Both connections are FULLY paginated (cursors, not a first:100 cap): a
240
430
  // large PR must never silently drop a thread or a later reply, or the bundle
241
431
  // would declare "complete" on partial data and the monitor would skip work.
242
- const threads = this._fetchAllReviewThreads({ owner, repo, pr });
432
+ const threads = this._fetchAllReviewThreads({ owner, repo, pr: number });
243
433
  return threads.map((t) => {
244
- const allComments = (t.comments && t.comments.nodes) || [];
434
+ const allComments = t.comments.nodes;
245
435
  return {
246
- threadId: t.id || '',
436
+ threadId: t.id,
247
437
  path: t.path || null,
248
438
  line: typeof t.line === 'number' ? t.line : null,
249
- isResolved: !!t.isResolved,
250
- isOutdated: !!t.isOutdated,
251
- comments: allComments.map(c => ({
252
- author: (c.author && c.author.login) || '',
253
- body: c.body || '',
254
- // REST comment id (needed to REPLY to the thread). `fullDatabaseId` is
255
- // the string-encoded integer id; null when GitHub omits it.
256
- commentId: c.fullDatabaseId ? String(c.fullDatabaseId) : null,
257
- })),
439
+ isResolved: t.isResolved,
440
+ isOutdated: t.isOutdated,
441
+ comments: allComments.map((c) => {
442
+ const commentId = c.fullDatabaseId ? String(c.fullDatabaseId) : '';
443
+ const author = String(c.author?.login || '');
444
+ const authorType = String(c.author?.__typename || '');
445
+ if (!commentId) throw new Error(`Review thread ${t.id} comment is missing stable database id`);
446
+ if (!author || !GITHUB_ACTOR_TYPENAMES.has(authorType)) {
447
+ throw new Error(`Review thread ${t.id} comment ${commentId} is missing valid author identity`);
448
+ }
449
+ if (typeof c.body !== 'string') throw new Error(`Review thread ${t.id} comment ${commentId} has malformed body`);
450
+ return { author, authorType, body: c.body, commentId };
451
+ }),
258
452
  };
259
453
  });
260
454
  }
@@ -269,16 +463,20 @@ class PrStateAdapter {
269
463
  * @returns {object[]} raw thread nodes (comments.nodes carries the full chain)
270
464
  */
271
465
  _fetchAllReviewThreads({ owner, repo, pr }) {
272
- const query = 'query($o:String!,$n:String!,$pr:Int!,$after:String){repository(owner:$o,name:$n){pullRequest(number:$pr){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{id isResolved isOutdated path line comments(first:100){pageInfo{hasNextPage endCursor} nodes{fullDatabaseId author{login} body}}}}}}}';
466
+ const query = 'query($o:String!,$n:String!,$pr:Int!,$after:String){repository(owner:$o,name:$n){pullRequest(number:$pr){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{id isResolved isOutdated path line comments(first:100){pageInfo{hasNextPage endCursor} nodes{fullDatabaseId author{__typename login} body}}}}}}}';
273
467
  return this._paginateConnection(
274
468
  (after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
275
469
  ?.data?.repository?.pullRequest?.reviewThreads,
276
470
  (t) => {
277
- // A thread whose first comment page is truncated → fetch the rest.
278
- if (t.comments?.pageInfo?.hasNextPage) {
279
- t.comments = { nodes: this._fetchAllThreadComments(t.id, t.comments) };
471
+ if (typeof t.id !== 'string' || !t.id
472
+ || typeof t.isResolved !== 'boolean' || typeof t.isOutdated !== 'boolean') {
473
+ throw new Error('GraphQL review thread node is malformed');
280
474
  }
281
- return t;
475
+ const comments = this._validateGraphqlConnection(t.comments, `Review thread ${t.id} comments`);
476
+ const nodes = comments.pageInfo.hasNextPage
477
+ ? this._fetchAllThreadComments(t.id, comments)
478
+ : comments.nodes;
479
+ return { ...t, comments: { nodes } };
282
480
  },
283
481
  );
284
482
  }
@@ -300,7 +498,31 @@ class PrStateAdapter {
300
498
  '-F', `o=${owner}`, '-F', `n=${repo}`, '-F', `pr=${pr}`,
301
499
  ];
302
500
  if (after) args.push('-f', `after=${after}`);
303
- return JSON.parse(this._gh('gh', args) || '{}');
501
+ return this._parseGraphqlResponse(this._gh('gh', args));
502
+ }
503
+
504
+ _parseGraphqlResponse(raw) {
505
+ const parsed = JSON.parse(raw || '{}');
506
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
507
+ throw new Error('GraphQL response envelope is malformed');
508
+ }
509
+ if (Object.prototype.hasOwnProperty.call(parsed, 'errors')
510
+ && (!Array.isArray(parsed.errors) || parsed.errors.length > 0)) {
511
+ throw new Error('GraphQL response contains errors');
512
+ }
513
+ return parsed;
514
+ }
515
+
516
+ _validateGraphqlConnection(conn, label = 'GraphQL connection') {
517
+ if (!conn || typeof conn !== 'object' || Array.isArray(conn)
518
+ || !Array.isArray(conn.nodes) || conn.nodes.some((node) => !node || typeof node !== 'object' || Array.isArray(node))
519
+ || !conn.pageInfo || typeof conn.pageInfo !== 'object' || Array.isArray(conn.pageInfo)
520
+ || typeof conn.pageInfo.hasNextPage !== 'boolean'
521
+ || (conn.pageInfo.endCursor !== null && typeof conn.pageInfo.endCursor !== 'string')
522
+ || (conn.pageInfo.hasNextPage && !conn.pageInfo.endCursor)) {
523
+ throw new Error(`${label} is missing valid nodes/pageInfo`);
524
+ }
525
+ return conn;
304
526
  }
305
527
 
306
528
  /**
@@ -321,11 +543,18 @@ class PrStateAdapter {
321
543
  let guard = 0;
322
544
  const MAX_PAGES = 1000;
323
545
  do {
324
- const conn = runPage(after);
325
- for (const node of (conn?.nodes || [])) all.push(mapNode(node));
326
- after = conn?.pageInfo?.hasNextPage ? conn.pageInfo.endCursor : null;
546
+ const previous = after;
547
+ const conn = this._validateGraphqlConnection(runPage(after));
548
+ for (const node of conn.nodes) all.push(mapNode(node));
549
+ after = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
550
+ if (after !== null && after === previous) {
551
+ throw new Error('GraphQL pagination cursor did not advance');
552
+ }
327
553
  guard += 1;
328
- } while (after && guard < MAX_PAGES);
554
+ if (guard >= MAX_PAGES && after !== null) {
555
+ throw new Error('GraphQL pagination exceeded the page limit');
556
+ }
557
+ } while (after !== null);
329
558
  return all;
330
559
  }
331
560
 
@@ -338,21 +567,33 @@ class PrStateAdapter {
338
567
  * @returns {object[]} the full comment-node list (first page + all later pages)
339
568
  */
340
569
  _fetchAllThreadComments(threadId, initial) {
341
- const query = 'query($id:ID!,$after:String){node(id:$id){... on PullRequestReviewThread{comments(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{fullDatabaseId author{login} body}}}}}';
342
- const acc = [...((initial && initial.nodes) || [])];
343
- let after = initial && initial.pageInfo && initial.pageInfo.hasNextPage ? initial.pageInfo.endCursor : null;
570
+ const query = 'query($id:ID!,$after:String){node(id:$id){... on PullRequestReviewThread{comments(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{fullDatabaseId author{__typename login} body}}}}}';
571
+ if (typeof threadId !== 'string' || !threadId) throw new Error('Review thread id is malformed');
572
+ let conn = this._validateGraphqlConnection(initial, `Review thread ${threadId} comments`);
573
+ const acc = [...conn.nodes];
574
+ let after = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
344
575
  let guard = 0;
345
576
  const MAX_PAGES = 1000;
346
- while (after && guard < MAX_PAGES) {
577
+ while (after !== null) {
578
+ const previous = after;
347
579
  const raw = this._gh('gh', [
348
580
  'api', 'graphql', '-f', `query=${query}`,
349
581
  '-f', `id=${threadId}`, '-f', `after=${after}`,
350
582
  ]);
351
- const data = JSON.parse(raw || '{}');
352
- const conn = data.data && data.data.node && data.data.node.comments;
353
- acc.push(...((conn && conn.nodes) || []));
354
- after = conn && conn.pageInfo && conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
583
+ const data = this._parseGraphqlResponse(raw);
584
+ conn = this._validateGraphqlConnection(
585
+ data?.data?.node?.comments,
586
+ `Review thread ${threadId} comments`,
587
+ );
588
+ acc.push(...conn.nodes);
589
+ after = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
590
+ if (after !== null && after === previous) {
591
+ throw new Error('GraphQL pagination cursor did not advance');
592
+ }
355
593
  guard += 1;
594
+ if (guard >= MAX_PAGES && after !== null) {
595
+ throw new Error('GraphQL pagination exceeded the page limit');
596
+ }
356
597
  }
357
598
  return acc;
358
599
  }
@@ -372,56 +613,112 @@ class PrStateAdapter {
372
613
  // `author{__typename login}` surfaces the GraphQL actor TYPE ('Bot' vs 'User')
373
614
  // so a non-human direct comment can be detected GENERICALLY — by mechanism,
374
615
  // not by a hardcoded bot-name list — which fails closed for unknown bots.
375
- const query = 'query($o:String!,$n:String!,$pr:Int!,$after:String){repository(owner:$o,name:$n){pullRequest(number:$pr){comments(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{fullDatabaseId author{__typename login} body createdAt}}}}}';
616
+ const query = 'query($o:String!,$n:String!,$pr:Int!,$after:String){repository(owner:$o,name:$n){pullRequest(number:$pr){comments(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{fullDatabaseId author{__typename login} body createdAt updatedAt}}}}}';
376
617
  return this._paginateConnection(
377
618
  (after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
378
619
  ?.data?.repository?.pullRequest?.comments,
379
- (c) => ({
380
- // `id` is the stable REST comment id — the PR monitor keys `comment.posted`
381
- // on it so a re-read never re-emits an already-seen comment. Falls back to
382
- // author+createdAt when GitHub omits it.
383
- id: c.fullDatabaseId ? String(c.fullDatabaseId) : `${c.author?.login || ''}:${c.createdAt || ''}`,
384
- author: c.author?.login || '',
385
- authorTypename: c.author?.__typename || '',
386
- body: c.body || '',
387
- createdAt: c.createdAt || '',
388
- }),
620
+ (c) => {
621
+ const id = c.fullDatabaseId ? String(c.fullDatabaseId) : '';
622
+ if (!id) throw new Error('Issue comment is missing stable database id');
623
+ if (!c.author?.login) throw new Error(`Issue comment ${id} is missing author identity`);
624
+ if (!GITHUB_ACTOR_TYPENAMES.has(String(c.author?.__typename || ''))) {
625
+ throw new Error(`Issue comment ${id} has missing or malformed actor type`);
626
+ }
627
+ if (typeof c.body !== 'string' || typeof c.createdAt !== 'string' || !c.createdAt) {
628
+ throw new Error(`Issue comment ${id} has malformed body or timestamp`);
629
+ }
630
+ return {
631
+ id,
632
+ author: c.author.login,
633
+ authorTypename: c.author.__typename,
634
+ body: c.body,
635
+ createdAt: c.createdAt,
636
+ updatedAt: c.updatedAt || c.createdAt,
637
+ };
638
+ },
389
639
  );
390
640
  }
391
641
 
392
642
  /**
393
643
  * Read submitted PR REVIEWS (not inline threads) — the latest review per
394
644
  * author, each with the commit it was submitted against. This is the
395
- * review-at-head signal that catches the #365 race: a CodeRabbit review from
396
- * an earlier commit whose `commit.oid` no longer matches HEAD is STALE, so the
397
- * post-push re-review is still pending. Uses GraphQL because `gh pr view`
645
+ * review-at-head signal that catches the #365 race: any reviewer submission
646
+ * from an earlier commit whose `commit.oid` no longer matches HEAD is STALE,
647
+ * so the post-push re-review is still pending. Uses GraphQL because `gh pr view`
398
648
  * cannot return a review's target commit oid. Fully paginated by cursor.
399
649
  *
400
650
  * @param {{ owner: string, repo: string, pr: string }} ctx
401
- * @returns {Promise<Array<{ author: string, state: string, submittedAt: string|null, commitOid: string|null, body: string }>>}
651
+ * @returns {Promise<Array<{ author: string, state: string, createdAt: string, updatedAt: string, submittedAt: string, activityAt: string, commitOid: string, body: string }>>}
402
652
  */
403
653
  async readReviews({ owner, repo, pr }) {
404
- const query = 'query($o:String!,$n:String!,$pr:Int!,$after:String){repository(owner:$o,name:$n){pullRequest(number:$pr){reviews(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{author{__typename login} state submittedAt commit{oid} body}}}}}';
654
+ const query = 'query($o:String!,$n:String!,$pr:Int!,$after:String){repository(owner:$o,name:$n){pullRequest(number:$pr){reviews(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{id author{__typename login} state createdAt updatedAt submittedAt commit{oid} body}}}}}';
405
655
  const all = this._paginateConnection(
406
656
  (after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
407
657
  ?.data?.repository?.pullRequest?.reviews,
408
- (r) => ({
409
- author: String(r.author?.login || '').toLowerCase(),
410
- authorTypename: r.author?.__typename || '',
411
- state: String(r.state || '').toUpperCase(),
412
- submittedAt: r.submittedAt || null,
413
- commitOid: r.commit?.oid || null,
414
- body: r.body || '',
415
- }),
658
+ (r) => {
659
+ if (typeof r.id !== 'string' || !r.id) {
660
+ throw new Error('Review is missing stable GraphQL id');
661
+ }
662
+ const id = r.id;
663
+ if (typeof r.author?.login !== 'string' || !r.author.login) {
664
+ throw new Error(`Review ${id} is missing author identity`);
665
+ }
666
+ const author = r.author.login.toLowerCase().replace(/\[bot\]$/, '');
667
+ if (!author) throw new Error(`Review ${id} has empty normalized author identity`);
668
+ if (typeof r.author?.__typename !== 'string'
669
+ || !GITHUB_ACTOR_TYPENAMES.has(r.author.__typename)) {
670
+ throw new Error(`Review ${id} has missing or malformed actor type`);
671
+ }
672
+ const state = typeof r.state === 'string' ? r.state.toUpperCase() : '';
673
+ if (!PULL_REQUEST_REVIEW_STATES.has(state)
674
+ || typeof r.createdAt !== 'string' || !r.createdAt
675
+ || typeof r.updatedAt !== 'string' || !r.updatedAt
676
+ || typeof r.submittedAt !== 'string' || !r.submittedAt
677
+ || typeof r.commit?.oid !== 'string' || !FULL_HEAD_SHA.test(r.commit.oid)
678
+ || typeof r.body !== 'string') {
679
+ throw new Error(`Review ${id} has malformed state, timestamp, commit, or body`);
680
+ }
681
+ return {
682
+ id,
683
+ author,
684
+ authorTypename: r.author.__typename,
685
+ state,
686
+ createdAt: r.createdAt,
687
+ updatedAt: r.updatedAt,
688
+ submittedAt: r.submittedAt,
689
+ commitOid: r.commit.oid,
690
+ body: r.body,
691
+ };
692
+ },
416
693
  );
417
694
  // Keep only the LATEST review per author (first:100 yields oldest→newest, so
418
695
  // a later entry supersedes an earlier one from the same login).
419
696
  const latest = new Map();
697
+ const latestSubmission = new Map();
698
+ const latestActivity = new Map();
420
699
  for (const r of all) {
421
- if (!r.author) continue;
422
- latest.set(r.author, r);
700
+ const submitted = Date.parse(r.submittedAt);
701
+ if (!Number.isFinite(submitted)) {
702
+ throw new Error(`Review ${r.id} has malformed submission timestamp`);
703
+ }
704
+ if (!latestSubmission.has(r.author) || submitted >= latestSubmission.get(r.author)) {
705
+ latest.set(r.author, r);
706
+ latestSubmission.set(r.author, submitted);
707
+ }
708
+ const activity = Math.max(
709
+ Date.parse(r.createdAt),
710
+ Date.parse(r.updatedAt),
711
+ Date.parse(r.submittedAt),
712
+ );
713
+ if (!Number.isFinite(activity)) {
714
+ throw new Error(`Review ${r.id} has malformed activity timestamps`);
715
+ }
716
+ latestActivity.set(r.author, Math.max(latestActivity.get(r.author) || 0, activity));
423
717
  }
424
- return Array.from(latest.values());
718
+ return Array.from(latest.values()).map((review) => ({
719
+ ...review,
720
+ activityAt: new Date(latestActivity.get(review.author)).toISOString(),
721
+ }));
425
722
  }
426
723
 
427
724
  /**
@@ -441,23 +738,23 @@ class PrStateAdapter {
441
738
  }
442
739
 
443
740
  /**
444
- * Predict files that would conflict when merging `baseRef` into HEAD WITHOUT
741
+ * Predict files that would conflict when merging `baseRef` into `headRef` WITHOUT
445
742
  * touching the working tree, via `git merge-tree --write-tree`. Returns
446
743
  * `{ supported: false, reason }` when conflict prediction is unavailable (git
447
744
  * < 2.38, an unreadable ref, or a non-conflict error) so the bundle degrades
448
745
  * gracefully rather than failing the whole gather.
449
746
  *
450
- * @param {{ baseRef: string, cwd?: string }} ctx
747
+ * @param {{ baseRef: string, cwd?: string, headRef?: string }} ctx
451
748
  * @returns {Promise<{ supported: boolean, conflicted?: boolean, files?: string[], reason?: string }>}
452
749
  */
453
- async detectConflicts({ baseRef, cwd }) {
750
+ async detectConflicts({ baseRef, cwd, headRef = 'HEAD' }) {
454
751
  const opts = cwd ? { cwd } : undefined;
455
752
  try {
456
753
  // Exit 0 = clean merge. `--name-only` reduces the conflict report to bare
457
754
  // paths; `--no-messages` suppresses the human-readable conflict prose.
458
755
  this._git(
459
756
  'git',
460
- ['merge-tree', '--write-tree', '--name-only', '--no-messages', baseRef, 'HEAD'],
757
+ ['merge-tree', '--write-tree', '--name-only', '--no-messages', baseRef, headRef],
461
758
  opts,
462
759
  );
463
760
  return { supported: true, conflicted: false, files: [] };