forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6

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 +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  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,834 @@ 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
+ const gateCommand = require('./gate');
50
+ const { computeContentHash, validateContractStructure } = require('../../packages/memory-contracts');
51
+
52
+ const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
53
+ 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;
54
+ const POSITIVE_PR_NUMBER = /^[1-9][0-9]*$/;
55
+ const REPOSITORY_NAME = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
56
+ const CHECK_RUN_STATUSES = new Set(['QUEUED', 'IN_PROGRESS', 'COMPLETED', 'WAITING', 'PENDING', 'REQUESTED']);
57
+ const CHECK_RUN_CONCLUSIONS = new Set([
58
+ '', 'SUCCESS', 'FAILURE', 'NEUTRAL', 'CANCELLED', 'SKIPPED', 'TIMED_OUT',
59
+ 'ACTION_REQUIRED', 'STALE', 'STARTUP_FAILURE',
60
+ ]);
61
+ const SAFE_TERMINAL_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
62
+ const STATUS_CONTEXT_STATES = new Set(['ERROR', 'EXPECTED', 'FAILURE', 'PENDING', 'SUCCESS']);
63
+ const REVIEW_ACTOR_TYPENAMES = new Set([
64
+ 'Bot', 'EnterpriseUserAccount', 'Mannequin', 'Organization', 'User',
65
+ ]);
66
+ const REVIEW_STATES = new Set([
67
+ 'APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING',
68
+ ]);
69
+ const MERGE_GATE = 'gate.merge';
70
+
71
+ function uuidFromSeed(seed) {
72
+ const hash = computeContentHash({ seed });
73
+ return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-8${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
74
+ }
75
+
76
+ function contractEnvelope(schemaId, objectId, createdAt, actor, payload) {
77
+ const value = {
78
+ schema_id: schemaId,
79
+ schema_version: 1,
80
+ object_id: objectId,
81
+ created_at: createdAt,
82
+ producer: { product_id: 'forge', product_version: '0.1.0', instance_id: actor },
83
+ capabilities_used: [],
84
+ provenance: { source_kind: 'guarded-merge', actor_class: 'system', actor_id: actor },
85
+ payload,
86
+ extensions: {},
87
+ };
88
+ value.content_hash = computeContentHash(value);
89
+ const validation = validateContractStructure(value);
90
+ if (!validation.ok) throw new Error(`${schemaId} validation failed`);
91
+ return value;
92
+ }
93
+
94
+ function currentTimestamp(now = Date.now) {
95
+ const value = typeof now === 'function' ? now() : now;
96
+ const date = new Date(value);
97
+ if (!Number.isFinite(date.getTime())) throw new Error('Merge decision clock is invalid.');
98
+ return date.toISOString();
99
+ }
100
+
101
+ function normalizeFullHeadSha(value) {
102
+ return typeof value === 'string' && FULL_HEAD_SHA.test(value) ? value.toLowerCase() : null;
103
+ }
104
+
105
+ function normalizePrNumber(value) {
106
+ if (typeof value !== 'string' || !POSITIVE_PR_NUMBER.test(value)) return null;
107
+ const parsed = Number(value);
108
+ return Number.isSafeInteger(parsed) && parsed > 0 ? String(parsed) : null;
109
+ }
110
+
111
+ function normalizeKernelPrNumber(value) {
112
+ if (Number.isSafeInteger(value) && value > 0) return String(value);
113
+ return typeof value === 'string' ? normalizePrNumber(value) : null;
114
+ }
115
+
116
+ function normalizeRepository(value) {
117
+ return typeof value === 'string' && REPOSITORY_NAME.test(value) ? value.toLowerCase() : null;
118
+ }
119
+
120
+ function defaultResolveLocalRepository({ projectRoot, git = execFileSync }) {
121
+ try {
122
+ const remote = git('git', ['remote', 'get-url', 'origin'], {
123
+ cwd: projectRoot,
124
+ encoding: 'utf8',
125
+ timeout: 3000,
126
+ stdio: ['pipe', 'pipe', 'pipe'],
127
+ windowsHide: true,
128
+ }).trim();
129
+ const scp = /^[A-Za-z]:/.test(remote) || /^(?:git|https?|ssh|file):/i.test(remote) ? null
130
+ : /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9][A-Za-z0-9._-]*:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(remote);
131
+ if (scp) return normalizeRepository(scp[1]);
132
+ const parsed = new URL(remote);
133
+ if (!['git:', 'http:', 'https:', 'ssh:'].includes(parsed.protocol)
134
+ || !parsed.hostname || parsed.password || parsed.search || parsed.hash
135
+ || (['http:', 'https:'].includes(parsed.protocol) && parsed.username)) return null;
136
+ const parts = parsed.pathname.split('/').filter(Boolean);
137
+ if (parts.length !== 2) return null;
138
+ return normalizeRepository(`${parts[0]}/${parts[1].replace(/\.git$/, '')}`);
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ function terminalEvidenceFromTrace(row, { issueId, repository, number, expectedHead }) {
145
+ const normalizedExpectedHead = normalizeFullHeadSha(expectedHead);
146
+ if (!normalizedExpectedHead || !Array.isArray(row?.iterations)) return { invalid: true };
147
+ const merged = row.iterations.filter(iteration => iteration?.type === 'pr.merged');
148
+ if (merged.length === 0) return null;
149
+ if (merged.length !== 1) return { invalid: true };
150
+ const exact = merged.filter(iteration => iteration.issue_id === issueId
151
+ && Number.isInteger(iteration.issue_revision) && iteration.issue_revision >= 0
152
+ && normalizeFullHeadSha(iteration.head_sha) === normalizedExpectedHead
153
+ && typeof iteration.at === 'string' && Number.isFinite(Date.parse(iteration.at))
154
+ && /^[0-9a-f]{64}$/.test(iteration.work_packet_hash)
155
+ && /^[0-9a-f]{64}$/.test(iteration.run_receipt_hash));
156
+ if (exact.length !== 1) return { invalid: true };
157
+ const iteration = exact[0];
158
+ const decisionSeed = `${issueId}:${iteration.issue_revision}:${repository}:${number}:${normalizedExpectedHead}`;
159
+ return {
160
+ decisionId: uuidFromSeed(`merge-packet:${decisionSeed}`),
161
+ receiptId: uuidFromSeed(`merge-receipt:${iteration.work_packet_hash}`),
162
+ receiptHash: iteration.run_receipt_hash,
163
+ occurredAt: iteration.at,
164
+ };
165
+ }
166
+
167
+ function parseMergeArgs(argv) {
168
+ const values = { auto: false, pr: null, expectedHead: null, issueId: null, error: null };
169
+ const seen = new Set();
170
+ const input = Array.isArray(argv) ? argv : [];
171
+
172
+ for (let index = 0; index < input.length; index += 1) {
173
+ const raw = String(input[index]);
174
+ if (raw === '--auto') {
175
+ values.auto = true;
176
+ continue;
177
+ }
178
+
179
+ let option = null;
180
+ let inlineValue = null;
181
+ if (raw === '--expect-head' || raw === '--issue') option = raw;
182
+ else if (raw.startsWith('--expect-head=')) {
183
+ option = '--expect-head';
184
+ inlineValue = raw.slice('--expect-head='.length);
185
+ } else if (raw.startsWith('--issue=')) {
186
+ option = '--issue';
187
+ inlineValue = raw.slice('--issue='.length);
188
+ }
189
+
190
+ if (option) {
191
+ if (seen.has(option)) {
192
+ values.error = `Duplicate ${option} is not allowed.`;
193
+ break;
194
+ }
195
+ seen.add(option);
196
+ let value = inlineValue;
197
+ if (value === null) {
198
+ const candidate = input[index + 1];
199
+ if (candidate === undefined || String(candidate).startsWith('--')) {
200
+ values.error = `${option} requires a value.`;
201
+ break;
202
+ }
203
+ value = String(candidate);
204
+ index += 1;
205
+ }
206
+ if (!value) {
207
+ values.error = `${option} requires a value.`;
208
+ break;
209
+ }
210
+ if (option === '--expect-head') values.expectedHead = normalizeFullHeadSha(value);
211
+ else values.issueId = FORGE_ISSUE_ID.test(value) ? value.toLowerCase() : null;
212
+ if ((option === '--expect-head' && !values.expectedHead)
213
+ || (option === '--issue' && !values.issueId)) {
214
+ values.error = `${option} has an invalid value.`;
215
+ break;
216
+ }
217
+ continue;
218
+ }
219
+
220
+ if (raw.startsWith('--')) {
221
+ values.error = `Unknown merge option: ${raw}`;
222
+ break;
223
+ }
224
+ if (values.pr !== null) {
225
+ values.error = 'Exactly one PR number is required.';
226
+ break;
227
+ }
228
+ values.pr = normalizePrNumber(raw);
229
+ if (!values.pr) {
230
+ values.error = 'PR selector must be one positive decimal PR number.';
231
+ break;
232
+ }
233
+ }
234
+
235
+ return values;
236
+ }
237
+
238
+ function resolveOwnershipActor(env = process.env) {
239
+ return (typeof env.FORGE_ACTOR === 'string' && env.FORGE_ACTOR.trim())
240
+ || (typeof env.FORGE_SESSION_ID === 'string' && env.FORGE_SESSION_ID.trim())
241
+ || null;
242
+ }
243
+
244
+ async function defaultVerifyIssueOwnership({
245
+ issueId, projectRoot, actor: expectedActor, env = process.env, runIssue = runIssueOperation,
246
+ }) {
247
+ const actor = expectedActor || resolveOwnershipActor(env);
248
+ if (!actor) return { owned: false, actor: null, error: 'FORGE_ACTOR or FORGE_SESSION_ID is required.' };
249
+ const frozenEnv = { ...env, FORGE_ACTOR: actor };
250
+ const result = await runIssue('owns', [issueId], projectRoot, { env: frozenEnv });
251
+ const data = result?.data;
252
+ const ownershipMatches = result?.ok === true && data?.owned === true
253
+ && data.expired === false && data.actor === actor && data.claimed_by === actor;
254
+ const expectedSessionId = typeof env.FORGE_SESSION_ID === 'string' && env.FORGE_SESSION_ID.trim()
255
+ ? env.FORGE_SESSION_ID.trim()
256
+ : null;
257
+ let sessionId = null;
258
+ if (ownershipMatches && expectedSessionId) {
259
+ const claimsResult = await runIssue('claims', [], projectRoot, { env: frozenEnv });
260
+ const claims = claimsResult?.ok === true && Array.isArray(claimsResult.data?.claims)
261
+ ? claimsResult.data.claims
262
+ : [];
263
+ const exactClaims = claims.filter(claim => claim?.issue_id === issueId
264
+ && claim?.actor === actor && claim?.session_id === expectedSessionId);
265
+ sessionId = exactClaims.length === 1 ? exactClaims[0].session_id : null;
266
+ }
267
+ const owned = ownershipMatches && (!expectedSessionId || sessionId === expectedSessionId);
268
+ return {
269
+ owned: Boolean(owned),
270
+ actor,
271
+ claimedBy: data?.claimed_by,
272
+ sessionId,
273
+ expired: typeof data?.expired === 'boolean' ? data.expired : null,
274
+ error: result?.error,
275
+ };
276
+ }
277
+
278
+ function selectPrBindingRow(rows, repository, number, gitCommonDir = null) {
279
+ if (!Array.isArray(rows)) return { error: 'Kernel PR linkage is unreadable.' };
280
+ const repositoryRows = repository
281
+ ? rows.filter(row => normalizeRepository(row?.repo) === repository)
282
+ : rows;
283
+ const candidateRows = gitCommonDir
284
+ ? repositoryRows.filter(row => row?.git_common_dir === gitCommonDir)
285
+ : repositoryRows;
286
+ if (candidateRows.some(row => !normalizeKernelPrNumber(row.number))) {
287
+ return { error: 'Kernel PR linkage contains a malformed PR number.' };
288
+ }
289
+ const matches = candidateRows.filter(row => normalizeKernelPrNumber(row.number) === number);
290
+ if (matches.length === 0) return { missing: true, error: 'Kernel PR linkage is missing.' };
291
+ if (matches.length > 1) return { error: 'Kernel PR linkage is ambiguous.' };
292
+ if (!normalizeRepository(matches[0]?.repo)) return { error: 'Kernel PR repository identity is unreadable.' };
293
+ return { row: matches[0] };
294
+ }
295
+
296
+ async function openPrBindingBroker(projectRoot, buildBroker) {
297
+ if (buildBroker) {
298
+ const built = await buildBroker({ projectRoot });
299
+ return { built, ownsDriver: false };
300
+ }
301
+ const { resolveGitCommonDir } = require('../kernel/broker');
302
+ const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
303
+ const gitCommonDir = resolveGitCommonDir(projectRoot);
304
+ const deps = await buildMigratedKernelIssueDeps({ projectRoot, gitCommonDir });
305
+ return {
306
+ built: { gitCommonDir, broker: deps.kernelBroker, driver: deps.kernelDriver },
307
+ ownsDriver: true,
308
+ };
309
+ }
310
+
311
+ async function readPrBindingRows(built, allowRetired, { issueId, number, repository }) {
312
+ if (allowRetired) {
313
+ const target = {
314
+ issue_id: issueId,
315
+ pr_number: Number(number),
316
+ git_common_dir: built.gitCommonDir,
317
+ };
318
+ if (repository) target.repo = repository;
319
+ const read = await built.broker.readTrace(target);
320
+ return { rows: read?.pull_requests, gaps: read?.gaps };
321
+ }
322
+ return { rows: await built.broker.listOpenPrs(built.gitCommonDir), gaps: [] };
323
+ }
324
+
325
+ function terminalTraceGapKind(gap) {
326
+ if (typeof gap !== 'string') return null;
327
+ if (/^(?:issue|worktree|work_folder|pull_requests|(?:plan|tasks|decisions)(?::(?:outside_project|oversized))?)$/.test(gap)) {
328
+ return 'benign';
329
+ }
330
+ if (gap === 'pull_requests:overflow'
331
+ || /^pull_requests:[^:]+:unlinked_issue$/.test(gap)
332
+ || /^iterations:[^:]+:(?:missing|incomplete|overflow)$/.test(gap)) {
333
+ return 'authority';
334
+ }
335
+ return null;
336
+ }
337
+
338
+ function terminalTraceIsIncomplete(gaps, row) {
339
+ if (!Array.isArray(gaps) || gaps.some(gap => terminalTraceGapKind(gap) === null)) return true;
340
+ const rowId = typeof row?.id === 'string' && row.id ? row.id : null;
341
+ return gaps.some((gap) => {
342
+ if (gap === 'pull_requests:overflow') return true;
343
+ if (rowId && gap === `pull_requests:${rowId}:unlinked_issue`) return true;
344
+ if (!/^iterations:[^:]+:(?:missing|incomplete|overflow)$/.test(gap)) return false;
345
+ return !rowId || ['missing', 'incomplete', 'overflow']
346
+ .some(suffix => gap === `iterations:${rowId}:${suffix}`);
347
+ });
348
+ }
349
+
350
+ function terminalTraceAbsenceIsClean(gaps) {
351
+ return Array.isArray(gaps) && gaps.every(gap => terminalTraceGapKind(gap) === 'benign');
352
+ }
353
+
354
+ async function defaultVerifyPrIssueBinding({
355
+ issueId,
356
+ pr,
357
+ projectRoot,
358
+ prContext,
359
+ buildBroker,
360
+ allowRetired = false,
361
+ }) {
362
+ const number = normalizePrNumber(String(pr));
363
+ let repository = normalizeRepository(prContext?.repository);
364
+ if (!number || !prContext || prContext.number !== Number(number) || (!allowRetired && !repository)) {
365
+ return { bound: false, error: 'PR identity is unreadable or does not match the requested PR number.' };
366
+ }
367
+
368
+ let built;
369
+ let ownsDriver = false;
370
+ try {
371
+ ({ built, ownsDriver } = await openPrBindingBroker(projectRoot, buildBroker));
372
+ const linkageReader = allowRetired ? built?.broker?.readTrace : built?.broker?.listOpenPrs;
373
+ if (!built?.broker || typeof linkageReader !== 'function'
374
+ || typeof built.gitCommonDir !== 'string' || !built.gitCommonDir) {
375
+ return { bound: false, error: 'Kernel PR linkage reader is unavailable.' };
376
+ }
377
+ const linkage = await readPrBindingRows(built, allowRetired, { issueId, number, repository });
378
+ const { rows } = linkage;
379
+ const selection = selectPrBindingRow(
380
+ rows,
381
+ repository,
382
+ number,
383
+ allowRetired ? built.gitCommonDir : null,
384
+ );
385
+ if (!selection.row) {
386
+ const cleanRetiredAbsence = allowRetired && selection.missing === true
387
+ && terminalTraceAbsenceIsClean(linkage.gaps);
388
+ return {
389
+ bound: false,
390
+ error: cleanRetiredAbsence
391
+ ? selection.error
392
+ : (selection.missing === true && allowRetired
393
+ ? 'Kernel merged PR evidence is incomplete or ambiguous.'
394
+ : selection.error),
395
+ ...(cleanRetiredAbsence ? { missing: true } : {}),
396
+ };
397
+ }
398
+ const { row } = selection;
399
+ repository = repository || normalizeRepository(row.repo);
400
+ const bound = (allowRetired || row.state === 'open') && row.issue_id === issueId;
401
+ if (allowRetired && bound && terminalTraceIsIncomplete(linkage.gaps, row)) {
402
+ return { bound: false, error: 'Kernel merged PR evidence is incomplete or ambiguous.' };
403
+ }
404
+ const terminalEvidence = allowRetired && bound
405
+ ? terminalEvidenceFromTrace(row, {
406
+ issueId,
407
+ repository,
408
+ number: Number(number),
409
+ expectedHead: normalizeFullHeadSha(prContext?.headSha),
410
+ })
411
+ : null;
412
+ if (terminalEvidence?.invalid) {
413
+ return { bound: false, error: 'Kernel merged PR evidence is incomplete or ambiguous.' };
414
+ }
415
+ return {
416
+ bound,
417
+ repository,
418
+ number: Number(number),
419
+ issueId: row.issue_id || null,
420
+ branch: row.branch || null,
421
+ url: row.url || null,
422
+ gitCommonDir: row.git_common_dir || built.gitCommonDir,
423
+ error: bound ? null : (allowRetired
424
+ ? 'Kernel PR row is linked to a different issue.'
425
+ : 'Kernel PR row is not open or is linked to a different issue.'),
426
+ ...(terminalEvidence ? { terminalEvidence } : {}),
427
+ };
428
+ } catch (err) {
429
+ return { bound: false, error: `Kernel PR linkage verification failed: ${err.message}` };
430
+ } finally {
431
+ if (ownsDriver && typeof built?.driver?.close === 'function') {
432
+ try { await built.driver.close(); } catch { /* cleanup must not mask the linkage verdict */ }
433
+ }
434
+ }
435
+ }
436
+
437
+ async function defaultVerifyMergeGate({ issueId, projectRoot, now }) {
438
+ const result = await gateCommand.handler(
439
+ ['check', issueId, MERGE_GATE],
440
+ {},
441
+ projectRoot,
442
+ { now },
443
+ );
444
+ return result;
445
+ }
446
+
447
+ async function defaultPrepareMergeDecision({
448
+ issueId,
449
+ pr,
450
+ expectedHead,
451
+ repository,
452
+ binding,
453
+ actor,
454
+ sessionId,
455
+ requireMergeGate = true,
456
+ config,
457
+ projectRoot,
458
+ env = process.env,
459
+ now = Date.now,
460
+ runIssue = runIssueOperation,
461
+ }) {
462
+ if (requireMergeGate !== true) {
463
+ throw new Error('A disabled merge gate cannot produce broker-verifiable terminal authority.');
464
+ }
465
+ const timestamp = currentTimestamp(now);
466
+ const frozenEnv = { ...env, FORGE_ACTOR: actor };
467
+ if (sessionId) frozenEnv.FORGE_SESSION_ID = sessionId;
468
+ else delete frozenEnv.FORGE_SESSION_ID;
469
+ const issue = await runIssue('show', [issueId], projectRoot, {
470
+ env: frozenEnv,
471
+ });
472
+ const revision = issue?.data?.revision;
473
+ if (issue?.ok !== true || !Number.isInteger(revision) || revision < 0) {
474
+ throw new Error('Live issue revision is unavailable for the merge decision.');
475
+ }
476
+ if (!binding || typeof binding.branch !== 'string' || !binding.branch
477
+ || typeof binding.gitCommonDir !== 'string' || !binding.gitCommonDir) {
478
+ throw new Error('Authoritative PR branch/worktree linkage is unavailable for the merge decision.');
479
+ }
480
+ const capabilityDigest = computeContentHash({ capability: 'forge.merge.exact-head.v1' });
481
+ const workflowConfigRevision = computeContentHash({ merge: config.merge?.auto ?? null });
482
+ const riskManifestDigest = computeContentHash({ rules: config.merge?.auto?.rules ?? [] });
483
+ const decisionSeed = `${issueId}:${revision}:${repository}:${pr}:${expectedHead}`;
484
+ const packet = contractEnvelope(
485
+ 'forge.memory.work-packet.v1',
486
+ uuidFromSeed(`merge-packet:${decisionSeed}`),
487
+ timestamp,
488
+ actor,
489
+ {
490
+ issue_id: issueId,
491
+ expected_issue_revision: revision,
492
+ packet_id: `merge:${decisionSeed}`,
493
+ packet_revision: 1,
494
+ repository_id: repository,
495
+ target_head: expectedHead,
496
+ objective: `Guarded exact-head merge of ${repository}#${pr}`,
497
+ authority: { kind: 'kernel-live-lease', issue_revision: revision },
498
+ allowed_mutations: ['pr.merged'],
499
+ prohibited_actions: ['pr.opened', 'review-thread.resolve'],
500
+ workflow_config_revision: workflowConfigRevision,
501
+ capability_manifest_digest: capabilityDigest,
502
+ risk_manifest_digest: riskManifestDigest,
503
+ receipt_requirements: { gate_ids: requireMergeGate ? [MERGE_GATE] : [] },
504
+ target: {
505
+ pr_number: Number(pr),
506
+ branch: binding.branch,
507
+ git_common_dir: binding.gitCommonDir,
508
+ ...(binding.url ? { url: binding.url } : {}),
509
+ },
510
+ },
511
+ );
512
+ return {
513
+ decisionId: packet.object_id,
514
+ packet,
515
+ occurredAt: timestamp,
516
+ actor,
517
+ sessionId: sessionId || null,
518
+ binding: { ...binding },
519
+ };
520
+ }
521
+
522
+ async function defaultRecordMergeDecision({ decision, mergeResult, pr, expectedHead, repository, projectRoot, buildBroker }) {
523
+ if (!decision?.packet || !decision?.binding) throw new Error('Merge decision evidence is unavailable.');
524
+ const recovered = mergeResult?.recovered === true;
525
+ const receipt = contractEnvelope(
526
+ 'forge.memory.run-receipt.v1',
527
+ uuidFromSeed(`merge-receipt:${decision.packet.content_hash}`),
528
+ decision.occurredAt,
529
+ decision.actor,
530
+ {
531
+ packet_hash: decision.packet.content_hash,
532
+ run_id: `merge:${repository}:${pr}:${expectedHead}`,
533
+ attempt_id: `merge:${expectedHead}`,
534
+ exact_head: expectedHead,
535
+ packet_revision: decision.packet.payload.packet_revision,
536
+ manifest_digest: decision.packet.payload.capability_manifest_digest,
537
+ workflow_config_revision: decision.packet.payload.workflow_config_revision,
538
+ status: 'PASS',
539
+ executor: { product_id: 'forge', mode: recovered ? 'guarded-exact-head-recovery' : 'guarded-exact-head' },
540
+ started_at: decision.occurredAt,
541
+ ended_at: decision.occurredAt,
542
+ evidence_refs: [{ kind: 'pr', repository, pr_number: Number(pr), head_sha: expectedHead }],
543
+ validation: { status: 'PASS', decision_id: decision.decisionId },
544
+ cleanup: { status: 'NOT_REQUIRED' },
545
+ mutations_attempted: ['pr.merged'],
546
+ mutations_authorized: ['pr.merged'],
547
+ },
548
+ );
549
+
550
+ let built;
551
+ const ownsDriver = !buildBroker;
552
+ try {
553
+ if (buildBroker) built = await buildBroker({ projectRoot });
554
+ else {
555
+ const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
556
+ const deps = await buildMigratedKernelIssueDeps({
557
+ projectRoot,
558
+ gitCommonDir: decision.binding.gitCommonDir,
559
+ });
560
+ built = { broker: deps.kernelBroker, driver: deps.kernelDriver };
561
+ }
562
+ if (!built?.broker) throw new Error('Kernel lifecycle provider is unavailable.');
563
+ const { createMemoryAuthorityProvider } = require('../../packages/memory');
564
+ const provider = createMemoryAuthorityProvider({ broker: built.broker });
565
+ await provider.recordPrLinkage({
566
+ phase: 'merged',
567
+ git_common_dir: decision.binding.gitCommonDir,
568
+ repo: repository,
569
+ number: Number(pr),
570
+ branch: decision.binding.branch,
571
+ ...(decision.binding.url ? { url: decision.binding.url } : {}),
572
+ occurred_at: decision.occurredAt,
573
+ work_packet: decision.packet,
574
+ run_receipt: receipt,
575
+ }, {
576
+ actor: decision.actor,
577
+ sessionId: decision.sessionId,
578
+ requireExactLifecycleOwnership: true,
579
+ });
580
+ return { receiptId: receipt.object_id, receiptHash: receipt.content_hash };
581
+ } finally {
582
+ if (ownsDriver && built?.driver && typeof built.driver.close === 'function') {
583
+ try { await built.driver.close(); } catch { /* terminal evidence outcome remains authoritative */ }
584
+ }
585
+ }
586
+ }
587
+
588
+ function strictCheckSuccess(check) {
589
+ if (!check || typeof check !== 'object') return false;
590
+ const hasStatus = Object.prototype.hasOwnProperty.call(check, 'status');
591
+ const hasConclusion = Object.prototype.hasOwnProperty.call(check, 'conclusion');
592
+ const hasState = Object.prototype.hasOwnProperty.call(check, 'state');
593
+ if (hasState) {
594
+ return !hasStatus && !hasConclusion && String(check.state || '').toUpperCase() === 'SUCCESS';
595
+ }
596
+ return hasStatus && hasConclusion
597
+ && String(check.status || '').toUpperCase() === 'COMPLETED'
598
+ && String(check.conclusion || '').toUpperCase() === 'SUCCESS';
599
+ }
600
+
601
+ /**
602
+ * Mandatory preflight accepts only terminal check-run conclusions that are safe
603
+ * to classify as optional. Required checks are still evaluated with
604
+ * strictCheckSuccess below, and status contexts never gain NEUTRAL/SKIPPED
605
+ * semantics because their only successful terminal state is SUCCESS.
606
+ */
607
+ function safeTerminalCheck(check) {
608
+ if (!check || typeof check !== 'object') return false;
609
+ if (Object.prototype.hasOwnProperty.call(check, 'state')) return strictCheckSuccess(check);
610
+ return Object.prototype.hasOwnProperty.call(check, 'status')
611
+ && Object.prototype.hasOwnProperty.call(check, 'conclusion')
612
+ && String(check.status || '').toUpperCase() === 'COMPLETED'
613
+ && SAFE_TERMINAL_CONCLUSIONS.has(String(check.conclusion || '').toUpperCase());
614
+ }
615
+
616
+ function malformedCheckObservation(check) {
617
+ if (!check || typeof check !== 'object') return true;
618
+ const name = check.name || check.context;
619
+ if (typeof name !== 'string' || !name.trim()) return true;
620
+ if (!Object.prototype.hasOwnProperty.call(check, 'appId')) return true;
621
+ const hasStatus = Object.prototype.hasOwnProperty.call(check, 'status');
622
+ const hasConclusion = Object.prototype.hasOwnProperty.call(check, 'conclusion');
623
+ const hasState = Object.prototype.hasOwnProperty.call(check, 'state');
624
+ const status = String(check.status || '').toUpperCase();
625
+ const conclusion = String(check.conclusion || '').toUpperCase();
626
+ const state = String(check.state || '').toUpperCase();
627
+ if (hasState) {
628
+ return hasStatus || hasConclusion || check.appId !== null
629
+ || typeof check.state !== 'string' || !STATUS_CONTEXT_STATES.has(state);
630
+ }
631
+ if (!hasStatus || !hasConclusion || !Number.isInteger(check.appId) || check.appId <= 0) return true;
632
+ if (hasStatus && (typeof check.status !== 'string' || !CHECK_RUN_STATUSES.has(status))) return true;
633
+ if (hasConclusion && check.conclusion !== null && typeof check.conclusion !== 'string') return true;
634
+ if (hasConclusion && !CHECK_RUN_CONCLUSIONS.has(conclusion)) return true;
635
+ if (hasStatus && status === 'COMPLETED' && !conclusion) return true;
636
+ if (hasStatus && status !== 'COMPLETED' && conclusion) return true;
637
+ return ['status', 'conclusion', 'state']
638
+ .some((key) => String(check[key] || '').toUpperCase() === 'UNREADABLE');
639
+ }
640
+
641
+ function normalizeRequiredEntry(entry) {
642
+ if (!entry || typeof entry.context !== 'string' || !entry.context.trim()) return null;
643
+ if (!Object.prototype.hasOwnProperty.call(entry, 'appId')) return null;
644
+ if (entry.appId !== null && (!Number.isInteger(entry.appId) || entry.appId <= 0)) return null;
645
+ return { context: entry.context, appId: entry.appId };
646
+ }
647
+
648
+ function normalizeRollupObservation(entry) {
649
+ if (!entry || typeof entry !== 'object') return null;
650
+ if (entry.__typename === 'StatusContext') {
651
+ return { name: entry.context, state: entry.state };
652
+ }
653
+ if (entry.__typename === 'CheckRun') {
654
+ return { name: entry.name, status: entry.status, conclusion: entry.conclusion };
655
+ }
656
+ return null;
657
+ }
658
+
659
+ function malformedRollupObservation(observation) {
660
+ if (!observation || typeof observation.name !== 'string' || !observation.name.trim()) return true;
661
+ const hasState = Object.prototype.hasOwnProperty.call(observation, 'state');
662
+ if (hasState) {
663
+ return Object.prototype.hasOwnProperty.call(observation, 'status')
664
+ || Object.prototype.hasOwnProperty.call(observation, 'conclusion')
665
+ || typeof observation.state !== 'string'
666
+ || !STATUS_CONTEXT_STATES.has(observation.state.toUpperCase());
667
+ }
668
+ const status = String(observation.status || '').toUpperCase();
669
+ const conclusion = String(observation.conclusion || '').toUpperCase();
670
+ if (typeof observation.status !== 'string' || !CHECK_RUN_STATUSES.has(status)) return true;
671
+ if (observation.conclusion !== null && typeof observation.conclusion !== 'string') return true;
672
+ if (!CHECK_RUN_CONCLUSIONS.has(conclusion)) return true;
673
+ if (status === 'COMPLETED' && !conclusion) return true;
674
+ return status !== 'COMPLETED' && Boolean(conclusion);
675
+ }
676
+
677
+ function evaluateProtectedRequiredChecks(context) {
678
+ if (!context || context.requiredCheckSource !== 'protection'
679
+ || !Array.isArray(context.requiredChecks) || !Array.isArray(context.checks)) {
680
+ return { allowed: false, reason: 'Protected required-check policy is unreadable or non-authoritative.' };
681
+ }
682
+ const required = context.requiredChecks.map(normalizeRequiredEntry);
683
+ if (required.some((entry) => !entry)) {
684
+ return { allowed: false, reason: 'Protected required-check policy contains malformed entries.' };
685
+ }
686
+ const policyApps = new Map();
687
+ for (const entry of required) {
688
+ if (!policyApps.has(entry.context)) policyApps.set(entry.context, new Set());
689
+ policyApps.get(entry.context).add(entry.appId === null ? '*' : String(entry.appId));
690
+ }
691
+ if ([...policyApps.values()].some((apps) => apps.size > 1)) {
692
+ return { allowed: false, reason: 'Protected required-check policy contains conflicting application identities.' };
693
+ }
694
+ if (context.checks.some(malformedCheckObservation)) {
695
+ return { allowed: false, reason: 'Check-run observation collection contains malformed entries.' };
696
+ }
697
+ const missing = [];
698
+ const nonSuccess = [];
699
+ for (const entry of required) {
700
+ const matching = context.checks.filter((check) => check
701
+ && (check.name || check.context) === entry.context
702
+ && (entry.appId === null || check.appId === entry.appId));
703
+ const label = entry.appId === null ? entry.context : `${entry.context}@app:${entry.appId}`;
704
+ if (matching.length === 0) missing.push(label);
705
+ else if (matching.some((check) => !strictCheckSuccess(check))) nonSuccess.push(label);
706
+ }
707
+ if (missing.length || nonSuccess.length) {
708
+ const parts = [];
709
+ if (missing.length) parts.push(`missing: ${missing.join(', ')}`);
710
+ if (nonSuccess.length) parts.push(`non-success: ${nonSuccess.join(', ')}`);
711
+ return {
712
+ allowed: false,
713
+ reason: `Protected required checks are not successful (${parts.join('; ')}).`,
714
+ details: { missing, nonSuccess },
715
+ };
716
+ }
717
+ return { allowed: true, details: { missing: [], nonSuccess: [] } };
718
+ }
719
+
720
+ function mandatoryContextError(context, expectedHead) {
721
+ const observedHead = normalizeFullHeadSha(context && context.headSha);
722
+ if (!observedHead || observedHead !== expectedHead) {
723
+ return 'PR head changed or could not be verified against --expect-head.';
724
+ }
725
+ if (context.state !== 'OPEN') return 'PR lifecycle state is unreadable or is not OPEN.';
726
+ if (context.isDraft !== false) return 'PR draft status is unreadable or the PR is still a draft.';
727
+ if (context.conflicting !== false) return 'PR conflict status is unreadable or conflicting.';
728
+ if (context.unresolvedThreads !== 0) return 'Review-thread state is unreadable or unresolved threads remain.';
729
+ if (context.reviewEvidenceReadable !== true || !Array.isArray(context.reviews)) {
730
+ return 'Review evidence is unreadable.';
731
+ }
732
+ for (const review of context.reviews) {
733
+ const state = review && typeof review.state === 'string' ? review.state.toUpperCase() : '';
734
+ const timestamps = review
735
+ ? [review.createdAt, review.updatedAt, review.submittedAt, review.activityAt]
736
+ : [];
737
+ if (!review || typeof review.id !== 'string' || !review.id
738
+ || typeof review.author !== 'string' || !review.author
739
+ || !REVIEW_ACTOR_TYPENAMES.has(review.authorTypename)
740
+ || !REVIEW_STATES.has(state)
741
+ || typeof review.commitOid !== 'string' || !FULL_HEAD_SHA.test(review.commitOid)
742
+ || typeof review.body !== 'string'
743
+ || timestamps.length !== 4
744
+ || timestamps.some((value) => typeof value !== 'string' || !Number.isFinite(Date.parse(value)))) {
745
+ return 'Review evidence contains malformed identity, state, timestamp, or commit-head data.';
746
+ }
747
+ if (state !== 'DISMISSED' && state !== 'COMMENTED' && review.commitOid.toLowerCase() !== expectedHead) {
748
+ return 'Latest active review evidence is stale for the expected PR head.';
749
+ }
750
+ if (state === 'CHANGES_REQUESTED' || state === 'PENDING') {
751
+ return `Latest review state ${state} does not authorize merging.`;
752
+ }
753
+ }
754
+ if (!Array.isArray(context.checks) || context.checks.some(malformedCheckObservation)
755
+ || context.checks.some((check) => !safeTerminalCheck(check))) {
756
+ return 'Every check-run and status observation must be complete and have a safe terminal conclusion (SUCCESS, NEUTRAL, or SKIPPED).';
757
+ }
758
+ if (Object.prototype.hasOwnProperty.call(context, 'providerObservations')) {
759
+ if (!Array.isArray(context.providerObservations)
760
+ || context.providerObservations.some(malformedRollupObservation)
761
+ || context.providerObservations.some((check) => !safeTerminalCheck(check))) {
762
+ return 'The complete provider rollup must contain only observations with safe terminal conclusions (SUCCESS, NEUTRAL, or SKIPPED).';
763
+ }
764
+ }
765
+ const protectedGate = evaluateProtectedRequiredChecks(context);
766
+ return protectedGate.allowed ? null : protectedGate.reason;
767
+ }
768
+
769
+ async function recoverMergedTerminal({
770
+ pr, parsed, root, config, prContext, ownershipActor, ownershipSessionId, deps, replayOnly = false,
771
+ }) {
772
+ if (normalizeFullHeadSha(prContext?.headSha) !== parsed.expectedHead) {
773
+ return { success: false, merged: true, error: 'Merged PR identity does not match the expected head and repository.' };
774
+ }
775
+ const verifyPrIssueBinding = deps.verifyPrIssueBinding || defaultVerifyPrIssueBinding;
776
+ const binding = await verifyPrIssueBinding({
777
+ issueId: parsed.issueId,
778
+ pr,
779
+ projectRoot: root,
780
+ prContext,
781
+ buildBroker: deps.buildPrBindingBroker,
782
+ allowRetired: true,
783
+ });
784
+ const repository = normalizeRepository(prContext?.repository) || normalizeRepository(binding?.repository);
785
+ if (binding?.bound !== true) {
786
+ if (replayOnly && binding?.missing === true) {
787
+ return {
788
+ success: false,
789
+ merged: true,
790
+ replayMissing: true,
791
+ error: 'Merged PR terminal evidence is not yet available for replay.',
792
+ };
793
+ }
794
+ return { success: false, merged: true, error: 'Merged PR is not authoritatively linked to the supplied Forge issue.' };
795
+ }
796
+ if (binding.terminalEvidence) {
797
+ if (!repository) {
798
+ return { success: false, merged: true, error: 'Merged PR repository identity is unavailable.' };
799
+ }
800
+ return {
801
+ success: true, merged: true, enabled: true, recovered: true,
802
+ ...binding.terminalEvidence,
803
+ };
804
+ }
805
+ if (replayOnly) {
806
+ return {
807
+ success: false,
808
+ merged: true,
809
+ replayMissing: true,
810
+ error: 'Merged PR terminal evidence is not yet available for replay.',
811
+ };
812
+ }
813
+ if (!repository) {
814
+ return { success: false, merged: true, error: 'Merged PR repository identity is unavailable.' };
815
+ }
816
+ const decisionAt = currentTimestamp(deps.now || Date.now);
817
+ const verifyMergeGate = deps.verifyMergeGate || defaultVerifyMergeGate;
818
+ const gateApproval = await verifyMergeGate({
819
+ issueId: parsed.issueId, projectRoot: root, actor: ownershipActor, now: decisionAt,
820
+ });
821
+ if (gateApproval !== true && gateApproval?.approved !== true && gateApproval?.disabled !== true) {
822
+ return { success: false, merged: true, error: `${MERGE_GATE} is not approved for this issue.` };
823
+ }
824
+ if (gateApproval?.disabled === true) {
825
+ return {
826
+ success: false,
827
+ merged: true,
828
+ error: 'The disabled merge gate cannot produce broker-verifiable terminal authority.',
829
+ };
830
+ }
831
+ const prepareMergeDecision = deps.prepareMergeDecision || defaultPrepareMergeDecision;
832
+ const decision = await prepareMergeDecision({
833
+ issueId: parsed.issueId,
834
+ pr,
835
+ expectedHead: parsed.expectedHead,
836
+ repository,
837
+ binding,
838
+ actor: ownershipActor,
839
+ sessionId: ownershipSessionId,
840
+ env: deps.env || process.env,
841
+ requireMergeGate: gateApproval?.disabled !== true,
842
+ config,
843
+ projectRoot: root,
844
+ now: decisionAt,
845
+ });
846
+ if (!decision || typeof decision.decisionId !== 'string' || !decision.decisionId) {
847
+ return { success: false, merged: true, error: 'Merge recovery decision evidence is unavailable.' };
848
+ }
849
+ const recordMergeDecision = deps.recordMergeDecision || defaultRecordMergeDecision;
850
+ try {
851
+ const terminal = await recordMergeDecision({
852
+ decision,
853
+ mergeResult: { merged: true, recovered: true },
854
+ pr,
855
+ expectedHead: parsed.expectedHead,
856
+ repository,
857
+ projectRoot: root,
858
+ buildBroker: deps.buildMergeBroker,
859
+ });
860
+ if (!terminal || typeof terminal.receiptId !== 'string' || !terminal.receiptId) {
861
+ throw new Error('terminal receipt evidence is unavailable');
862
+ }
863
+ return {
864
+ success: true, merged: true, enabled: true, recovered: true,
865
+ decisionId: decision.decisionId, ...terminal,
866
+ };
867
+ } catch (error) {
868
+ return {
869
+ success: false, merged: true, enabled: true, decisionId: decision.decisionId,
870
+ error: `Merged PR terminal linkage recovery failed: ${error.message}`,
871
+ };
872
+ }
873
+ }
46
874
 
47
875
  /** Default `gh` runner. Only reached by the default fetch/merge seams (never in unit tests). */
48
876
  function defaultGh(args, options = {}) {
@@ -50,9 +878,9 @@ function defaultGh(args, options = {}) {
50
878
  }
51
879
 
52
880
  /** Parse `gh ... --json` output, returning `null` on any failure (callers fail closed). */
53
- function ghJson(gh, args) {
881
+ function ghJson(gh, args, options) {
54
882
  try {
55
- return JSON.parse(gh(args) || '{}');
883
+ return JSON.parse(gh(args, options) || '{}');
56
884
  } catch (_err) {
57
885
  return null;
58
886
  }
@@ -63,12 +891,9 @@ function ghJson(gh, args) {
63
891
  * is not a valid `gh pr view --json` field, so this needs a dedicated query.
64
892
  * Returns `undefined` on any failure so `threads_resolved` fails closed.
65
893
  */
66
- function fetchUnresolvedThreadCount(gh, pr) {
894
+ function fetchUnresolvedThreadCount(gh, { owner, repo, pr }) {
67
895
  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;
896
+ if (!owner || !repo) return undefined;
72
897
  // Paginate through ALL review threads — a PR can have >100, and the ones on
73
898
  // later pages could be the unresolved/newest ones. Stopping at page 1 would
74
899
  // both miss them and make a large PR un-mergeable. Loop on the GraphQL cursor
@@ -79,15 +904,22 @@ function fetchUnresolvedThreadCount(gh, pr) {
79
904
  let count = 0;
80
905
  for (let page = 0; page < 100; page += 1) { // 100-page cap = 10k threads backstop
81
906
  const args = ['api', 'graphql', '-f', `query=${query}`,
82
- '-F', `o=${owner}`, '-F', `n=${name}`, '-F', `pr=${Number(pr)}`];
907
+ '-F', `o=${owner}`, '-F', `n=${repo}`, '-F', `pr=${Number(pr)}`];
83
908
  if (after) args.push('-F', `after=${after}`);
84
909
  const data = JSON.parse(gh(args) || '{}');
910
+ if (Object.prototype.hasOwnProperty.call(data, 'errors')
911
+ && (!Array.isArray(data.errors) || data.errors.length > 0)) return undefined;
85
912
  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
913
+ if (!threads || !Array.isArray(threads.nodes) || !threads.pageInfo
914
+ || typeof threads.pageInfo.hasNextPage !== 'boolean'
915
+ || (threads.pageInfo.endCursor !== null && typeof threads.pageInfo.endCursor !== 'string')
916
+ || threads.nodes.some((thread) => !thread || typeof thread.isResolved !== 'boolean'
917
+ || typeof thread.isOutdated !== 'boolean')) return undefined;
918
+ count += threads.nodes.filter((thread) => thread.isResolved === false && thread.isOutdated === false).length;
919
+ if (!threads.pageInfo.hasNextPage) return count;
920
+ const nextCursor = threads.pageInfo.endCursor;
921
+ if (typeof nextCursor !== 'string' || !nextCursor || nextCursor === after) return undefined;
922
+ after = nextCursor;
91
923
  }
92
924
  return undefined; // exceeded the page cap → fail closed rather than undercount
93
925
  } catch (_err) {
@@ -95,6 +927,47 @@ function fetchUnresolvedThreadCount(gh, pr) {
95
927
  }
96
928
  }
97
929
 
930
+ function fetchCheckRunObservations(gh, { owner, repo, head }) {
931
+ try {
932
+ const pages = JSON.parse(gh([
933
+ 'api', '--paginate', '--slurp',
934
+ `repos/${owner}/${repo}/commits/${head}/check-runs?filter=latest&per_page=100`,
935
+ ]) || 'null');
936
+ if (!Array.isArray(pages) || pages.length === 0
937
+ || pages.some((page) => !page || !Array.isArray(page.check_runs)
938
+ || !Number.isInteger(page.total_count) || page.total_count < 0)) return null;
939
+ const runs = pages.flatMap((page) => page.check_runs);
940
+ if (pages.some((page) => page.total_count !== pages[0].total_count)
941
+ || pages[0].total_count !== runs.length) return null;
942
+ if (runs.some((run) => {
943
+ const name = run && typeof run.name === 'string' && run.name.trim() ? run.name : null;
944
+ const appId = run && run.app && Number.isInteger(run.app.id) && run.app.id > 0 ? run.app.id : null;
945
+ const headSha = normalizeFullHeadSha(run && run.head_sha);
946
+ const status = String(run && run.status || '').toUpperCase();
947
+ const conclusion = String(run && run.conclusion || '').toUpperCase();
948
+ return !run || !Number.isInteger(run.id) || run.id <= 0 || !name || !appId || headSha !== head
949
+ || !CHECK_RUN_STATUSES.has(status)
950
+ || !Object.prototype.hasOwnProperty.call(run, 'conclusion')
951
+ || (run.conclusion !== null && typeof run.conclusion !== 'string')
952
+ || !CHECK_RUN_CONCLUSIONS.has(conclusion)
953
+ || (status === 'COMPLETED' ? !conclusion : Boolean(conclusion));
954
+ })) return null;
955
+ return runs.map((run) => {
956
+ const name = run.name;
957
+ const appId = run.app.id;
958
+ return {
959
+ id: run.id,
960
+ name,
961
+ appId,
962
+ status: String(run.status).toUpperCase(),
963
+ conclusion: String(run.conclusion || '').toUpperCase(),
964
+ };
965
+ });
966
+ } catch (_err) {
967
+ return null;
968
+ }
969
+ }
970
+
98
971
  /**
99
972
  * Default PR-context fetcher (the network seam). Assembles the shape consumed
100
973
  * by `evaluateMergeRules` from `gh`. Anything it cannot read is left absent so
@@ -103,27 +976,39 @@ function fetchUnresolvedThreadCount(gh, pr) {
103
976
  *
104
977
  * @returns {object} prContext
105
978
  */
106
- function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
107
- const view = ghJson(gh, ['pr', 'view', String(pr), '--json',
108
- 'number,state,isDraft,mergeable,mergeStateStatus,statusCheckRollup,reviews,comments,updatedAt']) || {};
979
+ async function defaultFetchPrContext({ pr, projectRoot, gh = defaultGh, now = Date.now() }) {
980
+ const repoOptions = typeof projectRoot === 'string' && projectRoot ? { cwd: projectRoot } : undefined;
981
+ const repoIdentity = ghJson(gh, ['repo', 'view', '--json', 'owner,name,isFork,parent'], repoOptions);
982
+ const currentRepository = normalizeRepository(repoIdentity?.owner?.login && repoIdentity?.name
983
+ ? `${repoIdentity.owner.login}/${repoIdentity.name}`
984
+ : null);
985
+ const repository = repoIdentity?.isFork === true
986
+ ? normalizeRepository(repoIdentity?.parent?.nameWithOwner)
987
+ : (repoIdentity?.isFork === false ? currentRepository : null);
988
+ const [owner, repo] = repository ? repository.split('/') : [null, null];
989
+ const view = repository
990
+ ? (ghJson(gh, ['pr', 'view', String(pr), '--repo', repository, '--json',
991
+ 'number,headRefOid,baseRefName,state,isDraft,mergeable,mergeStateStatus,statusCheckRollup,comments,updatedAt']) || {})
992
+ : {};
109
993
 
110
994
  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
995
 
116
996
  const comments = Array.isArray(view.comments)
117
997
  ? view.comments.map((c) => ({
118
998
  author: (c.author && c.author.login) || '',
119
- at: c.createdAt || c.submittedAt || '',
999
+ at: [c.createdAt, c.updatedAt, c.submittedAt]
1000
+ .filter(Boolean)
1001
+ .sort((left, right) => Date.parse(right) - Date.parse(left))[0] || '',
120
1002
  }))
121
1003
  : [];
122
1004
 
123
- const reviews = Array.isArray(view.reviews) ? view.reviews : [];
1005
+ const adapter = owner && repo
1006
+ ? new PrStateAdapter({ gh: (_cmd, adapterArgs) => gh(adapterArgs) })
1007
+ : null;
1008
+ const reviews = adapter ? await adapter.readReviews({ owner, repo, pr }) : [];
124
1009
  const approvals = reviews
125
1010
  .filter((r) => String(r.state).toUpperCase() === 'APPROVED')
126
- .map((r) => ({ author: (r.author && r.author.login) || '' }));
1011
+ .map((r) => ({ author: typeof r.author === 'string' ? r.author : '' }));
127
1012
 
128
1013
  // Derive from GitHub's mergeStateStatus / mergeable. Only a known set maps to
129
1014
  // a definite answer; anything else stays undefined so the dependent rule fails
@@ -146,29 +1031,67 @@ function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
146
1031
 
147
1032
  const stamps = [
148
1033
  ...comments.map((c) => c.at),
149
- ...reviews.map((r) => r.submittedAt || r.createdAt || ''),
1034
+ ...reviews.flatMap((r) => [r.activityAt, r.createdAt, r.updatedAt, r.submittedAt]),
150
1035
  view.updatedAt || '',
151
1036
  ].map((s) => Date.parse(s)).filter((n) => !Number.isNaN(n));
152
1037
  const lastActivityAt = stamps.length ? Math.max(...stamps) : undefined;
153
1038
 
1039
+ const base = typeof view.baseRefName === 'string' ? view.baseRefName : null;
1040
+ const headSha = normalizeFullHeadSha(view.headRefOid);
1041
+ let requiredChecks = null;
1042
+ let requiredCheckSource = null;
1043
+ let checks = null;
1044
+ const providerObservations = rollup ? rollup.map(normalizeRollupObservation) : null;
1045
+ if (adapter && base) {
1046
+ requiredChecks = await adapter.readRequiredCheckPolicy({ owner, repo, base });
1047
+ requiredCheckSource = adapter.lastRequiredSource;
1048
+ const checkRuns = headSha ? fetchCheckRunObservations(gh, { owner, repo, head: headSha }) : null;
1049
+ if (rollup && checkRuns && providerObservations.every((entry) => entry !== null)) {
1050
+ const statuses = rollup
1051
+ .filter((entry) => entry && entry.__typename === 'StatusContext')
1052
+ .map((entry) => ({
1053
+ name: typeof entry.context === 'string' ? entry.context : '',
1054
+ appId: null,
1055
+ state: String(entry.state || '').toUpperCase(),
1056
+ }));
1057
+ checks = [...checkRuns, ...statuses];
1058
+ }
1059
+ }
1060
+
154
1061
  return {
1062
+ number: Number.isInteger(view.number) ? view.number : null,
1063
+ repository,
1064
+ headSha: view.headRefOid || null,
155
1065
  checks,
156
- requiredChecksKnown: rollup !== null,
157
- unresolvedThreads: fetchUnresolvedThreadCount(gh, pr),
1066
+ providerObservations,
1067
+ requiredChecks,
1068
+ requiredCheckSource,
1069
+ requiredChecksKnown: requiredCheckSource === 'protection' && Array.isArray(requiredChecks),
1070
+ unresolvedThreads: fetchUnresolvedThreadCount(gh, { owner, repo, pr }),
158
1071
  behindBase,
159
1072
  conflicting,
160
1073
  isDraft,
161
1074
  state,
162
1075
  approvals,
1076
+ reviews,
1077
+ reviewEvidenceReadable: adapter !== null,
163
1078
  comments,
164
1079
  lastActivityAt,
165
1080
  now,
166
1081
  };
167
1082
  }
168
1083
 
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']);
1084
+ /** Default merge action (squash), atomically bound to the reviewed remote head. */
1085
+ function defaultMergePr({ pr, expectedHead, repository, gh = defaultGh }) {
1086
+ const head = normalizeFullHeadSha(expectedHead);
1087
+ const repo = normalizeRepository(repository);
1088
+ if (!head) throw new Error('A full 40-character expected PR head SHA is required.');
1089
+ if (!normalizePrNumber(String(pr)) || !repo) throw new Error('A canonical PR number and repository are required.');
1090
+ gh(['pr', 'merge', String(pr), '--repo', repo, '--squash', '--match-head-commit', head]);
1091
+ const confirmed = ghJson(gh, ['pr', 'view', String(pr), '--repo', repo, '--json', 'state,headRefOid']);
1092
+ if (confirmed?.state !== 'MERGED' || normalizeFullHeadSha(confirmed.headRefOid) !== head) {
1093
+ return { merged: false, reason: 'provider merge is not confirmed for the exact reviewed head' };
1094
+ }
172
1095
  return { merged: true, method: 'squash' };
173
1096
  }
174
1097
 
@@ -183,18 +1106,81 @@ function defaultMergePr({ pr, gh = defaultGh }) {
183
1106
  */
184
1107
  async function handler(args, _flags, projectRoot, deps = {}) {
185
1108
  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];
1109
+ const parsed = parseMergeArgs(stripGlobalFlags(argv));
1110
+ const pr = parsed.pr;
189
1111
  const root = projectRoot || process.cwd();
190
1112
 
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>' };
1113
+ if (!parsed.auto || !pr) {
1114
+ return {
1115
+ success: false,
1116
+ merged: false,
1117
+ error: 'Usage: forge merge --auto <pr> --expect-head <40-char-sha> --issue <issue-id>',
1118
+ };
196
1119
  }
197
1120
 
1121
+ const verifyIssueOwnership = deps.verifyIssueOwnership || defaultVerifyIssueOwnership;
1122
+ const ownershipEnv = deps.env || process.env;
1123
+ const ownershipActor = resolveOwnershipActor(ownershipEnv);
1124
+ const ownershipSessionId = typeof ownershipEnv.FORGE_SESSION_ID === 'string'
1125
+ && ownershipEnv.FORGE_SESSION_ID.trim()
1126
+ ? ownershipEnv.FORGE_SESSION_ID.trim()
1127
+ : null;
1128
+ const ownershipInput = {
1129
+ issueId: parsed.issueId,
1130
+ projectRoot: root,
1131
+ actor: ownershipActor,
1132
+ env: { ...ownershipEnv },
1133
+ };
1134
+ const fetchPrContext = deps.fetchPrContext || defaultFetchPrContext;
1135
+ const mergePr = deps.mergePr || defaultMergePr;
1136
+ const verifyPrIssueBinding = deps.verifyPrIssueBinding || defaultVerifyPrIssueBinding;
1137
+ const gh = deps.githubContext?.bound ? deps.githubContext.runGh : (deps.gh || defaultGh);
1138
+ const exactMergeIdentity = !parsed.error && parsed.expectedHead && parsed.issueId;
1139
+ let prContext; let prState;
1140
+ const replayBeforeProvider = async () => {
1141
+ const resolveLocalRepository = deps.resolveLocalRepository || defaultResolveLocalRepository;
1142
+ let localRepository;
1143
+ try {
1144
+ localRepository = normalizeRepository(await resolveLocalRepository({ projectRoot: root }));
1145
+ } catch {
1146
+ localRepository = null;
1147
+ }
1148
+ if (!localRepository) {
1149
+ return { failure: {
1150
+ success: false,
1151
+ merged: false,
1152
+ error: 'Local repository identity is unavailable for terminal evidence replay.',
1153
+ } };
1154
+ }
1155
+ try {
1156
+ const replay = await recoverMergedTerminal({
1157
+ pr,
1158
+ parsed,
1159
+ root,
1160
+ config: {},
1161
+ prContext: {
1162
+ number: Number(pr),
1163
+ headSha: parsed.expectedHead,
1164
+ repository: localRepository,
1165
+ },
1166
+ ownershipActor,
1167
+ ownershipSessionId,
1168
+ deps,
1169
+ replayOnly: true,
1170
+ });
1171
+ if (replay.replayMissing !== true) {
1172
+ return replay.success ? { replay } : { failure: { ...replay, merged: false } };
1173
+ }
1174
+ } catch (error) {
1175
+ return { failure: {
1176
+ success: false,
1177
+ merged: false,
1178
+ error: `Merged PR terminal evidence replay failed: ${error.message}`,
1179
+ } };
1180
+ }
1181
+ return {};
1182
+ };
1183
+
198
1184
  const loadConfig = deps.loadConfig || loadRawConfig;
199
1185
  let config;
200
1186
  try {
@@ -217,7 +1203,6 @@ async function handler(args, _flags, projectRoot, deps = {}) {
217
1203
  process.stdout.write(`${reason}\n`);
218
1204
  return { success: true, merged: false, enabled: false, reason };
219
1205
  }
220
-
221
1206
  // Opted in but no rules → refuse (fail-closed): an empty ruleset is vacuously
222
1207
  // "allowed", which would merge unconditionally. Treat that as misconfiguration.
223
1208
  if (rules.length === 0) {
@@ -226,26 +1211,92 @@ async function handler(args, _flags, projectRoot, deps = {}) {
226
1211
  return { success: false, merged: false, enabled: true, reason };
227
1212
  }
228
1213
 
229
- const fetchPrContext = deps.fetchPrContext || defaultFetchPrContext;
230
- const mergePr = deps.mergePr || defaultMergePr;
231
- const gh = deps.gh || defaultGh;
1214
+ if (!exactMergeIdentity) {
1215
+ const reason = parsed.error
1216
+ || 'Enabled auto-merge requires --expect-head <full 40-character SHA> and --issue <Forge issue ID>.';
1217
+ return { success: false, merged: false, enabled: true, error: reason };
1218
+ }
1219
+
1220
+ let ownership;
1221
+ try {
1222
+ ownership = await verifyIssueOwnership(ownershipInput);
1223
+ } catch (err) {
1224
+ return { success: false, merged: false, error: `Failed to verify issue ownership: ${err.message}` };
1225
+ }
1226
+ if (!ownership || ownership.owned !== true || ownership.expired !== false
1227
+ || typeof ownership.actor !== 'string' || ownership.actor !== ownershipActor
1228
+ || typeof ownership.claimedBy !== 'string' || ownership.claimedBy !== ownershipActor
1229
+ || ownership.sessionId !== ownershipSessionId) {
1230
+ return {
1231
+ success: false,
1232
+ merged: false,
1233
+ error: `Active Kernel ownership claim with the exact session is required for issue ${parsed.issueId}; refusing to merge.`,
1234
+ };
1235
+ }
1236
+ const preProvider = await replayBeforeProvider();
1237
+ if (preProvider.failure) return preProvider.failure;
1238
+ if (preProvider.replay) return preProvider.replay;
232
1239
 
233
- let prContext;
234
1240
  try {
235
1241
  prContext = await fetchPrContext({ pr, projectRoot: root, gh, now: deps.now || Date.now() });
236
1242
  } catch (err) {
237
1243
  return { success: false, merged: false, error: `Failed to fetch PR context: ${err.message}` };
238
1244
  }
1245
+ prState = prContext?.state ? String(prContext.state).toUpperCase() : '';
1246
+ if (prState === 'MERGED') {
1247
+ try {
1248
+ const replay = await recoverMergedTerminal({
1249
+ pr, parsed, root, config: {}, prContext, ownershipActor, ownershipSessionId, deps, replayOnly: true,
1250
+ });
1251
+ if (replay.replayMissing !== true) return replay;
1252
+ } catch (error) {
1253
+ return { success: false, merged: true, error: `Merged PR terminal evidence replay failed: ${error.message}` };
1254
+ }
1255
+ }
239
1256
 
240
- // Pre-flight: a PR that is already merged or closed is terminal. Re-running the
241
- // command must be an idempotent NO-OP — never an error and never a second merge
242
- // attempt. (An absent/unknown state falls through to the fail-closed rules.)
243
- const prState = prContext && prContext.state ? String(prContext.state).toUpperCase() : '';
244
- if (prState && prState !== 'OPEN') {
1257
+ if (prState === 'MERGED') {
1258
+ if (!ownershipSessionId) {
1259
+ return { success: false, merged: true, error: 'An exact FORGE_SESSION_ID session identity is required to recover terminal merge evidence.' };
1260
+ }
1261
+ try {
1262
+ return await recoverMergedTerminal({
1263
+ pr, parsed, root, config, prContext, ownershipActor, ownershipSessionId, deps,
1264
+ });
1265
+ } catch (error) {
1266
+ return { success: false, merged: true, error: `Merged PR terminal linkage recovery failed: ${error.message}` };
1267
+ }
1268
+ }
1269
+ if (prState === 'CLOSED') {
245
1270
  const reason = `PR #${pr} is ${prState} (not OPEN) — nothing to merge. No action taken.`;
246
1271
  process.stdout.write(`${reason}\n`);
247
1272
  return { success: true, merged: false, enabled: true, state: prState, reason };
248
1273
  }
1274
+ if (!ownershipSessionId) {
1275
+ return { success: false, merged: false, error: 'An exact FORGE_SESSION_ID session identity is required before merge.' };
1276
+ }
1277
+
1278
+ const mandatoryError = mandatoryContextError(prContext, parsed.expectedHead);
1279
+ if (mandatoryError) return { success: false, merged: false, error: mandatoryError };
1280
+ const leasedRepository = normalizeRepository(prContext && prContext.repository);
1281
+ if (!leasedRepository) {
1282
+ return { success: false, merged: false, error: 'PR repository identity is unreadable; refusing to merge.' };
1283
+ }
1284
+
1285
+ let binding;
1286
+ try {
1287
+ binding = await verifyPrIssueBinding({
1288
+ issueId: parsed.issueId,
1289
+ pr,
1290
+ projectRoot: root,
1291
+ prContext,
1292
+ buildBroker: deps.buildPrBindingBroker,
1293
+ });
1294
+ } catch (err) {
1295
+ return { success: false, merged: false, error: `Failed to verify PR issue binding: ${err.message}` };
1296
+ }
1297
+ if (!binding || binding.bound !== true) {
1298
+ return { success: false, merged: false, error: 'PR is not authoritatively linked to the supplied Forge issue.' };
1299
+ }
249
1300
 
250
1301
  const { allowed, unmet } = evaluateMergeRules(prContext, rules);
251
1302
 
@@ -258,7 +1309,7 @@ async function handler(args, _flags, projectRoot, deps = {}) {
258
1309
  }
259
1310
 
260
1311
  // 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.
1312
+ // new comment resets configured settle_min, a required check regresses, or a thread opens.
262
1313
  // Re-pull LIVE data and re-evaluate immediately before merging so our custom
263
1314
  // rules (which GitHub's server-side branch protection does NOT enforce) are
264
1315
  // honored against the freshest possible state, never a stale snapshot.
@@ -271,11 +1322,26 @@ async function handler(args, _flags, projectRoot, deps = {}) {
271
1322
  // Re-apply the terminal-state guard on the FRESH context: the PR may have been
272
1323
  // merged or closed between the first fetch and now. Never merge a terminal PR.
273
1324
  const freshState = freshContext && freshContext.state ? String(freshContext.state).toUpperCase() : '';
274
- if (freshState && freshState !== 'OPEN') {
1325
+ if (freshState === 'MERGED') {
1326
+ try {
1327
+ return await recoverMergedTerminal({
1328
+ pr, parsed, root, config, prContext: freshContext, ownershipActor, ownershipSessionId, deps,
1329
+ });
1330
+ } catch (error) {
1331
+ return { success: false, merged: true, error: `Merged PR terminal linkage recovery failed: ${error.message}` };
1332
+ }
1333
+ }
1334
+ if (freshState === 'CLOSED') {
275
1335
  const reason = `PR #${pr} became ${freshState} (not OPEN) before merge — nothing to merge. No action taken.`;
276
1336
  process.stdout.write(`${reason}\n`);
277
1337
  return { success: true, merged: false, enabled: true, state: freshState, reason };
278
1338
  }
1339
+ const freshMandatoryError = mandatoryContextError(freshContext, parsed.expectedHead);
1340
+ if (freshMandatoryError) return { success: false, merged: false, error: freshMandatoryError };
1341
+ const freshRepository = normalizeRepository(freshContext && freshContext.repository);
1342
+ if (!freshRepository || freshRepository !== leasedRepository) {
1343
+ return { success: false, merged: false, error: 'PR repository identity changed or became unreadable before merge.' };
1344
+ }
279
1345
  const recheck = evaluateMergeRules(freshContext, rules);
280
1346
  if (!recheck.allowed) {
281
1347
  process.stdout.write(`Auto-merge ABORTED for PR #${pr} — state changed since first check; ${recheck.unmet.length} rule(s) now unmet:\n`);
@@ -285,28 +1351,169 @@ async function handler(args, _flags, projectRoot, deps = {}) {
285
1351
  return { success: true, merged: false, enabled: true, allowed: false, unmet: recheck.unmet, reason: 'PR state changed before merge (live re-check failed)' };
286
1352
  }
287
1353
 
1354
+ let freshBinding;
1355
+ try {
1356
+ freshBinding = await verifyPrIssueBinding({
1357
+ issueId: parsed.issueId,
1358
+ pr,
1359
+ projectRoot: root,
1360
+ prContext: freshContext,
1361
+ buildBroker: deps.buildPrBindingBroker,
1362
+ });
1363
+ } catch (err) {
1364
+ return { success: false, merged: false, error: `Failed to re-verify PR issue binding: ${err.message}` };
1365
+ }
1366
+ if (!freshBinding || freshBinding.bound !== true) {
1367
+ return { success: false, merged: false, error: 'PR issue linkage changed or is unreadable before merge.' };
1368
+ }
1369
+
1370
+ let finalOwnership;
1371
+ try {
1372
+ finalOwnership = await verifyIssueOwnership(ownershipInput);
1373
+ } catch (err) {
1374
+ return { success: false, merged: false, error: `Failed to re-verify issue ownership before merge: ${err.message}` };
1375
+ }
1376
+ if (!finalOwnership || finalOwnership.owned !== true || finalOwnership.expired !== false
1377
+ || typeof finalOwnership.actor !== 'string' || finalOwnership.actor !== ownershipActor
1378
+ || typeof finalOwnership.claimedBy !== 'string' || finalOwnership.claimedBy !== ownershipActor
1379
+ || finalOwnership.sessionId !== ownershipSessionId) {
1380
+ return { success: false, merged: false, error: 'Kernel ownership changed or expired before merge; refusing to merge.' };
1381
+ }
1382
+
1383
+ const decisionAt = currentTimestamp(deps.now || Date.now);
1384
+ const verifyMergeGate = deps.verifyMergeGate || defaultVerifyMergeGate;
1385
+ let gateApproval;
288
1386
  try {
289
- const mergeResult = await mergePr({ pr, projectRoot: root, gh });
290
- process.stdout.write(`All ${rules.length} merge rule(s) passed — merged PR #${pr}.\n`);
1387
+ gateApproval = await verifyMergeGate({
1388
+ issueId: parsed.issueId,
1389
+ projectRoot: root,
1390
+ actor: ownershipActor,
1391
+ now: decisionAt,
1392
+ });
1393
+ } catch (err) {
1394
+ return { success: false, merged: false, error: `Failed to verify ${MERGE_GATE}: ${err.message}` };
1395
+ }
1396
+ if (gateApproval !== true && gateApproval?.approved !== true && gateApproval?.disabled !== true) {
1397
+ return { success: false, merged: false, error: `${MERGE_GATE} is not approved for this issue.` };
1398
+ }
1399
+ if (gateApproval?.disabled === true) {
1400
+ return {
1401
+ success: false,
1402
+ merged: false,
1403
+ error: 'The disabled merge gate cannot produce broker-verifiable terminal authority; refusing to mutate GitHub.',
1404
+ };
1405
+ }
1406
+
1407
+ const prepareMergeDecision = deps.prepareMergeDecision || defaultPrepareMergeDecision;
1408
+ let decision;
1409
+ try {
1410
+ decision = await prepareMergeDecision({
1411
+ issueId: parsed.issueId,
1412
+ pr,
1413
+ expectedHead: parsed.expectedHead,
1414
+ repository: leasedRepository,
1415
+ binding: freshBinding,
1416
+ actor: ownershipActor,
1417
+ sessionId: ownershipSessionId,
1418
+ env: ownershipEnv,
1419
+ requireMergeGate: gateApproval?.disabled !== true,
1420
+ config,
1421
+ projectRoot: root,
1422
+ now: decisionAt,
1423
+ });
1424
+ } catch (err) {
1425
+ return { success: false, merged: false, error: `Failed to prepare merge decision: ${err.message}` };
1426
+ }
1427
+ if (!decision || typeof decision.decisionId !== 'string' || !decision.decisionId) {
1428
+ return { success: false, merged: false, error: 'Merge decision evidence is unavailable.' };
1429
+ }
1430
+
1431
+ let mergeResult;
1432
+ try {
1433
+ mergeResult = await mergePr({
1434
+ pr,
1435
+ expectedHead: parsed.expectedHead,
1436
+ repository: leasedRepository,
1437
+ issueId: parsed.issueId,
1438
+ projectRoot: root,
1439
+ gh,
1440
+ });
1441
+ } catch (err) {
1442
+ return { success: false, merged: false, decisionId: decision.decisionId, error: `Merge failed: ${err.message}` };
1443
+ }
1444
+ if (mergeResult?.merged !== true) {
291
1445
  return {
292
- success: true,
1446
+ success: false,
1447
+ merged: false,
1448
+ decisionId: decision.decisionId,
1449
+ error: `Merge failed: ${mergeResult?.reason || 'provider did not confirm the merge'}`,
1450
+ };
1451
+ }
1452
+
1453
+ const recordMergeDecision = deps.recordMergeDecision || defaultRecordMergeDecision;
1454
+ let terminal;
1455
+ try {
1456
+ terminal = await recordMergeDecision({
1457
+ decision,
1458
+ mergeResult,
1459
+ pr,
1460
+ expectedHead: parsed.expectedHead,
1461
+ repository: leasedRepository,
1462
+ projectRoot: root,
1463
+ buildBroker: deps.buildMergeBroker,
1464
+ });
1465
+ } catch (err) {
1466
+ return {
1467
+ success: false,
293
1468
  merged: true,
294
1469
  enabled: true,
295
1470
  allowed: true,
296
- reason: 'all merge rules passed',
297
- ...(mergeResult && typeof mergeResult === 'object' ? mergeResult : {}),
1471
+ decisionId: decision.decisionId,
1472
+ error: `Merge succeeded but terminal linkage failed: ${err.message}`,
298
1473
  };
299
- } catch (err) {
300
- return { success: false, merged: false, error: `Merge failed: ${err.message}` };
301
1474
  }
1475
+ if (!terminal || typeof terminal.receiptId !== 'string' || !terminal.receiptId) {
1476
+ return {
1477
+ success: false,
1478
+ merged: true,
1479
+ enabled: true,
1480
+ allowed: true,
1481
+ decisionId: decision.decisionId,
1482
+ error: 'Merge succeeded but terminal receipt evidence is unavailable.',
1483
+ };
1484
+ }
1485
+
1486
+ process.stdout.write(`All ${rules.length} merge rule(s) passed — merged PR #${pr}.\n`);
1487
+ const providerFields = typeof mergeResult?.method === 'string' ? { method: mergeResult.method } : {};
1488
+ return {
1489
+ ...providerFields,
1490
+ success: true,
1491
+ merged: true,
1492
+ enabled: true,
1493
+ allowed: true,
1494
+ reason: 'all merge rules passed',
1495
+ decisionId: decision.decisionId,
1496
+ ...terminal,
1497
+ };
302
1498
  }
303
1499
 
304
1500
  module.exports = {
305
1501
  name: 'merge',
1502
+ githubAuth: true,
306
1503
  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>',
1504
+ usage: 'Usage: forge merge --auto <pr> --expect-head <40-char-sha> --issue <issue-id>',
308
1505
  handler,
309
1506
  // Exported seams for testing / reuse.
310
1507
  defaultFetchPrContext,
1508
+ defaultResolveLocalRepository,
311
1509
  defaultMergePr,
1510
+ defaultPrepareMergeDecision,
1511
+ defaultRecordMergeDecision,
1512
+ defaultVerifyMergeGate,
1513
+ defaultVerifyPrIssueBinding,
1514
+ defaultVerifyIssueOwnership,
1515
+ evaluateProtectedRequiredChecks,
1516
+ normalizeFullHeadSha,
1517
+ normalizePrNumber,
1518
+ parseMergeArgs,
312
1519
  };