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
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * merge command — opt-in conditional auto-merge.
5
5
  *
6
- * `forge merge --auto <pr>` is the ONLY path by which Forge will merge a PR on
6
+ * `forge merge --auto <pr> --expect-head <sha> --issue <id>` is the ONLY path by which Forge will merge a PR on
7
7
  * its own, and it stays OFF unless the user has explicitly opted in. It reads
8
8
  * the `merge.auto` section of `.forge/config.yaml`:
9
9
  *
@@ -43,6 +43,381 @@ const { execFileSync } = require('node:child_process');
43
43
 
44
44
  const { loadRawConfig } = require('../config-writer');
45
45
  const { evaluateMergeRules } = require('../merge-rules');
46
+ const { runIssueOperation } = require('../forge-issues');
47
+ const { PrStateAdapter } = require('../adapters/pr-state-adapter');
48
+ const { stripGlobalFlags } = require('../global-flags');
49
+
50
+ const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
51
+ const FORGE_ISSUE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
52
+ const POSITIVE_PR_NUMBER = /^[1-9][0-9]*$/;
53
+ const REPOSITORY_NAME = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
54
+ const CHECK_RUN_STATUSES = new Set(['QUEUED', 'IN_PROGRESS', 'COMPLETED', 'WAITING', 'PENDING', 'REQUESTED']);
55
+ const CHECK_RUN_CONCLUSIONS = new Set([
56
+ '', 'SUCCESS', 'FAILURE', 'NEUTRAL', 'CANCELLED', 'SKIPPED', 'TIMED_OUT',
57
+ 'ACTION_REQUIRED', 'STALE', 'STARTUP_FAILURE',
58
+ ]);
59
+ const SAFE_TERMINAL_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
60
+ const STATUS_CONTEXT_STATES = new Set(['ERROR', 'EXPECTED', 'FAILURE', 'PENDING', 'SUCCESS']);
61
+ const REVIEW_ACTOR_TYPENAMES = new Set([
62
+ 'Bot', 'EnterpriseUserAccount', 'Mannequin', 'Organization', 'User',
63
+ ]);
64
+ const REVIEW_STATES = new Set([
65
+ 'APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING',
66
+ ]);
67
+
68
+ function normalizeFullHeadSha(value) {
69
+ return typeof value === 'string' && FULL_HEAD_SHA.test(value) ? value.toLowerCase() : null;
70
+ }
71
+
72
+ function normalizePrNumber(value) {
73
+ if (typeof value !== 'string' || !POSITIVE_PR_NUMBER.test(value)) return null;
74
+ const parsed = Number(value);
75
+ return Number.isSafeInteger(parsed) && parsed > 0 ? String(parsed) : null;
76
+ }
77
+
78
+ function normalizeKernelPrNumber(value) {
79
+ if (Number.isSafeInteger(value) && value > 0) return String(value);
80
+ return typeof value === 'string' ? normalizePrNumber(value) : null;
81
+ }
82
+
83
+ function normalizeRepository(value) {
84
+ return typeof value === 'string' && REPOSITORY_NAME.test(value) ? value.toLowerCase() : null;
85
+ }
86
+
87
+ function parseMergeArgs(argv) {
88
+ const values = { auto: false, pr: null, expectedHead: null, issueId: null, error: null };
89
+ const seen = new Set();
90
+ const input = Array.isArray(argv) ? argv : [];
91
+
92
+ for (let index = 0; index < input.length; index += 1) {
93
+ const raw = String(input[index]);
94
+ if (raw === '--auto') {
95
+ values.auto = true;
96
+ continue;
97
+ }
98
+
99
+ let option = null;
100
+ let inlineValue = null;
101
+ if (raw === '--expect-head' || raw === '--issue') option = raw;
102
+ else if (raw.startsWith('--expect-head=')) {
103
+ option = '--expect-head';
104
+ inlineValue = raw.slice('--expect-head='.length);
105
+ } else if (raw.startsWith('--issue=')) {
106
+ option = '--issue';
107
+ inlineValue = raw.slice('--issue='.length);
108
+ }
109
+
110
+ if (option) {
111
+ if (seen.has(option)) {
112
+ values.error = `Duplicate ${option} is not allowed.`;
113
+ break;
114
+ }
115
+ seen.add(option);
116
+ let value = inlineValue;
117
+ if (value === null) {
118
+ const candidate = input[index + 1];
119
+ if (candidate === undefined || String(candidate).startsWith('--')) {
120
+ values.error = `${option} requires a value.`;
121
+ break;
122
+ }
123
+ value = String(candidate);
124
+ index += 1;
125
+ }
126
+ if (!value) {
127
+ values.error = `${option} requires a value.`;
128
+ break;
129
+ }
130
+ if (option === '--expect-head') values.expectedHead = normalizeFullHeadSha(value);
131
+ else values.issueId = FORGE_ISSUE_ID.test(value) ? value.toLowerCase() : null;
132
+ if ((option === '--expect-head' && !values.expectedHead)
133
+ || (option === '--issue' && !values.issueId)) {
134
+ values.error = `${option} has an invalid value.`;
135
+ break;
136
+ }
137
+ continue;
138
+ }
139
+
140
+ if (raw.startsWith('--')) {
141
+ values.error = `Unknown merge option: ${raw}`;
142
+ break;
143
+ }
144
+ if (values.pr !== null) {
145
+ values.error = 'Exactly one PR number is required.';
146
+ break;
147
+ }
148
+ values.pr = normalizePrNumber(raw);
149
+ if (!values.pr) {
150
+ values.error = 'PR selector must be one positive decimal PR number.';
151
+ break;
152
+ }
153
+ }
154
+
155
+ return values;
156
+ }
157
+
158
+ function resolveOwnershipActor(env = process.env) {
159
+ return (typeof env.FORGE_ACTOR === 'string' && env.FORGE_ACTOR.trim())
160
+ || (typeof env.FORGE_SESSION_ID === 'string' && env.FORGE_SESSION_ID.trim())
161
+ || null;
162
+ }
163
+
164
+ async function defaultVerifyIssueOwnership({
165
+ issueId, projectRoot, actor: expectedActor, env = process.env, runIssue = runIssueOperation,
166
+ }) {
167
+ const actor = expectedActor || resolveOwnershipActor(env);
168
+ if (!actor) return { owned: false, actor: null, error: 'FORGE_ACTOR or FORGE_SESSION_ID is required.' };
169
+ const frozenEnv = { ...env, FORGE_ACTOR: actor };
170
+ const result = await runIssue('owns', [issueId], projectRoot, { env: frozenEnv });
171
+ const data = result && result.data;
172
+ const owned = result && result.ok === true && data && data.owned === true
173
+ && data.expired === false && data.actor === actor && data.claimed_by === actor;
174
+ return {
175
+ owned: Boolean(owned),
176
+ actor,
177
+ claimedBy: data && data.claimed_by,
178
+ expired: data && typeof data.expired === 'boolean' ? data.expired : null,
179
+ error: result && result.error,
180
+ };
181
+ }
182
+
183
+ async function defaultVerifyPrIssueBinding({
184
+ issueId,
185
+ pr,
186
+ projectRoot,
187
+ prContext,
188
+ buildBroker,
189
+ }) {
190
+ const number = normalizePrNumber(String(pr));
191
+ const repository = normalizeRepository(prContext && prContext.repository);
192
+ if (!number || !repository || !prContext || prContext.number !== Number(number)) {
193
+ return { bound: false, error: 'PR identity is unreadable or does not match the requested PR number.' };
194
+ }
195
+
196
+ let built;
197
+ const ownsDriver = !buildBroker;
198
+ try {
199
+ if (buildBroker) {
200
+ built = await buildBroker({ projectRoot });
201
+ } else {
202
+ const { resolveGitCommonDir } = require('../kernel/broker');
203
+ const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
204
+ const gitCommonDir = resolveGitCommonDir(projectRoot);
205
+ const deps = await buildMigratedKernelIssueDeps({ projectRoot, gitCommonDir });
206
+ built = { gitCommonDir, broker: deps.kernelBroker, driver: deps.kernelDriver };
207
+ }
208
+ if (!built || !built.broker || typeof built.broker.listOpenPrs !== 'function'
209
+ || typeof built.gitCommonDir !== 'string' || !built.gitCommonDir) {
210
+ return { bound: false, error: 'Kernel PR linkage reader is unavailable.' };
211
+ }
212
+ const rows = await built.broker.listOpenPrs(built.gitCommonDir);
213
+ if (!Array.isArray(rows)) return { bound: false, error: 'Kernel PR linkage is unreadable.' };
214
+ const sameRepositoryRows = rows.filter((row) => row && normalizeRepository(row.repo) === repository);
215
+ if (sameRepositoryRows.some((row) => !normalizeKernelPrNumber(row.number))) {
216
+ return { bound: false, error: 'Kernel PR linkage contains a malformed PR number.' };
217
+ }
218
+ const matches = sameRepositoryRows.filter((row) => row
219
+ && normalizeKernelPrNumber(row.number) === number);
220
+ if (matches.length !== 1) {
221
+ return { bound: false, error: 'Kernel PR linkage is missing or ambiguous.' };
222
+ }
223
+ const row = matches[0];
224
+ const bound = row.state === 'open' && row.issue_id === issueId;
225
+ return {
226
+ bound,
227
+ repository,
228
+ number: Number(number),
229
+ issueId: row.issue_id || null,
230
+ error: bound ? null : 'Kernel PR row is not open or is linked to a different issue.',
231
+ };
232
+ } catch (err) {
233
+ return { bound: false, error: `Kernel PR linkage verification failed: ${err.message}` };
234
+ } finally {
235
+ if (ownsDriver && built && built.driver && typeof built.driver.close === 'function') {
236
+ try { await built.driver.close(); } catch { /* cleanup must not mask the linkage verdict */ }
237
+ }
238
+ }
239
+ }
240
+
241
+ function strictCheckSuccess(check) {
242
+ if (!check || typeof check !== 'object') return false;
243
+ const hasStatus = Object.prototype.hasOwnProperty.call(check, 'status');
244
+ const hasConclusion = Object.prototype.hasOwnProperty.call(check, 'conclusion');
245
+ const hasState = Object.prototype.hasOwnProperty.call(check, 'state');
246
+ if (hasState) {
247
+ return !hasStatus && !hasConclusion && String(check.state || '').toUpperCase() === 'SUCCESS';
248
+ }
249
+ return hasStatus && hasConclusion
250
+ && String(check.status || '').toUpperCase() === 'COMPLETED'
251
+ && String(check.conclusion || '').toUpperCase() === 'SUCCESS';
252
+ }
253
+
254
+ /**
255
+ * Mandatory preflight accepts only terminal check-run conclusions that are safe
256
+ * to classify as optional. Required checks are still evaluated with
257
+ * strictCheckSuccess below, and status contexts never gain NEUTRAL/SKIPPED
258
+ * semantics because their only successful terminal state is SUCCESS.
259
+ */
260
+ function safeTerminalCheck(check) {
261
+ if (!check || typeof check !== 'object') return false;
262
+ if (Object.prototype.hasOwnProperty.call(check, 'state')) return strictCheckSuccess(check);
263
+ return Object.prototype.hasOwnProperty.call(check, 'status')
264
+ && Object.prototype.hasOwnProperty.call(check, 'conclusion')
265
+ && String(check.status || '').toUpperCase() === 'COMPLETED'
266
+ && SAFE_TERMINAL_CONCLUSIONS.has(String(check.conclusion || '').toUpperCase());
267
+ }
268
+
269
+ function malformedCheckObservation(check) {
270
+ if (!check || typeof check !== 'object') return true;
271
+ const name = check.name || check.context;
272
+ if (typeof name !== 'string' || !name.trim()) return true;
273
+ if (!Object.prototype.hasOwnProperty.call(check, 'appId')) return true;
274
+ const hasStatus = Object.prototype.hasOwnProperty.call(check, 'status');
275
+ const hasConclusion = Object.prototype.hasOwnProperty.call(check, 'conclusion');
276
+ const hasState = Object.prototype.hasOwnProperty.call(check, 'state');
277
+ const status = String(check.status || '').toUpperCase();
278
+ const conclusion = String(check.conclusion || '').toUpperCase();
279
+ const state = String(check.state || '').toUpperCase();
280
+ if (hasState) {
281
+ return hasStatus || hasConclusion || check.appId !== null
282
+ || typeof check.state !== 'string' || !STATUS_CONTEXT_STATES.has(state);
283
+ }
284
+ if (!hasStatus || !hasConclusion || !Number.isInteger(check.appId) || check.appId <= 0) return true;
285
+ if (hasStatus && (typeof check.status !== 'string' || !CHECK_RUN_STATUSES.has(status))) return true;
286
+ if (hasConclusion && check.conclusion !== null && typeof check.conclusion !== 'string') return true;
287
+ if (hasConclusion && !CHECK_RUN_CONCLUSIONS.has(conclusion)) return true;
288
+ if (hasStatus && status === 'COMPLETED' && !conclusion) return true;
289
+ if (hasStatus && status !== 'COMPLETED' && conclusion) return true;
290
+ return ['status', 'conclusion', 'state']
291
+ .some((key) => String(check[key] || '').toUpperCase() === 'UNREADABLE');
292
+ }
293
+
294
+ function normalizeRequiredEntry(entry) {
295
+ if (!entry || typeof entry.context !== 'string' || !entry.context.trim()) return null;
296
+ if (!Object.prototype.hasOwnProperty.call(entry, 'appId')) return null;
297
+ if (entry.appId !== null && (!Number.isInteger(entry.appId) || entry.appId <= 0)) return null;
298
+ return { context: entry.context, appId: entry.appId };
299
+ }
300
+
301
+ function normalizeRollupObservation(entry) {
302
+ if (!entry || typeof entry !== 'object') return null;
303
+ if (entry.__typename === 'StatusContext') {
304
+ return { name: entry.context, state: entry.state };
305
+ }
306
+ if (entry.__typename === 'CheckRun') {
307
+ return { name: entry.name, status: entry.status, conclusion: entry.conclusion };
308
+ }
309
+ return null;
310
+ }
311
+
312
+ function malformedRollupObservation(observation) {
313
+ if (!observation || typeof observation.name !== 'string' || !observation.name.trim()) return true;
314
+ const hasState = Object.prototype.hasOwnProperty.call(observation, 'state');
315
+ if (hasState) {
316
+ return Object.prototype.hasOwnProperty.call(observation, 'status')
317
+ || Object.prototype.hasOwnProperty.call(observation, 'conclusion')
318
+ || typeof observation.state !== 'string'
319
+ || !STATUS_CONTEXT_STATES.has(observation.state.toUpperCase());
320
+ }
321
+ const status = String(observation.status || '').toUpperCase();
322
+ const conclusion = String(observation.conclusion || '').toUpperCase();
323
+ if (typeof observation.status !== 'string' || !CHECK_RUN_STATUSES.has(status)) return true;
324
+ if (observation.conclusion !== null && typeof observation.conclusion !== 'string') return true;
325
+ if (!CHECK_RUN_CONCLUSIONS.has(conclusion)) return true;
326
+ if (status === 'COMPLETED' && !conclusion) return true;
327
+ return status !== 'COMPLETED' && Boolean(conclusion);
328
+ }
329
+
330
+ function evaluateProtectedRequiredChecks(context) {
331
+ if (!context || context.requiredCheckSource !== 'protection'
332
+ || !Array.isArray(context.requiredChecks) || !Array.isArray(context.checks)) {
333
+ return { allowed: false, reason: 'Protected required-check policy is unreadable or non-authoritative.' };
334
+ }
335
+ const required = context.requiredChecks.map(normalizeRequiredEntry);
336
+ if (required.some((entry) => !entry)) {
337
+ return { allowed: false, reason: 'Protected required-check policy contains malformed entries.' };
338
+ }
339
+ const policyApps = new Map();
340
+ for (const entry of required) {
341
+ if (!policyApps.has(entry.context)) policyApps.set(entry.context, new Set());
342
+ policyApps.get(entry.context).add(entry.appId === null ? '*' : String(entry.appId));
343
+ }
344
+ if ([...policyApps.values()].some((apps) => apps.size > 1)) {
345
+ return { allowed: false, reason: 'Protected required-check policy contains conflicting application identities.' };
346
+ }
347
+ if (context.checks.some(malformedCheckObservation)) {
348
+ return { allowed: false, reason: 'Check-run observation collection contains malformed entries.' };
349
+ }
350
+ const missing = [];
351
+ const nonSuccess = [];
352
+ for (const entry of required) {
353
+ const matching = context.checks.filter((check) => check
354
+ && (check.name || check.context) === entry.context
355
+ && (entry.appId === null || check.appId === entry.appId));
356
+ const label = entry.appId === null ? entry.context : `${entry.context}@app:${entry.appId}`;
357
+ if (matching.length === 0) missing.push(label);
358
+ else if (matching.some((check) => !strictCheckSuccess(check))) nonSuccess.push(label);
359
+ }
360
+ if (missing.length || nonSuccess.length) {
361
+ const parts = [];
362
+ if (missing.length) parts.push(`missing: ${missing.join(', ')}`);
363
+ if (nonSuccess.length) parts.push(`non-success: ${nonSuccess.join(', ')}`);
364
+ return {
365
+ allowed: false,
366
+ reason: `Protected required checks are not successful (${parts.join('; ')}).`,
367
+ details: { missing, nonSuccess },
368
+ };
369
+ }
370
+ return { allowed: true, details: { missing: [], nonSuccess: [] } };
371
+ }
372
+
373
+ function mandatoryContextError(context, expectedHead) {
374
+ const observedHead = normalizeFullHeadSha(context && context.headSha);
375
+ if (!observedHead || observedHead !== expectedHead) {
376
+ return 'PR head changed or could not be verified against --expect-head.';
377
+ }
378
+ if (context.state !== 'OPEN') return 'PR lifecycle state is unreadable or is not OPEN.';
379
+ if (context.isDraft !== false) return 'PR draft status is unreadable or the PR is still a draft.';
380
+ if (context.conflicting !== false) return 'PR conflict status is unreadable or conflicting.';
381
+ if (context.unresolvedThreads !== 0) return 'Review-thread state is unreadable or unresolved threads remain.';
382
+ if (context.reviewEvidenceReadable !== true || !Array.isArray(context.reviews)) {
383
+ return 'Review evidence is unreadable.';
384
+ }
385
+ for (const review of context.reviews) {
386
+ const state = review && typeof review.state === 'string' ? review.state.toUpperCase() : '';
387
+ const timestamps = review
388
+ ? [review.createdAt, review.updatedAt, review.submittedAt, review.activityAt]
389
+ : [];
390
+ if (!review || typeof review.id !== 'string' || !review.id
391
+ || typeof review.author !== 'string' || !review.author
392
+ || !REVIEW_ACTOR_TYPENAMES.has(review.authorTypename)
393
+ || !REVIEW_STATES.has(state)
394
+ || typeof review.commitOid !== 'string' || !FULL_HEAD_SHA.test(review.commitOid)
395
+ || typeof review.body !== 'string'
396
+ || timestamps.length !== 4
397
+ || timestamps.some((value) => typeof value !== 'string' || !Number.isFinite(Date.parse(value)))) {
398
+ return 'Review evidence contains malformed identity, state, timestamp, or commit-head data.';
399
+ }
400
+ if (state !== 'DISMISSED' && state !== 'COMMENTED' && review.commitOid.toLowerCase() !== expectedHead) {
401
+ return 'Latest active review evidence is stale for the expected PR head.';
402
+ }
403
+ if (state === 'CHANGES_REQUESTED' || state === 'PENDING') {
404
+ return `Latest review state ${state} does not authorize merging.`;
405
+ }
406
+ }
407
+ if (!Array.isArray(context.checks) || context.checks.some(malformedCheckObservation)
408
+ || context.checks.some((check) => !safeTerminalCheck(check))) {
409
+ return 'Every check-run and status observation must be complete and have a safe terminal conclusion (SUCCESS, NEUTRAL, or SKIPPED).';
410
+ }
411
+ if (Object.prototype.hasOwnProperty.call(context, 'providerObservations')) {
412
+ if (!Array.isArray(context.providerObservations)
413
+ || context.providerObservations.some(malformedRollupObservation)
414
+ || context.providerObservations.some((check) => !safeTerminalCheck(check))) {
415
+ return 'The complete provider rollup must contain only observations with safe terminal conclusions (SUCCESS, NEUTRAL, or SKIPPED).';
416
+ }
417
+ }
418
+ const protectedGate = evaluateProtectedRequiredChecks(context);
419
+ return protectedGate.allowed ? null : protectedGate.reason;
420
+ }
46
421
 
47
422
  /** Default `gh` runner. Only reached by the default fetch/merge seams (never in unit tests). */
48
423
  function defaultGh(args, options = {}) {
@@ -63,12 +438,9 @@ function ghJson(gh, args) {
63
438
  * is not a valid `gh pr view --json` field, so this needs a dedicated query.
64
439
  * Returns `undefined` on any failure so `threads_resolved` fails closed.
65
440
  */
66
- function fetchUnresolvedThreadCount(gh, pr) {
441
+ function fetchUnresolvedThreadCount(gh, { owner, repo, pr }) {
67
442
  try {
68
- const repo = ghJson(gh, ['repo', 'view', '--json', 'owner,name']);
69
- const owner = repo && repo.owner && repo.owner.login;
70
- const name = repo && repo.name;
71
- if (!owner || !name) return undefined;
443
+ if (!owner || !repo) return undefined;
72
444
  // Paginate through ALL review threads — a PR can have >100, and the ones on
73
445
  // later pages could be the unresolved/newest ones. Stopping at page 1 would
74
446
  // both miss them and make a large PR un-mergeable. Loop on the GraphQL cursor
@@ -79,15 +451,22 @@ function fetchUnresolvedThreadCount(gh, pr) {
79
451
  let count = 0;
80
452
  for (let page = 0; page < 100; page += 1) { // 100-page cap = 10k threads backstop
81
453
  const args = ['api', 'graphql', '-f', `query=${query}`,
82
- '-F', `o=${owner}`, '-F', `n=${name}`, '-F', `pr=${Number(pr)}`];
454
+ '-F', `o=${owner}`, '-F', `n=${repo}`, '-F', `pr=${Number(pr)}`];
83
455
  if (after) args.push('-F', `after=${after}`);
84
456
  const data = JSON.parse(gh(args) || '{}');
457
+ if (Object.prototype.hasOwnProperty.call(data, 'errors')
458
+ && (!Array.isArray(data.errors) || data.errors.length > 0)) return undefined;
85
459
  const threads = (((data.data || {}).repository || {}).pullRequest || {}).reviewThreads;
86
- if (!threads) return undefined; // unreadable page → fail closed
87
- count += (threads.nodes || []).filter((t) => t && t.isResolved === false && t.isOutdated === false).length;
88
- if (!threads.pageInfo || !threads.pageInfo.hasNextPage) return count;
89
- after = threads.pageInfo.endCursor;
90
- if (!after) return undefined; // hasNextPage but no cursor → fail closed
460
+ if (!threads || !Array.isArray(threads.nodes) || !threads.pageInfo
461
+ || typeof threads.pageInfo.hasNextPage !== 'boolean'
462
+ || (threads.pageInfo.endCursor !== null && typeof threads.pageInfo.endCursor !== 'string')
463
+ || threads.nodes.some((thread) => !thread || typeof thread.isResolved !== 'boolean'
464
+ || typeof thread.isOutdated !== 'boolean')) return undefined;
465
+ count += threads.nodes.filter((thread) => thread.isResolved === false && thread.isOutdated === false).length;
466
+ if (!threads.pageInfo.hasNextPage) return count;
467
+ const nextCursor = threads.pageInfo.endCursor;
468
+ if (typeof nextCursor !== 'string' || !nextCursor || nextCursor === after) return undefined;
469
+ after = nextCursor;
91
470
  }
92
471
  return undefined; // exceeded the page cap → fail closed rather than undercount
93
472
  } catch (_err) {
@@ -95,6 +474,47 @@ function fetchUnresolvedThreadCount(gh, pr) {
95
474
  }
96
475
  }
97
476
 
477
+ function fetchCheckRunObservations(gh, { owner, repo, head }) {
478
+ try {
479
+ const pages = JSON.parse(gh([
480
+ 'api', '--paginate', '--slurp',
481
+ `repos/${owner}/${repo}/commits/${head}/check-runs?filter=latest&per_page=100`,
482
+ ]) || 'null');
483
+ if (!Array.isArray(pages) || pages.length === 0
484
+ || pages.some((page) => !page || !Array.isArray(page.check_runs)
485
+ || !Number.isInteger(page.total_count) || page.total_count < 0)) return null;
486
+ const runs = pages.flatMap((page) => page.check_runs);
487
+ if (pages.some((page) => page.total_count !== pages[0].total_count)
488
+ || pages[0].total_count !== runs.length) return null;
489
+ if (runs.some((run) => {
490
+ const name = run && typeof run.name === 'string' && run.name.trim() ? run.name : null;
491
+ const appId = run && run.app && Number.isInteger(run.app.id) && run.app.id > 0 ? run.app.id : null;
492
+ const headSha = normalizeFullHeadSha(run && run.head_sha);
493
+ const status = String(run && run.status || '').toUpperCase();
494
+ const conclusion = String(run && run.conclusion || '').toUpperCase();
495
+ return !run || !Number.isInteger(run.id) || run.id <= 0 || !name || !appId || headSha !== head
496
+ || !CHECK_RUN_STATUSES.has(status)
497
+ || !Object.prototype.hasOwnProperty.call(run, 'conclusion')
498
+ || (run.conclusion !== null && typeof run.conclusion !== 'string')
499
+ || !CHECK_RUN_CONCLUSIONS.has(conclusion)
500
+ || (status === 'COMPLETED' ? !conclusion : Boolean(conclusion));
501
+ })) return null;
502
+ return runs.map((run) => {
503
+ const name = run.name;
504
+ const appId = run.app.id;
505
+ return {
506
+ id: run.id,
507
+ name,
508
+ appId,
509
+ status: String(run.status).toUpperCase(),
510
+ conclusion: String(run.conclusion || '').toUpperCase(),
511
+ };
512
+ });
513
+ } catch (_err) {
514
+ return null;
515
+ }
516
+ }
517
+
98
518
  /**
99
519
  * Default PR-context fetcher (the network seam). Assembles the shape consumed
100
520
  * by `evaluateMergeRules` from `gh`. Anything it cannot read is left absent so
@@ -103,27 +523,31 @@ function fetchUnresolvedThreadCount(gh, pr) {
103
523
  *
104
524
  * @returns {object} prContext
105
525
  */
106
- function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
526
+ async function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
107
527
  const view = ghJson(gh, ['pr', 'view', String(pr), '--json',
108
- 'number,state,isDraft,mergeable,mergeStateStatus,statusCheckRollup,reviews,comments,updatedAt']) || {};
528
+ 'number,headRefOid,baseRefName,state,isDraft,mergeable,mergeStateStatus,statusCheckRollup,comments,updatedAt']) || {};
109
529
 
110
530
  const rollup = Array.isArray(view.statusCheckRollup) ? view.statusCheckRollup : null;
111
- const checks = (rollup || []).map((c) => ({
112
- name: c.name || c.context || '?',
113
- conclusion: String(c.conclusion || c.state || c.status || ''),
114
- }));
115
531
 
116
532
  const comments = Array.isArray(view.comments)
117
533
  ? view.comments.map((c) => ({
118
534
  author: (c.author && c.author.login) || '',
119
- at: c.createdAt || c.submittedAt || '',
535
+ at: [c.createdAt, c.updatedAt, c.submittedAt]
536
+ .filter(Boolean)
537
+ .sort((left, right) => Date.parse(right) - Date.parse(left))[0] || '',
120
538
  }))
121
539
  : [];
122
540
 
123
- const reviews = Array.isArray(view.reviews) ? view.reviews : [];
541
+ const repoIdentity = ghJson(gh, ['repo', 'view', '--json', 'owner,name']);
542
+ const owner = repoIdentity && repoIdentity.owner && repoIdentity.owner.login;
543
+ const repo = repoIdentity && repoIdentity.name;
544
+ const adapter = owner && repo
545
+ ? new PrStateAdapter({ gh: (_cmd, adapterArgs) => gh(adapterArgs) })
546
+ : null;
547
+ const reviews = adapter ? await adapter.readReviews({ owner, repo, pr }) : [];
124
548
  const approvals = reviews
125
549
  .filter((r) => String(r.state).toUpperCase() === 'APPROVED')
126
- .map((r) => ({ author: (r.author && r.author.login) || '' }));
550
+ .map((r) => ({ author: typeof r.author === 'string' ? r.author : '' }));
127
551
 
128
552
  // Derive from GitHub's mergeStateStatus / mergeable. Only a known set maps to
129
553
  // a definite answer; anything else stays undefined so the dependent rule fails
@@ -146,29 +570,63 @@ function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
146
570
 
147
571
  const stamps = [
148
572
  ...comments.map((c) => c.at),
149
- ...reviews.map((r) => r.submittedAt || r.createdAt || ''),
573
+ ...reviews.flatMap((r) => [r.activityAt, r.createdAt, r.updatedAt, r.submittedAt]),
150
574
  view.updatedAt || '',
151
575
  ].map((s) => Date.parse(s)).filter((n) => !Number.isNaN(n));
152
576
  const lastActivityAt = stamps.length ? Math.max(...stamps) : undefined;
153
577
 
578
+ const base = typeof view.baseRefName === 'string' ? view.baseRefName : null;
579
+ const headSha = normalizeFullHeadSha(view.headRefOid);
580
+ let requiredChecks = null;
581
+ let requiredCheckSource = null;
582
+ let checks = null;
583
+ const providerObservations = rollup ? rollup.map(normalizeRollupObservation) : null;
584
+ if (adapter && base) {
585
+ requiredChecks = await adapter.readRequiredCheckPolicy({ owner, repo, base });
586
+ requiredCheckSource = adapter.lastRequiredSource;
587
+ const checkRuns = headSha ? fetchCheckRunObservations(gh, { owner, repo, head: headSha }) : null;
588
+ if (rollup && checkRuns && providerObservations.every((entry) => entry !== null)) {
589
+ const statuses = rollup
590
+ .filter((entry) => entry && entry.__typename === 'StatusContext')
591
+ .map((entry) => ({
592
+ name: typeof entry.context === 'string' ? entry.context : '',
593
+ appId: null,
594
+ state: String(entry.state || '').toUpperCase(),
595
+ }));
596
+ checks = [...checkRuns, ...statuses];
597
+ }
598
+ }
599
+
154
600
  return {
601
+ number: Number.isInteger(view.number) ? view.number : null,
602
+ repository: owner && repo ? `${owner}/${repo}` : null,
603
+ headSha: view.headRefOid || null,
155
604
  checks,
156
- requiredChecksKnown: rollup !== null,
157
- unresolvedThreads: fetchUnresolvedThreadCount(gh, pr),
605
+ providerObservations,
606
+ requiredChecks,
607
+ requiredCheckSource,
608
+ requiredChecksKnown: requiredCheckSource === 'protection' && Array.isArray(requiredChecks),
609
+ unresolvedThreads: fetchUnresolvedThreadCount(gh, { owner, repo, pr }),
158
610
  behindBase,
159
611
  conflicting,
160
612
  isDraft,
161
613
  state,
162
614
  approvals,
615
+ reviews,
616
+ reviewEvidenceReadable: adapter !== null,
163
617
  comments,
164
618
  lastActivityAt,
165
619
  now,
166
620
  };
167
621
  }
168
622
 
169
- /** Default merge action (squash). Fully replaced by `deps.mergePr` in tests. */
170
- function defaultMergePr({ pr, gh = defaultGh }) {
171
- gh(['pr', 'merge', String(pr), '--squash']);
623
+ /** Default merge action (squash), atomically bound to the reviewed remote head. */
624
+ function defaultMergePr({ pr, expectedHead, repository, gh = defaultGh }) {
625
+ const head = normalizeFullHeadSha(expectedHead);
626
+ const repo = normalizeRepository(repository);
627
+ if (!head) throw new Error('A full 40-character expected PR head SHA is required.');
628
+ if (!normalizePrNumber(String(pr)) || !repo) throw new Error('A canonical PR number and repository are required.');
629
+ gh(['pr', 'merge', String(pr), '--repo', repo, '--squash', '--match-head-commit', head]);
172
630
  return { merged: true, method: 'squash' };
173
631
  }
174
632
 
@@ -183,16 +641,16 @@ function defaultMergePr({ pr, gh = defaultGh }) {
183
641
  */
184
642
  async function handler(args, _flags, projectRoot, deps = {}) {
185
643
  const argv = Array.isArray(args) ? args : [];
186
- const positional = argv.filter((a) => !String(a).startsWith('--'));
187
- const flags = new Set(argv.filter((a) => String(a).startsWith('--')));
188
- const pr = positional[0];
644
+ const parsed = parseMergeArgs(stripGlobalFlags(argv));
645
+ const pr = parsed.pr;
189
646
  const root = projectRoot || process.cwd();
190
647
 
191
- if (!flags.has('--auto')) {
192
- return { success: false, error: 'Usage: forge merge --auto <pr> (opt-in conditional auto-merge; OFF by default)' };
193
- }
194
- if (!pr) {
195
- return { success: false, error: 'Usage: forge merge --auto <pr>' };
648
+ if (!parsed.auto || !pr) {
649
+ return {
650
+ success: false,
651
+ merged: false,
652
+ error: 'Usage: forge merge --auto <pr> --expect-head <40-char-sha> --issue <issue-id>',
653
+ };
196
654
  }
197
655
 
198
656
  const loadConfig = deps.loadConfig || loadRawConfig;
@@ -226,8 +684,40 @@ async function handler(args, _flags, projectRoot, deps = {}) {
226
684
  return { success: false, merged: false, enabled: true, reason };
227
685
  }
228
686
 
687
+ if (parsed.error || !parsed.expectedHead || !parsed.issueId) {
688
+ const reason = parsed.error
689
+ || 'Enabled auto-merge requires --expect-head <full 40-character SHA> and --issue <Forge issue ID>.';
690
+ return { success: false, merged: false, enabled: true, error: reason };
691
+ }
692
+
693
+ const verifyIssueOwnership = deps.verifyIssueOwnership || defaultVerifyIssueOwnership;
694
+ const ownershipEnv = deps.env || process.env;
695
+ const ownershipActor = resolveOwnershipActor(ownershipEnv);
696
+ const ownershipInput = {
697
+ issueId: parsed.issueId,
698
+ projectRoot: root,
699
+ actor: ownershipActor,
700
+ env: { ...ownershipEnv },
701
+ };
702
+ let ownership;
703
+ try {
704
+ ownership = await verifyIssueOwnership(ownershipInput);
705
+ } catch (err) {
706
+ return { success: false, merged: false, error: `Failed to verify issue ownership: ${err.message}` };
707
+ }
708
+ if (!ownership || ownership.owned !== true || ownership.expired !== false
709
+ || typeof ownership.actor !== 'string' || ownership.actor !== ownershipActor
710
+ || typeof ownership.claimedBy !== 'string' || ownership.claimedBy !== ownershipActor) {
711
+ return {
712
+ success: false,
713
+ merged: false,
714
+ error: `Active Kernel ownership claim is required for issue ${parsed.issueId}; refusing to merge.`,
715
+ };
716
+ }
717
+
229
718
  const fetchPrContext = deps.fetchPrContext || defaultFetchPrContext;
230
719
  const mergePr = deps.mergePr || defaultMergePr;
720
+ const verifyPrIssueBinding = deps.verifyPrIssueBinding || defaultVerifyPrIssueBinding;
231
721
  const gh = deps.gh || defaultGh;
232
722
 
233
723
  let prContext;
@@ -241,12 +731,35 @@ async function handler(args, _flags, projectRoot, deps = {}) {
241
731
  // command must be an idempotent NO-OP — never an error and never a second merge
242
732
  // attempt. (An absent/unknown state falls through to the fail-closed rules.)
243
733
  const prState = prContext && prContext.state ? String(prContext.state).toUpperCase() : '';
244
- if (prState && prState !== 'OPEN') {
734
+ if (prState === 'MERGED' || prState === 'CLOSED') {
245
735
  const reason = `PR #${pr} is ${prState} (not OPEN) — nothing to merge. No action taken.`;
246
736
  process.stdout.write(`${reason}\n`);
247
737
  return { success: true, merged: false, enabled: true, state: prState, reason };
248
738
  }
249
739
 
740
+ const mandatoryError = mandatoryContextError(prContext, parsed.expectedHead);
741
+ if (mandatoryError) return { success: false, merged: false, error: mandatoryError };
742
+ const leasedRepository = normalizeRepository(prContext && prContext.repository);
743
+ if (!leasedRepository) {
744
+ return { success: false, merged: false, error: 'PR repository identity is unreadable; refusing to merge.' };
745
+ }
746
+
747
+ let binding;
748
+ try {
749
+ binding = await verifyPrIssueBinding({
750
+ issueId: parsed.issueId,
751
+ pr,
752
+ projectRoot: root,
753
+ prContext,
754
+ buildBroker: deps.buildPrBindingBroker,
755
+ });
756
+ } catch (err) {
757
+ return { success: false, merged: false, error: `Failed to verify PR issue binding: ${err.message}` };
758
+ }
759
+ if (!binding || binding.bound !== true) {
760
+ return { success: false, merged: false, error: 'PR is not authoritatively linked to the supplied Forge issue.' };
761
+ }
762
+
250
763
  const { allowed, unmet } = evaluateMergeRules(prContext, rules);
251
764
 
252
765
  if (!allowed) {
@@ -258,7 +771,7 @@ async function handler(args, _flags, projectRoot, deps = {}) {
258
771
  }
259
772
 
260
773
  // TOCTOU guard: PR state can change between the first fetch and the merge — a
261
- // new comment resets settle_min, a required check regresses, a thread opens.
774
+ // new comment resets configured settle_min, a required check regresses, or a thread opens.
262
775
  // Re-pull LIVE data and re-evaluate immediately before merging so our custom
263
776
  // rules (which GitHub's server-side branch protection does NOT enforce) are
264
777
  // honored against the freshest possible state, never a stale snapshot.
@@ -271,11 +784,17 @@ async function handler(args, _flags, projectRoot, deps = {}) {
271
784
  // Re-apply the terminal-state guard on the FRESH context: the PR may have been
272
785
  // merged or closed between the first fetch and now. Never merge a terminal PR.
273
786
  const freshState = freshContext && freshContext.state ? String(freshContext.state).toUpperCase() : '';
274
- if (freshState && freshState !== 'OPEN') {
787
+ if (freshState === 'MERGED' || freshState === 'CLOSED') {
275
788
  const reason = `PR #${pr} became ${freshState} (not OPEN) before merge — nothing to merge. No action taken.`;
276
789
  process.stdout.write(`${reason}\n`);
277
790
  return { success: true, merged: false, enabled: true, state: freshState, reason };
278
791
  }
792
+ const freshMandatoryError = mandatoryContextError(freshContext, parsed.expectedHead);
793
+ if (freshMandatoryError) return { success: false, merged: false, error: freshMandatoryError };
794
+ const freshRepository = normalizeRepository(freshContext && freshContext.repository);
795
+ if (!freshRepository || freshRepository !== leasedRepository) {
796
+ return { success: false, merged: false, error: 'PR repository identity changed or became unreadable before merge.' };
797
+ }
279
798
  const recheck = evaluateMergeRules(freshContext, rules);
280
799
  if (!recheck.allowed) {
281
800
  process.stdout.write(`Auto-merge ABORTED for PR #${pr} — state changed since first check; ${recheck.unmet.length} rule(s) now unmet:\n`);
@@ -285,8 +804,43 @@ async function handler(args, _flags, projectRoot, deps = {}) {
285
804
  return { success: true, merged: false, enabled: true, allowed: false, unmet: recheck.unmet, reason: 'PR state changed before merge (live re-check failed)' };
286
805
  }
287
806
 
807
+ let freshBinding;
808
+ try {
809
+ freshBinding = await verifyPrIssueBinding({
810
+ issueId: parsed.issueId,
811
+ pr,
812
+ projectRoot: root,
813
+ prContext: freshContext,
814
+ buildBroker: deps.buildPrBindingBroker,
815
+ });
816
+ } catch (err) {
817
+ return { success: false, merged: false, error: `Failed to re-verify PR issue binding: ${err.message}` };
818
+ }
819
+ if (!freshBinding || freshBinding.bound !== true) {
820
+ return { success: false, merged: false, error: 'PR issue linkage changed or is unreadable before merge.' };
821
+ }
822
+
823
+ let finalOwnership;
824
+ try {
825
+ finalOwnership = await verifyIssueOwnership(ownershipInput);
826
+ } catch (err) {
827
+ return { success: false, merged: false, error: `Failed to re-verify issue ownership before merge: ${err.message}` };
828
+ }
829
+ if (!finalOwnership || finalOwnership.owned !== true || finalOwnership.expired !== false
830
+ || typeof finalOwnership.actor !== 'string' || finalOwnership.actor !== ownershipActor
831
+ || typeof finalOwnership.claimedBy !== 'string' || finalOwnership.claimedBy !== ownershipActor) {
832
+ return { success: false, merged: false, error: 'Kernel ownership changed or expired before merge; refusing to merge.' };
833
+ }
834
+
288
835
  try {
289
- const mergeResult = await mergePr({ pr, projectRoot: root, gh });
836
+ const mergeResult = await mergePr({
837
+ pr,
838
+ expectedHead: parsed.expectedHead,
839
+ repository: leasedRepository,
840
+ issueId: parsed.issueId,
841
+ projectRoot: root,
842
+ gh,
843
+ });
290
844
  process.stdout.write(`All ${rules.length} merge rule(s) passed — merged PR #${pr}.\n`);
291
845
  return {
292
846
  success: true,
@@ -304,9 +858,15 @@ async function handler(args, _flags, projectRoot, deps = {}) {
304
858
  module.exports = {
305
859
  name: 'merge',
306
860
  description: 'Opt-in conditional auto-merge: merge a PR only when all user-configured rules pass (OFF by default)',
307
- usage: 'Usage: forge merge --auto <pr>',
861
+ usage: 'Usage: forge merge --auto <pr> --expect-head <40-char-sha> --issue <issue-id>',
308
862
  handler,
309
863
  // Exported seams for testing / reuse.
310
864
  defaultFetchPrContext,
311
865
  defaultMergePr,
866
+ defaultVerifyPrIssueBinding,
867
+ defaultVerifyIssueOwnership,
868
+ evaluateProtectedRequiredChecks,
869
+ normalizeFullHeadSha,
870
+ normalizePrNumber,
871
+ parseMergeArgs,
312
872
  };