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

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 (268) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +48 -0
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/bin/forge.js +12 -1
  6. package/docs/guides/MIGRATION.md +3 -3
  7. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  8. package/docs/reference/INSTALL.md +4 -0
  9. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  10. package/docs/reference/RELEASE.md +4 -4
  11. package/docs/reference/github-accounts.md +134 -0
  12. package/docs/reference/shepherd.md +63 -13
  13. package/lib/adapters/pr-state-adapter.js +15 -2
  14. package/lib/base-remote.js +138 -0
  15. package/lib/beta5-compatibility-evidence.js +1093 -0
  16. package/lib/bun-lockfile-proof.js +413 -0
  17. package/lib/bun-workflow-pins.js +461 -0
  18. package/lib/capabilities/index.js +9 -0
  19. package/lib/capabilities/model.js +141 -0
  20. package/lib/capabilities/probes.js +347 -0
  21. package/lib/codex-skills.js +2 -2
  22. package/lib/commands/_manifest.js +1 -0
  23. package/lib/commands/_registry.js +48 -18
  24. package/lib/commands/clean.js +57 -1
  25. package/lib/commands/doctor.js +37 -6
  26. package/lib/commands/gate.js +197 -27
  27. package/lib/commands/github.js +215 -0
  28. package/lib/commands/hooks.js +54 -6
  29. package/lib/commands/memory.js +66 -2
  30. package/lib/commands/merge.js +720 -73
  31. package/lib/commands/plan.js +33 -2
  32. package/lib/commands/pr.js +2 -0
  33. package/lib/commands/preflight.js +10 -2
  34. package/lib/commands/push.js +120 -9
  35. package/lib/commands/recall.js +95 -61
  36. package/lib/commands/release.js +23 -2
  37. package/lib/commands/remember.js +28 -4
  38. package/lib/commands/serve.js +26 -9
  39. package/lib/commands/setup.js +132 -4
  40. package/lib/commands/shepherd.js +578 -72
  41. package/lib/commands/ship.js +15 -69
  42. package/lib/commands/skill.js +8 -0
  43. package/lib/commands/team.js +47 -8
  44. package/lib/commands/test.js +163 -4
  45. package/lib/commands/validate.js +78 -22
  46. package/lib/commands/worktree.js +155 -19
  47. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  48. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  49. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  50. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  51. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  52. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  53. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  54. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  55. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  56. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  57. package/lib/forge-issues.js +78 -0
  58. package/lib/gate-events.js +98 -10
  59. package/lib/github-context.js +308 -0
  60. package/lib/global-flags.js +1 -0
  61. package/lib/hook-renderer.js +29 -1
  62. package/lib/issue-render.js +19 -0
  63. package/lib/kernel/broker.js +723 -31
  64. package/lib/kernel/claim-reconciler.js +238 -0
  65. package/lib/kernel/lease-enforcer.js +9 -4
  66. package/lib/kernel/legacy-claim-repair.js +442 -0
  67. package/lib/kernel/live-claim-projection.js +26 -0
  68. package/lib/kernel/migrations.js +118 -3
  69. package/lib/kernel/readiness-model.js +184 -12
  70. package/lib/kernel/schema.js +49 -1
  71. package/lib/kernel/sqlite-driver.js +3322 -183
  72. package/lib/kernel/taxonomy-validator.js +4 -1
  73. package/lib/kernel/windows-private-acl.js +239 -0
  74. package/lib/memory/hygiene.js +191 -0
  75. package/lib/memory/router.js +94 -27
  76. package/lib/memory/usage-evidence.js +4 -0
  77. package/lib/memory-digest.js +59 -0
  78. package/lib/merge-rules.js +135 -17
  79. package/lib/npm-publish-workflow.js +233 -40
  80. package/lib/package-root.js +2 -0
  81. package/lib/pr-monitor/auto-actions.js +169 -28
  82. package/lib/pr-monitor/differ.js +110 -4
  83. package/lib/pr-monitor/events.js +0 -0
  84. package/lib/pr-monitor/flow-monitor.js +1424 -0
  85. package/lib/pr-monitor/gather.js +251 -44
  86. package/lib/pr-monitor/journal.js +0 -37
  87. package/lib/pr-monitor/monitor.js +117 -10
  88. package/lib/pr-monitor/process-identity.js +117 -0
  89. package/lib/pr-monitor/reconcile-executor.js +1101 -625
  90. package/lib/pr-monitor/reconcile.js +0 -0
  91. package/lib/pr-monitor/render-summary.js +121 -24
  92. package/lib/pr-monitor/review-preflight.js +269 -0
  93. package/lib/pr-monitor/shepherd-lease.js +28 -19
  94. package/lib/pr-monitor/verdict.js +438 -0
  95. package/lib/pr-monitor/watch-lifecycle.js +144 -38
  96. package/lib/pr-monitor/watch-owner.js +1414 -0
  97. package/lib/pr-monitor/watch.js +129 -58
  98. package/lib/pr-shepherd.js +17 -3
  99. package/lib/project-memory.js +145 -3
  100. package/lib/protected-state-authority.js +799 -4
  101. package/lib/protected-state-surfaces.js +181 -3
  102. package/lib/release-readiness.js +2 -3
  103. package/lib/review-adapter.js +65 -0
  104. package/lib/skills-sync.js +65 -32
  105. package/lib/validation/risk-manifest.js +339 -0
  106. package/lib/validation-receipt.js +190 -0
  107. package/lib/workflow/enforce-stage.js +44 -0
  108. package/lib/workflow/plan-authority.js +225 -0
  109. package/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  110. package/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  111. package/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  112. package/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  113. package/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  114. package/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  115. package/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  116. package/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  117. package/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  118. package/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  119. package/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  120. package/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  121. package/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  122. package/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  123. package/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  124. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  125. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  126. package/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  127. package/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  128. package/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  129. package/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  130. package/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  131. package/node_modules/@forge/contracts/index.js +32 -0
  132. package/node_modules/@forge/contracts/package.json +35 -0
  133. package/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  134. package/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  135. package/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  136. package/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  137. package/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  138. package/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  139. package/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  140. package/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  141. package/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  142. package/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  143. package/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  144. package/node_modules/@forge/contracts/src/baseline.js +23 -0
  145. package/node_modules/@forge/contracts/src/canonical.js +151 -0
  146. package/node_modules/@forge/contracts/src/definitions.js +176 -0
  147. package/node_modules/@forge/contracts/src/identity.js +42 -0
  148. package/node_modules/@forge/contracts/src/schema.js +72 -0
  149. package/node_modules/@forge/contracts/src/validate.js +305 -0
  150. package/node_modules/@forge/flow/index.js +114 -0
  151. package/node_modules/@forge/flow/package.json +34 -0
  152. package/node_modules/@forge/flow/src/bounded-loop.js +415 -0
  153. package/node_modules/@forge/flow/src/efficiency-supervisor.js +89 -0
  154. package/node_modules/@forge/flow/src/executor.js +279 -0
  155. package/node_modules/@forge/flow/src/monitor-durability.js +419 -0
  156. package/node_modules/@forge/flow/src/monitor-runtime.js +460 -0
  157. package/node_modules/@forge/flow/src/process-lifecycle.js +469 -0
  158. package/node_modules/@forge/flow/src/skill-runtime.js +343 -0
  159. package/node_modules/@forge/memory/index.js +331 -0
  160. package/node_modules/@forge/memory/package.json +34 -0
  161. package/node_modules/@forge/memory/src/authority-provider.js +67 -0
  162. package/node_modules/@forge/memory/src/backend-registry.js +166 -0
  163. package/node_modules/@forge/memory/src/feedback-intake.js +243 -0
  164. package/node_modules/@forge/memory/src/pr-lifecycle-authority.js +946 -0
  165. package/node_modules/@forge/memory/src/usage-evidence.js +205 -0
  166. package/package.json +16 -4
  167. package/packages/flow/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  168. package/packages/flow/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  169. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  170. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  171. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  172. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  173. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  174. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  175. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  176. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  177. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  178. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  179. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  180. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  181. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  182. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  183. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  184. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  185. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  186. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  187. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  188. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  189. package/packages/flow/node_modules/@forge/contracts/index.js +32 -0
  190. package/packages/flow/node_modules/@forge/contracts/package.json +35 -0
  191. package/packages/flow/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  192. package/packages/flow/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  193. package/packages/flow/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  194. package/packages/flow/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  195. package/packages/flow/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  196. package/packages/flow/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  197. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  198. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  199. package/packages/flow/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  200. package/packages/flow/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  201. package/packages/flow/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  202. package/packages/flow/node_modules/@forge/contracts/src/baseline.js +23 -0
  203. package/packages/flow/node_modules/@forge/contracts/src/canonical.js +151 -0
  204. package/packages/flow/node_modules/@forge/contracts/src/definitions.js +176 -0
  205. package/packages/flow/node_modules/@forge/contracts/src/identity.js +42 -0
  206. package/packages/flow/node_modules/@forge/contracts/src/schema.js +72 -0
  207. package/packages/flow/node_modules/@forge/contracts/src/validate.js +305 -0
  208. package/packages/memory/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  209. package/packages/memory/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  210. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  211. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  212. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  213. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  214. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  215. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  216. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  217. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  218. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  219. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  220. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  221. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  222. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  223. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  224. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  225. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  226. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  227. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  228. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  229. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  230. package/packages/memory/node_modules/@forge/contracts/index.js +32 -0
  231. package/packages/memory/node_modules/@forge/contracts/package.json +35 -0
  232. package/packages/memory/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  233. package/packages/memory/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  234. package/packages/memory/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  235. package/packages/memory/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  236. package/packages/memory/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  237. package/packages/memory/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  238. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  239. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  240. package/packages/memory/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  241. package/packages/memory/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  242. package/packages/memory/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  243. package/packages/memory/node_modules/@forge/contracts/src/baseline.js +23 -0
  244. package/packages/memory/node_modules/@forge/contracts/src/canonical.js +151 -0
  245. package/packages/memory/node_modules/@forge/contracts/src/definitions.js +176 -0
  246. package/packages/memory/node_modules/@forge/contracts/src/identity.js +42 -0
  247. package/packages/memory/node_modules/@forge/contracts/src/schema.js +72 -0
  248. package/packages/memory/node_modules/@forge/contracts/src/validate.js +305 -0
  249. package/scripts/commitlint.js +13 -15
  250. package/scripts/generate-risk-manifest.js +91 -0
  251. package/scripts/github-context-bridge.sh +10 -0
  252. package/scripts/legacy-claim-repair.js +145 -0
  253. package/scripts/lib/behavioral-eval-runtime.js +3 -2
  254. package/scripts/process-tree.js +14 -2
  255. package/scripts/protected-state-check.js +440 -17
  256. package/scripts/sync-agent-skills.js +333 -34
  257. package/scripts/test-full-suite.js +704 -18
  258. package/scripts/test-profile.js +13 -3
  259. package/scripts/test.js +96 -15
  260. package/skills/coverage.json +1 -0
  261. package/skills/review/SKILL.md +2 -0
  262. package/skills/review/evals/scorecard.json +2 -2
  263. package/skills/setup/SKILL.md +18 -0
  264. package/skills/setup/evals/scorecard.json +3 -3
  265. package/skills/shepherd/SKILL.md +19 -2
  266. package/skills/shepherd/evals/scorecard.json +3 -3
  267. package/skills/validate/SKILL.md +3 -0
  268. package/skills/validate/evals/scorecard.json +1 -1
@@ -46,6 +46,8 @@ const { evaluateMergeRules } = require('../merge-rules');
46
46
  const { runIssueOperation } = require('../forge-issues');
47
47
  const { PrStateAdapter } = require('../adapters/pr-state-adapter');
48
48
  const { stripGlobalFlags } = require('../global-flags');
49
+ const gateCommand = require('./gate');
50
+ const { computeContentHash, validateContractStructure } = require('@forge/contracts');
49
51
 
50
52
  const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
51
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;
@@ -64,6 +66,37 @@ const REVIEW_ACTOR_TYPENAMES = new Set([
64
66
  const REVIEW_STATES = new Set([
65
67
  'APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING',
66
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
+ }
67
100
 
68
101
  function normalizeFullHeadSha(value) {
69
102
  return typeof value === 'string' && FULL_HEAD_SHA.test(value) ? value.toLowerCase() : null;
@@ -84,6 +117,53 @@ function normalizeRepository(value) {
84
117
  return typeof value === 'string' && REPOSITORY_NAME.test(value) ? value.toLowerCase() : null;
85
118
  }
86
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
+
87
167
  function parseMergeArgs(argv) {
88
168
  const values = { auto: false, pr: null, expectedHead: null, issueId: null, error: null };
89
169
  const seen = new Set();
@@ -168,76 +248,343 @@ async function defaultVerifyIssueOwnership({
168
248
  if (!actor) return { owned: false, actor: null, error: 'FORGE_ACTOR or FORGE_SESSION_ID is required.' };
169
249
  const frozenEnv = { ...env, FORGE_ACTOR: actor };
170
250
  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
251
+ const data = result?.data;
252
+ const ownershipMatches = result?.ok === true && data?.owned === true
173
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);
174
268
  return {
175
269
  owned: Boolean(owned),
176
270
  actor,
177
- claimedBy: data && data.claimed_by,
178
- expired: data && typeof data.expired === 'boolean' ? data.expired : null,
179
- error: result && result.error,
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,
180
308
  };
181
309
  }
182
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
+
183
354
  async function defaultVerifyPrIssueBinding({
184
355
  issueId,
185
356
  pr,
186
357
  projectRoot,
187
358
  prContext,
188
359
  buildBroker,
360
+ allowRetired = false,
189
361
  }) {
190
362
  const number = normalizePrNumber(String(pr));
191
- const repository = normalizeRepository(prContext && prContext.repository);
192
- if (!number || !repository || !prContext || prContext.number !== Number(number)) {
363
+ let repository = normalizeRepository(prContext?.repository);
364
+ if (!number || !prContext || prContext.number !== Number(number) || (!allowRetired && !repository)) {
193
365
  return { bound: false, error: 'PR identity is unreadable or does not match the requested PR number.' };
194
366
  }
195
367
 
196
368
  let built;
197
- const ownsDriver = !buildBroker;
369
+ let ownsDriver = false;
198
370
  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'
371
+ ({ built, ownsDriver } = await openPrBindingBroker(projectRoot, buildBroker));
372
+ const linkageReader = allowRetired ? built?.broker?.readTrace : built?.broker?.listOpenPrs;
373
+ if (!built?.broker || typeof linkageReader !== 'function'
209
374
  || typeof built.gitCommonDir !== 'string' || !built.gitCommonDir) {
210
375
  return { bound: false, error: 'Kernel PR linkage reader is unavailable.' };
211
376
  }
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.' };
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.' };
217
403
  }
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.' };
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.' };
222
414
  }
223
- const row = matches[0];
224
- const bound = row.state === 'open' && row.issue_id === issueId;
225
415
  return {
226
416
  bound,
227
417
  repository,
228
418
  number: Number(number),
229
419
  issueId: row.issue_id || null,
230
- error: bound ? null : 'Kernel PR row is not open or is linked to a different issue.',
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 } : {}),
231
427
  };
232
428
  } catch (err) {
233
429
  return { bound: false, error: `Kernel PR linkage verification failed: ${err.message}` };
234
430
  } finally {
235
- if (ownsDriver && built && built.driver && typeof built.driver.close === 'function') {
431
+ if (ownsDriver && typeof built?.driver?.close === 'function') {
236
432
  try { await built.driver.close(); } catch { /* cleanup must not mask the linkage verdict */ }
237
433
  }
238
434
  }
239
435
  }
240
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('@forge/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
+
241
588
  function strictCheckSuccess(check) {
242
589
  if (!check || typeof check !== 'object') return false;
243
590
  const hasStatus = Object.prototype.hasOwnProperty.call(check, 'status');
@@ -419,15 +766,121 @@ function mandatoryContextError(context, expectedHead) {
419
766
  return protectedGate.allowed ? null : protectedGate.reason;
420
767
  }
421
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
+ }
874
+
422
875
  /** Default `gh` runner. Only reached by the default fetch/merge seams (never in unit tests). */
423
876
  function defaultGh(args, options = {}) {
424
877
  return execFileSync('gh', args, { encoding: 'utf8', ...options }); // NOSONAR S4036 - hardcoded CLI (gh), args array (no shell), developer-tool context
425
878
  }
426
879
 
427
880
  /** Parse `gh ... --json` output, returning `null` on any failure (callers fail closed). */
428
- function ghJson(gh, args) {
881
+ function ghJson(gh, args, options) {
429
882
  try {
430
- return JSON.parse(gh(args) || '{}');
883
+ return JSON.parse(gh(args, options) || '{}');
431
884
  } catch (_err) {
432
885
  return null;
433
886
  }
@@ -523,9 +976,20 @@ function fetchCheckRunObservations(gh, { owner, repo, head }) {
523
976
  *
524
977
  * @returns {object} prContext
525
978
  */
526
- async function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
527
- const view = ghJson(gh, ['pr', 'view', String(pr), '--json',
528
- 'number,headRefOid,baseRefName,state,isDraft,mergeable,mergeStateStatus,statusCheckRollup,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
+ : {};
529
993
 
530
994
  const rollup = Array.isArray(view.statusCheckRollup) ? view.statusCheckRollup : null;
531
995
 
@@ -538,9 +1002,6 @@ async function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
538
1002
  }))
539
1003
  : [];
540
1004
 
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
1005
  const adapter = owner && repo
545
1006
  ? new PrStateAdapter({ gh: (_cmd, adapterArgs) => gh(adapterArgs) })
546
1007
  : null;
@@ -599,7 +1060,7 @@ async function defaultFetchPrContext({ pr, gh = defaultGh, now = Date.now() }) {
599
1060
 
600
1061
  return {
601
1062
  number: Number.isInteger(view.number) ? view.number : null,
602
- repository: owner && repo ? `${owner}/${repo}` : null,
1063
+ repository,
603
1064
  headSha: view.headRefOid || null,
604
1065
  checks,
605
1066
  providerObservations,
@@ -627,6 +1088,10 @@ function defaultMergePr({ pr, expectedHead, repository, gh = defaultGh }) {
627
1088
  if (!head) throw new Error('A full 40-character expected PR head SHA is required.');
628
1089
  if (!normalizePrNumber(String(pr)) || !repo) throw new Error('A canonical PR number and repository are required.');
629
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
+ }
630
1095
  return { merged: true, method: 'squash' };
631
1096
  }
632
1097
 
@@ -653,6 +1118,69 @@ async function handler(args, _flags, projectRoot, deps = {}) {
653
1118
  };
654
1119
  }
655
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
+
656
1184
  const loadConfig = deps.loadConfig || loadRawConfig;
657
1185
  let config;
658
1186
  try {
@@ -675,7 +1203,6 @@ async function handler(args, _flags, projectRoot, deps = {}) {
675
1203
  process.stdout.write(`${reason}\n`);
676
1204
  return { success: true, merged: false, enabled: false, reason };
677
1205
  }
678
-
679
1206
  // Opted in but no rules → refuse (fail-closed): an empty ruleset is vacuously
680
1207
  // "allowed", which would merge unconditionally. Treat that as misconfiguration.
681
1208
  if (rules.length === 0) {
@@ -684,21 +1211,12 @@ async function handler(args, _flags, projectRoot, deps = {}) {
684
1211
  return { success: false, merged: false, enabled: true, reason };
685
1212
  }
686
1213
 
687
- if (parsed.error || !parsed.expectedHead || !parsed.issueId) {
1214
+ if (!exactMergeIdentity) {
688
1215
  const reason = parsed.error
689
1216
  || 'Enabled auto-merge requires --expect-head <full 40-character SHA> and --issue <Forge issue ID>.';
690
1217
  return { success: false, merged: false, enabled: true, error: reason };
691
1218
  }
692
1219
 
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
1220
  let ownership;
703
1221
  try {
704
1222
  ownership = await verifyIssueOwnership(ownershipInput);
@@ -707,35 +1225,55 @@ async function handler(args, _flags, projectRoot, deps = {}) {
707
1225
  }
708
1226
  if (!ownership || ownership.owned !== true || ownership.expired !== false
709
1227
  || typeof ownership.actor !== 'string' || ownership.actor !== ownershipActor
710
- || typeof ownership.claimedBy !== 'string' || ownership.claimedBy !== ownershipActor) {
1228
+ || typeof ownership.claimedBy !== 'string' || ownership.claimedBy !== ownershipActor
1229
+ || ownership.sessionId !== ownershipSessionId) {
711
1230
  return {
712
1231
  success: false,
713
1232
  merged: false,
714
- error: `Active Kernel ownership claim is required for issue ${parsed.issueId}; refusing to merge.`,
1233
+ error: `Active Kernel ownership claim with the exact session is required for issue ${parsed.issueId}; refusing to merge.`,
715
1234
  };
716
1235
  }
1236
+ const preProvider = await replayBeforeProvider();
1237
+ if (preProvider.failure) return preProvider.failure;
1238
+ if (preProvider.replay) return preProvider.replay;
717
1239
 
718
- const fetchPrContext = deps.fetchPrContext || defaultFetchPrContext;
719
- const mergePr = deps.mergePr || defaultMergePr;
720
- const verifyPrIssueBinding = deps.verifyPrIssueBinding || defaultVerifyPrIssueBinding;
721
- const gh = deps.gh || defaultGh;
722
-
723
- let prContext;
724
1240
  try {
725
1241
  prContext = await fetchPrContext({ pr, projectRoot: root, gh, now: deps.now || Date.now() });
726
1242
  } catch (err) {
727
1243
  return { success: false, merged: false, error: `Failed to fetch PR context: ${err.message}` };
728
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
+ }
729
1256
 
730
- // Pre-flight: a PR that is already merged or closed is terminal. Re-running the
731
- // command must be an idempotent NO-OP — never an error and never a second merge
732
- // attempt. (An absent/unknown state falls through to the fail-closed rules.)
733
- const prState = prContext && prContext.state ? String(prContext.state).toUpperCase() : '';
734
- if (prState === 'MERGED' || prState === 'CLOSED') {
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') {
735
1270
  const reason = `PR #${pr} is ${prState} (not OPEN) — nothing to merge. No action taken.`;
736
1271
  process.stdout.write(`${reason}\n`);
737
1272
  return { success: true, merged: false, enabled: true, state: prState, reason };
738
1273
  }
1274
+ if (!ownershipSessionId) {
1275
+ return { success: false, merged: false, error: 'An exact FORGE_SESSION_ID session identity is required before merge.' };
1276
+ }
739
1277
 
740
1278
  const mandatoryError = mandatoryContextError(prContext, parsed.expectedHead);
741
1279
  if (mandatoryError) return { success: false, merged: false, error: mandatoryError };
@@ -784,7 +1322,16 @@ async function handler(args, _flags, projectRoot, deps = {}) {
784
1322
  // Re-apply the terminal-state guard on the FRESH context: the PR may have been
785
1323
  // merged or closed between the first fetch and now. Never merge a terminal PR.
786
1324
  const freshState = freshContext && freshContext.state ? String(freshContext.state).toUpperCase() : '';
787
- if (freshState === 'MERGED' || freshState === 'CLOSED') {
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') {
788
1335
  const reason = `PR #${pr} became ${freshState} (not OPEN) before merge — nothing to merge. No action taken.`;
789
1336
  process.stdout.write(`${reason}\n`);
790
1337
  return { success: true, merged: false, enabled: true, state: freshState, reason };
@@ -828,12 +1375,62 @@ async function handler(args, _flags, projectRoot, deps = {}) {
828
1375
  }
829
1376
  if (!finalOwnership || finalOwnership.owned !== true || finalOwnership.expired !== false
830
1377
  || typeof finalOwnership.actor !== 'string' || finalOwnership.actor !== ownershipActor
831
- || typeof finalOwnership.claimedBy !== 'string' || finalOwnership.claimedBy !== ownershipActor) {
1378
+ || typeof finalOwnership.claimedBy !== 'string' || finalOwnership.claimedBy !== ownershipActor
1379
+ || finalOwnership.sessionId !== ownershipSessionId) {
832
1380
  return { success: false, merged: false, error: 'Kernel ownership changed or expired before merge; refusing to merge.' };
833
1381
  }
834
1382
 
1383
+ const decisionAt = currentTimestamp(deps.now || Date.now);
1384
+ const verifyMergeGate = deps.verifyMergeGate || defaultVerifyMergeGate;
1385
+ let gateApproval;
1386
+ try {
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;
835
1409
  try {
836
- const mergeResult = await mergePr({
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({
837
1434
  pr,
838
1435
  expectedHead: parsed.expectedHead,
839
1436
  repository: leasedRepository,
@@ -841,28 +1438,78 @@ async function handler(args, _flags, projectRoot, deps = {}) {
841
1438
  projectRoot: root,
842
1439
  gh,
843
1440
  });
844
- process.stdout.write(`All ${rules.length} merge rule(s) passed — merged PR #${pr}.\n`);
1441
+ } catch (err) {
1442
+ return { success: false, merged: false, decisionId: decision.decisionId, error: `Merge failed: ${err.message}` };
1443
+ }
1444
+ if (mergeResult?.merged !== true) {
1445
+ return {
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) {
845
1466
  return {
846
- success: true,
1467
+ success: false,
847
1468
  merged: true,
848
1469
  enabled: true,
849
1470
  allowed: true,
850
- reason: 'all merge rules passed',
851
- ...(mergeResult && typeof mergeResult === 'object' ? mergeResult : {}),
1471
+ decisionId: decision.decisionId,
1472
+ error: `Merge succeeded but terminal linkage failed: ${err.message}`,
852
1473
  };
853
- } catch (err) {
854
- return { success: false, merged: false, error: `Merge failed: ${err.message}` };
855
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
+ };
856
1498
  }
857
1499
 
858
1500
  module.exports = {
859
1501
  name: 'merge',
1502
+ githubAuth: true,
860
1503
  description: 'Opt-in conditional auto-merge: merge a PR only when all user-configured rules pass (OFF by default)',
861
1504
  usage: 'Usage: forge merge --auto <pr> --expect-head <40-char-sha> --issue <issue-id>',
862
1505
  handler,
863
1506
  // Exported seams for testing / reuse.
864
1507
  defaultFetchPrContext,
1508
+ defaultResolveLocalRepository,
865
1509
  defaultMergePr,
1510
+ defaultPrepareMergeDecision,
1511
+ defaultRecordMergeDecision,
1512
+ defaultVerifyMergeGate,
866
1513
  defaultVerifyPrIssueBinding,
867
1514
  defaultVerifyIssueOwnership,
868
1515
  evaluateProtectedRequiredChecks,