forge-workflow 0.1.0-beta.4 → 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 (119) 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 +20 -0
  5. package/bin/forge.js +16 -374
  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 +8 -5
  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 +54 -25
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/pr-state-adapter.js +344 -142
  19. package/lib/audit-evidence.js +71 -110
  20. package/lib/capped-jsonl-log.js +236 -0
  21. package/lib/commands/_registry.js +2 -2
  22. package/lib/commands/clean.js +196 -32
  23. package/lib/commands/dev.js +4 -33
  24. package/lib/commands/hooks.js +223 -25
  25. package/lib/commands/insights.js +8 -3
  26. package/lib/commands/merge.js +600 -40
  27. package/lib/commands/pr.js +1 -1
  28. package/lib/commands/preflight.js +11 -2
  29. package/lib/commands/prime.js +21 -8
  30. package/lib/commands/push.js +41 -51
  31. package/lib/commands/recall.js +60 -16
  32. package/lib/commands/recap.js +6 -1
  33. package/lib/commands/release.js +17 -2
  34. package/lib/commands/setup.js +191 -94
  35. package/lib/commands/shepherd.js +13 -1
  36. package/lib/commands/ship.js +22 -23
  37. package/lib/commands/skill.js +119 -11
  38. package/lib/commands/status.js +17 -1
  39. package/lib/commands/test.js +24 -34
  40. package/lib/commands/worktree.js +220 -42
  41. package/lib/core/runtime-graph.js +1 -1
  42. package/lib/doc-assertions.js +297 -0
  43. package/lib/existing-tdd-gate.js +253 -0
  44. package/lib/forge-context.js +1 -4
  45. package/lib/forge-issues.js +56 -32
  46. package/lib/git-defaults.js +56 -0
  47. package/lib/harness-capability-matrix.js +3 -3
  48. package/lib/hook-renderer.js +93 -4
  49. package/lib/insights.js +96 -80
  50. package/lib/kernel/backing-issue.js +14 -2
  51. package/lib/kernel/broker.js +16 -0
  52. package/lib/kernel/cli-broker-factory.js +12 -1
  53. package/lib/kernel/close-on-merge.js +154 -0
  54. package/lib/kernel/fs-class.js +42 -25
  55. package/lib/kernel/sqlite-driver.js +153 -29
  56. package/lib/lefthook-wiring.js +21 -1
  57. package/lib/memory/router.js +16 -1
  58. package/lib/memory-digest.js +47 -15
  59. package/lib/memory-recall-events.js +145 -0
  60. package/lib/memory-recall.js +71 -10
  61. package/lib/merge-rules.js +8 -4
  62. package/lib/npm-publish-workflow.js +272 -0
  63. package/lib/orientation.js +68 -43
  64. package/lib/plugin-catalog.js +14 -4
  65. package/lib/pr-bundle.js +5 -6
  66. package/lib/pr-monitor/journal.js +18 -2
  67. package/lib/pr-monitor/reconcile-executor.js +224 -41
  68. package/lib/pr-monitor/render-summary.js +196 -0
  69. package/lib/pr-monitor/shepherd-lease.js +10 -1
  70. package/lib/pr-monitor/watch-lifecycle.js +13 -1
  71. package/lib/pr-pull.js +33 -14
  72. package/lib/pr-shepherd.js +34 -8
  73. package/lib/preflight/gates.js +65 -18
  74. package/lib/preflight/runner.js +5 -0
  75. package/lib/project-memory.js +33 -1
  76. package/lib/protected-state-authority.js +305 -0
  77. package/lib/protected-state-surfaces.js +64 -44
  78. package/lib/release-readiness.js +51 -4
  79. package/lib/shell-utils.js +1 -1
  80. package/lib/skills-sync.js +6 -3
  81. package/lib/smart-merge.js +28 -4
  82. package/lib/symlink-utils.js +74 -26
  83. package/lib/upgrade-safety.js +39 -0
  84. package/lib/using-forge.js +19 -6
  85. package/package.json +6 -7
  86. package/scripts/doc-asserting-tests.js +158 -0
  87. package/scripts/lib/behavioral-eval-runner.js +310 -0
  88. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  89. package/scripts/lib/eval-evidence.js +328 -0
  90. package/scripts/lib/eval-runner.js +81 -41
  91. package/scripts/lib/immutable-eval-corpus.js +309 -0
  92. package/scripts/lib/promotion-evidence-loader.js +94 -0
  93. package/scripts/lib/promotion-scorecard.js +314 -0
  94. package/scripts/npm-release-receipt.js +134 -0
  95. package/scripts/process-tree.js +761 -0
  96. package/scripts/protected-state-check.js +47 -22
  97. package/scripts/run-command-eval.js +29 -1
  98. package/scripts/sync-d20-audit.js +172 -0
  99. package/scripts/test-full-suite.js +249 -37
  100. package/scripts/test.js +176 -43
  101. package/skills/review/SKILL.md +4 -11
  102. package/skills/review/evals/scorecard.json +3 -3
  103. package/skills/rollback/SKILL.md +4 -11
  104. package/skills/rollback/evals/scorecard.json +3 -3
  105. package/skills/shepherd/SKILL.md +20 -14
  106. package/skills/shepherd/evals/scorecard.json +2 -2
  107. package/skills/ship/SKILL.md +4 -12
  108. package/skills/ship/evals/scorecard.json +3 -3
  109. package/skills/worktree/SKILL.md +6 -1
  110. package/skills/worktree/evals/scorecard.json +2 -2
  111. package/lib/beads-setup.js +0 -538
  112. package/lib/beads-sync-scaffold.js +0 -189
  113. package/lib/pat-setup.js +0 -207
  114. package/lib/pr-monitor/render-sticky.js +0 -206
  115. package/lib/pr-monitor/upsert-sticky.js +0 -169
  116. package/scripts/beads-context.sh +0 -577
  117. package/scripts/beads-migrate-to-dolt.sh +0 -7
  118. package/scripts/beads-upgrade-smoke.sh +0 -284
  119. 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().
@@ -88,6 +131,7 @@ class PrStateAdapter {
88
131
  });
89
132
  this._gh = options.gh || defaultRunner;
90
133
  this._git = options.git || defaultRunner;
134
+ this.lastProtectionStatus = null;
91
135
  }
92
136
 
93
137
  /**
@@ -99,32 +143,40 @@ class PrStateAdapter {
99
143
  async readState(pr) {
100
144
  const raw = this._gh('gh', ['pr', 'view', String(pr), '--json', PR_VIEW_FIELDS]);
101
145
  const data = JSON.parse(raw || '{}');
102
- 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 : [];
103
159
  return {
104
- headSha: data.headRefOid || '',
105
- state: String(data.state || 'OPEN').toUpperCase(),
160
+ headSha: typeof data.headRefOid === 'string' ? data.headRefOid : '',
161
+ state: lifecycleReadable ? data.state.toUpperCase() : 'UNKNOWN',
106
162
  mergeable: data.mergeable || 'UNKNOWN',
107
- mergeStateStatus: data.mergeStateStatus || 'UNKNOWN',
108
- // PR-level merge blockers (surfaced by the pull signal). `reviewDecision` is
109
- // '' when no review is required; normalized to null so consumers can treat
110
- // "not required" and "unknown" uniformly. `isDraft` blocks merge outright.
163
+ mergeStateStatus: mergeStateReadable ? mergeStateStatus : 'UNKNOWN',
111
164
  reviewDecision: data.reviewDecision || null,
112
- isDraft: Boolean(data.isDraft),
113
- // The rollup mixes two GraphQL types: a CheckRun (name/status/conclusion/
114
- // detailsUrl) and a legacy commit StatusContext (context/state/targetUrl —
115
- // Vercel/Netlify/other deploy+quality bots). Both are normalized into ONE
116
- // shape here so the failing/pending classification treats them identically;
117
- // `state` fills the conclusion slot for a StatusContext (SUCCESS/FAILURE/
118
- // ERROR/PENDING) and `targetUrl` fills the details link.
119
- checks: rollup.map((check) => ({
120
- name: check.name || check.context || '',
121
- status: check.status || check.state || '',
122
- conclusion: check.conclusion || check.state || '',
123
- databaseId: check.databaseId,
124
- detailsUrl: check.detailsUrl || check.targetUrl,
125
- })),
126
- // gh pr view cannot return review threads; the shepherd reads them via
127
- // 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
+ }),
128
180
  threads: [],
129
181
  };
130
182
  }
@@ -143,33 +195,32 @@ class PrStateAdapter {
143
195
  * hold (it is what `gh pr checks --required` uses) and it covers BOTH classic
144
196
  * branch protection AND repository rulesets.
145
197
  *
146
- * Known limitation of the fallback: the rollup only lists contexts that
147
- * PRODUCED a run, so a required context that never ran at all is invisible on
148
- * this path missing-required detection is best-effort when the source is the
149
- * rollup. That is strictly better than a permanent UNKNOWN.
150
- *
151
- * Returns `null` only when BOTH sources are unreadable (existing fail-closed
152
- * behaviour). `lastRequiredSource` records which source answered
153
- * (`'protection'` | `'rollup'` | `null`) so callers can surface it as evidence.
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.
154
202
  * Re-throws non-auth protection errors (unchanged).
155
203
  *
156
204
  * @param {{ owner: string, repo: string, base: string, pr?: string|number }} ctx
157
205
  * @returns {Promise<string[] | null>}
158
206
  */
159
- async readRequiredChecks({ owner, repo, base, pr }) {
207
+ async readRequiredCheckPolicy({ owner, repo, base }) {
160
208
  this.lastRequiredSource = null;
161
209
  const fromProtection = this._readProtectionRequired({ owner, repo, base });
162
210
  if (Array.isArray(fromProtection)) {
163
211
  this.lastRequiredSource = 'protection';
164
212
  return fromProtection;
165
213
  }
166
- // Protection unreadable (auth/scope/not-protected/unexpected shape) — fall back
167
- // to the rollup `isRequired` set the Actions token CAN read.
168
- const fromRollup = this._readRollupRequired({ owner, repo, pr });
169
- if (Array.isArray(fromRollup)) {
170
- this.lastRequiredSource = 'rollup';
171
- return fromRollup;
172
- }
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 });
173
224
  return null;
174
225
  }
175
226
 
@@ -183,19 +234,63 @@ class PrStateAdapter {
183
234
  * @returns {string[] | null}
184
235
  */
185
236
  _readProtectionRequired({ owner, repo, base }) {
237
+ this.lastProtectionStatus = 'malformed';
186
238
  const apiPath = `repos/${owner}/${repo}/branches/${encodeURIComponent(base)}/protection/required_status_checks`;
187
239
  try {
188
240
  const raw = this._gh('gh', ['api', apiPath]);
189
241
  const data = JSON.parse(raw || '{}');
190
- if (Array.isArray(data.contexts)) return data.contexts;
191
- 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
+ }
192
287
  // Unexpected/changed payload shape — treat as unreadable, not "no required
193
288
  // checks", so merge readiness is never computed from bad data.
194
289
  return null;
195
290
  } catch (error) {
196
291
  const auth = classifyAuthError(error);
197
292
  if (auth) {
198
- // Unreadable protection (auth/scope/not-protected) — fall back to rollup.
293
+ this.lastProtectionStatus = 'unavailable';
199
294
  return null;
200
295
  }
201
296
  throw error;
@@ -213,41 +308,38 @@ class PrStateAdapter {
213
308
  * @returns {string[] | null}
214
309
  */
215
310
  _readRollupRequired({ owner, repo, pr }) {
216
- const prNum = Number.parseInt(String(pr), 10);
217
- if (!Number.isInteger(prNum) || prNum <= 0) return null;
218
- // pr is inlined as a validated integer (no injection); owner/repo are GitHub
219
- // name-charset identifiers. Shape verified against the live GraphQL API.
220
- const query = `query { repository(owner: "${owner}", name: "${repo}") { `
221
- + `pullRequest(number: ${prNum}) { headRef { target { ... on Commit { `
222
- + `statusCheckRollup { contexts(first: 100) { nodes { __typename `
223
- + `... on CheckRun { name isRequired(pullRequestNumber: ${prNum}) } `
224
- + `... on StatusContext { context isRequired(pullRequestNumber: ${prNum}) } `
225
- + `} } } } } } } } }`;
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('');
226
320
  try {
227
- const raw = this._gh('gh', ['api', 'graphql', '-f', `query=${query}`]);
228
- const data = JSON.parse(raw || '{}');
229
- const nodes = data
230
- && data.data
231
- && data.data.repository
232
- && data.data.repository.pullRequest
233
- && data.data.repository.pullRequest.headRef
234
- && data.data.repository.pullRequest.headRef.target
235
- && data.data.repository.pullRequest.headRef.target.statusCheckRollup
236
- && data.data.repository.pullRequest.headRef.target.statusCheckRollup.contexts
237
- && data.data.repository.pullRequest.headRef.target.statusCheckRollup.contexts.nodes;
238
- // No rollup at all (e.g. statusCheckRollup null) → cannot determine the set.
239
- if (!Array.isArray(nodes)) return null;
240
- const required = [];
241
- for (const node of nodes) {
242
- if (node && node.isRequired === true) {
243
- const name = node.name || node.context;
244
- if (name) required.push(name);
245
- }
246
- }
247
- // Dedupe matrix duplicates (same context reported by multiple jobs).
248
- return [...new Set(required)];
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))];
249
342
  } catch {
250
- // GraphQL unreadable (auth/network/etc.) — fail closed to null.
251
343
  return null;
252
344
  }
253
345
  }
@@ -258,13 +350,13 @@ class PrStateAdapter {
258
350
  * `cwd` is threaded through to the git runner so divergence is computed
259
351
  * against the target worktree/checkout, not the process directory.
260
352
  *
261
- * @param {{ baseRef: string, cwd?: string }} ctx
353
+ * @param {{ baseRef: string, cwd?: string, headRef?: string }} ctx
262
354
  * @returns {Promise<{ behind: number, ahead: number }>}
263
355
  */
264
- async readDivergence({ baseRef, cwd }) {
356
+ async readDivergence({ baseRef, cwd, headRef = 'HEAD' }) {
265
357
  const out = this._git(
266
358
  'git',
267
- ['rev-list', '--left-right', '--count', `${baseRef}...HEAD`],
359
+ ['rev-list', '--left-right', '--count', `${baseRef}...${headRef}`],
268
360
  cwd ? { cwd } : undefined,
269
361
  );
270
362
  const [behindRaw = '0', aheadRaw = '0'] = String(out).trim().split(/\s+/);
@@ -286,7 +378,8 @@ class PrStateAdapter {
286
378
 
287
379
  /**
288
380
  * Fetch the base ref from its remote so a subsequent `readDivergence`/
289
- * `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
290
383
  * stale local remote-tracking ref (audit A6: a stale ref reports a false
291
384
  * `behind=0` / false "no conflict"). `baseRef` is `<remote>/<branch>` (e.g.
292
385
  * `origin/master`); the remote and branch are split back out for `git fetch`.
@@ -326,6 +419,8 @@ class PrStateAdapter {
326
419
  * @returns {Promise<object[]>}
327
420
  */
328
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');
329
424
  // `id`/`path`/`line` are surfaced so a consumer (e.g. the monitor bundle in
330
425
  // lib/pr-bundle.js) can hand an agent the thread id to resolve and the
331
426
  // file/line to act on — not just the body. Added fields are backward
@@ -334,22 +429,26 @@ class PrStateAdapter {
334
429
  // Both connections are FULLY paginated (cursors, not a first:100 cap): a
335
430
  // large PR must never silently drop a thread or a later reply, or the bundle
336
431
  // would declare "complete" on partial data and the monitor would skip work.
337
- const threads = this._fetchAllReviewThreads({ owner, repo, pr });
432
+ const threads = this._fetchAllReviewThreads({ owner, repo, pr: number });
338
433
  return threads.map((t) => {
339
- const allComments = (t.comments && t.comments.nodes) || [];
434
+ const allComments = t.comments.nodes;
340
435
  return {
341
- threadId: t.id || '',
436
+ threadId: t.id,
342
437
  path: t.path || null,
343
438
  line: typeof t.line === 'number' ? t.line : null,
344
- isResolved: !!t.isResolved,
345
- isOutdated: !!t.isOutdated,
346
- comments: allComments.map(c => ({
347
- author: (c.author && c.author.login) || '',
348
- body: c.body || '',
349
- // REST comment id (needed to REPLY to the thread). `fullDatabaseId` is
350
- // the string-encoded integer id; null when GitHub omits it.
351
- commentId: c.fullDatabaseId ? String(c.fullDatabaseId) : null,
352
- })),
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
+ }),
353
452
  };
354
453
  });
355
454
  }
@@ -364,16 +463,20 @@ class PrStateAdapter {
364
463
  * @returns {object[]} raw thread nodes (comments.nodes carries the full chain)
365
464
  */
366
465
  _fetchAllReviewThreads({ owner, repo, pr }) {
367
- 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}}}}}}}';
368
467
  return this._paginateConnection(
369
468
  (after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
370
469
  ?.data?.repository?.pullRequest?.reviewThreads,
371
470
  (t) => {
372
- // A thread whose first comment page is truncated → fetch the rest.
373
- if (t.comments?.pageInfo?.hasNextPage) {
374
- 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');
375
474
  }
376
- 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 } };
377
480
  },
378
481
  );
379
482
  }
@@ -395,7 +498,31 @@ class PrStateAdapter {
395
498
  '-F', `o=${owner}`, '-F', `n=${repo}`, '-F', `pr=${pr}`,
396
499
  ];
397
500
  if (after) args.push('-f', `after=${after}`);
398
- 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;
399
526
  }
400
527
 
401
528
  /**
@@ -416,11 +543,18 @@ class PrStateAdapter {
416
543
  let guard = 0;
417
544
  const MAX_PAGES = 1000;
418
545
  do {
419
- const conn = runPage(after);
420
- for (const node of (conn?.nodes || [])) all.push(mapNode(node));
421
- 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
+ }
422
553
  guard += 1;
423
- } 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);
424
558
  return all;
425
559
  }
426
560
 
@@ -433,21 +567,33 @@ class PrStateAdapter {
433
567
  * @returns {object[]} the full comment-node list (first page + all later pages)
434
568
  */
435
569
  _fetchAllThreadComments(threadId, initial) {
436
- 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}}}}}';
437
- const acc = [...((initial && initial.nodes) || [])];
438
- 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;
439
575
  let guard = 0;
440
576
  const MAX_PAGES = 1000;
441
- while (after && guard < MAX_PAGES) {
577
+ while (after !== null) {
578
+ const previous = after;
442
579
  const raw = this._gh('gh', [
443
580
  'api', 'graphql', '-f', `query=${query}`,
444
581
  '-f', `id=${threadId}`, '-f', `after=${after}`,
445
582
  ]);
446
- const data = JSON.parse(raw || '{}');
447
- const conn = data.data && data.data.node && data.data.node.comments;
448
- acc.push(...((conn && conn.nodes) || []));
449
- 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
+ }
450
593
  guard += 1;
594
+ if (guard >= MAX_PAGES && after !== null) {
595
+ throw new Error('GraphQL pagination exceeded the page limit');
596
+ }
451
597
  }
452
598
  return acc;
453
599
  }
@@ -467,56 +613,112 @@ class PrStateAdapter {
467
613
  // `author{__typename login}` surfaces the GraphQL actor TYPE ('Bot' vs 'User')
468
614
  // so a non-human direct comment can be detected GENERICALLY — by mechanism,
469
615
  // not by a hardcoded bot-name list — which fails closed for unknown bots.
470
- 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}}}}}';
471
617
  return this._paginateConnection(
472
618
  (after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
473
619
  ?.data?.repository?.pullRequest?.comments,
474
- (c) => ({
475
- // `id` is the stable REST comment id — the PR monitor keys `comment.posted`
476
- // on it so a re-read never re-emits an already-seen comment. Falls back to
477
- // author+createdAt when GitHub omits it.
478
- id: c.fullDatabaseId ? String(c.fullDatabaseId) : `${c.author?.login || ''}:${c.createdAt || ''}`,
479
- author: c.author?.login || '',
480
- authorTypename: c.author?.__typename || '',
481
- body: c.body || '',
482
- createdAt: c.createdAt || '',
483
- }),
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
+ },
484
639
  );
485
640
  }
486
641
 
487
642
  /**
488
643
  * Read submitted PR REVIEWS (not inline threads) — the latest review per
489
644
  * author, each with the commit it was submitted against. This is the
490
- * review-at-head signal that catches the #365 race: a CodeRabbit review from
491
- * an earlier commit whose `commit.oid` no longer matches HEAD is STALE, so the
492
- * 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`
493
648
  * cannot return a review's target commit oid. Fully paginated by cursor.
494
649
  *
495
650
  * @param {{ owner: string, repo: string, pr: string }} ctx
496
- * @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 }>>}
497
652
  */
498
653
  async readReviews({ owner, repo, pr }) {
499
- 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}}}}}';
500
655
  const all = this._paginateConnection(
501
656
  (after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
502
657
  ?.data?.repository?.pullRequest?.reviews,
503
- (r) => ({
504
- author: String(r.author?.login || '').toLowerCase(),
505
- authorTypename: r.author?.__typename || '',
506
- state: String(r.state || '').toUpperCase(),
507
- submittedAt: r.submittedAt || null,
508
- commitOid: r.commit?.oid || null,
509
- body: r.body || '',
510
- }),
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
+ },
511
693
  );
512
694
  // Keep only the LATEST review per author (first:100 yields oldest→newest, so
513
695
  // a later entry supersedes an earlier one from the same login).
514
696
  const latest = new Map();
697
+ const latestSubmission = new Map();
698
+ const latestActivity = new Map();
515
699
  for (const r of all) {
516
- if (!r.author) continue;
517
- 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));
518
717
  }
519
- 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
+ }));
520
722
  }
521
723
 
522
724
  /**
@@ -536,23 +738,23 @@ class PrStateAdapter {
536
738
  }
537
739
 
538
740
  /**
539
- * Predict files that would conflict when merging `baseRef` into HEAD WITHOUT
741
+ * Predict files that would conflict when merging `baseRef` into `headRef` WITHOUT
540
742
  * touching the working tree, via `git merge-tree --write-tree`. Returns
541
743
  * `{ supported: false, reason }` when conflict prediction is unavailable (git
542
744
  * < 2.38, an unreadable ref, or a non-conflict error) so the bundle degrades
543
745
  * gracefully rather than failing the whole gather.
544
746
  *
545
- * @param {{ baseRef: string, cwd?: string }} ctx
747
+ * @param {{ baseRef: string, cwd?: string, headRef?: string }} ctx
546
748
  * @returns {Promise<{ supported: boolean, conflicted?: boolean, files?: string[], reason?: string }>}
547
749
  */
548
- async detectConflicts({ baseRef, cwd }) {
750
+ async detectConflicts({ baseRef, cwd, headRef = 'HEAD' }) {
549
751
  const opts = cwd ? { cwd } : undefined;
550
752
  try {
551
753
  // Exit 0 = clean merge. `--name-only` reduces the conflict report to bare
552
754
  // paths; `--no-messages` suppresses the human-readable conflict prose.
553
755
  this._git(
554
756
  'git',
555
- ['merge-tree', '--write-tree', '--name-only', '--no-messages', baseRef, 'HEAD'],
757
+ ['merge-tree', '--write-tree', '--name-only', '--no-messages', baseRef, headRef],
556
758
  opts,
557
759
  );
558
760
  return { supported: true, conflicted: false, files: [] };