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
@@ -1,12 +1,14 @@
1
1
  'use strict';
2
2
 
3
3
  const { execFileSync } = require('node:child_process');
4
+ const fs = require('node:fs');
4
5
  const path = require('node:path');
5
- const { randomUUID } = require('node:crypto');
6
- const { buildConflict, evaluateKernelEvent, normalizePayload } = require('./evaluators');
6
+ const { createHash, randomUUID } = require('node:crypto');
7
+ const { types: { isProxy } } = require('node:util');
8
+ const { buildConflict, evaluateKernelEvent, normalizePayload, stableStringify } = require('./evaluators');
7
9
  const { CONFLICT_SIGNAL, classifyConflictSignal } = require('./conflict-signal');
8
10
  const { buildKernelMigrationPlan } = require('./migrations');
9
- const { buildClaimConflict, isValidExpiresAt, planClaimAcquisition } = require('./lease-enforcer');
11
+ const { buildClaimConflict, isLeaseExpired, isValidExpiresAt, planClaimAcquisition } = require('./lease-enforcer');
10
12
  const {
11
13
  ISSUE_COMMAND_EXIT_CODES,
12
14
  ISSUE_COMMAND_SCHEMA_VERSION,
@@ -15,7 +17,12 @@ const {
15
17
  isValidIssuePriority,
16
18
  normalizePriority,
17
19
  } = require('./issue-command-contract');
18
- const { isValidIssueStatus, isValidStatusTransition, rankForPriorityLabel } = require('./taxonomy-validator');
20
+ const {
21
+ isTerminalStatus,
22
+ isValidIssueStatus,
23
+ isValidStatusTransition,
24
+ rankForPriorityLabel,
25
+ } = require('./taxonomy-validator');
19
26
  const { assertFilesystemSafeForKernel } = require('./fs-class');
20
27
  const { resolveIssueIdArgs } = require('./issue-id-resolver');
21
28
 
@@ -25,6 +32,22 @@ const LOCAL_BROKER_PRAGMAS = Object.freeze([
25
32
  'PRAGMA foreign_keys=ON;',
26
33
  'PRAGMA busy_timeout=5000;',
27
34
  ]);
35
+ const PR_LINKAGE_TURNS = new WeakMap();
36
+
37
+ async function withPrLinkageTurn(driver, operation) {
38
+ const queueKey = driver.transactionQueueKey || driver;
39
+ const prior = PR_LINKAGE_TURNS.get(queueKey) || Promise.resolve();
40
+ let release;
41
+ const current = new Promise(resolve => { release = resolve; });
42
+ PR_LINKAGE_TURNS.set(queueKey, current);
43
+ await prior;
44
+ try {
45
+ return await operation();
46
+ } finally {
47
+ release();
48
+ if (PR_LINKAGE_TURNS.get(queueKey) === current) PR_LINKAGE_TURNS.delete(queueKey);
49
+ }
50
+ }
28
51
 
29
52
  // Bounded wall-clock ceiling for the single `git rev-parse --git-common-dir`
30
53
  // probe below. This synchronous spawn has intermittently hung on Windows/Node
@@ -56,6 +79,16 @@ function resolveGitCommonDir(projectRoot, deps = {}) {
56
79
  throw new Error('projectRoot is required to resolve the Kernel broker common-dir');
57
80
  }
58
81
 
82
+ const conventionalDir = defaultGitCommonDir(projectRoot);
83
+ try {
84
+ if (fs.lstatSync(conventionalDir).isDirectory()) {
85
+ return conventionalDir;
86
+ }
87
+ } catch {
88
+ // Missing/unreadable .git or a linked-worktree .git file needs Git's
89
+ // authoritative common-dir resolution below.
90
+ }
91
+
59
92
  const exec = deps.execFileSync || execFileSync;
60
93
  let rawCommonDir;
61
94
  try {
@@ -117,6 +150,500 @@ function requireDriverMethod(driver, methodName) {
117
150
  }
118
151
  }
119
152
 
153
+ const TRACE_FULL_HEAD = /^[0-9a-f]{40}$/;
154
+ const TRACE_SHA256 = /^[0-9a-f]{64}$/;
155
+ const TRACE_MAX_RECEIPT_BYTES = 1_048_576;
156
+ const TRACE_MAX_RECEIPT_DEPTH = 64;
157
+ const TRACE_MAX_RECEIPT_NODES = 100_000;
158
+ const TRACE_MAX_ARTIFACT_BYTES = 65_536;
159
+ const TRACE_MAX_GATES = 32;
160
+
161
+ function traceError(code, message) {
162
+ const error = new Error(message);
163
+ error.code = code;
164
+ error.status = 'INCOMPLETE';
165
+ return error;
166
+ }
167
+
168
+ function readReceiptShape(value) {
169
+ try {
170
+ return {
171
+ descriptors: Object.getOwnPropertyDescriptors(value),
172
+ prototype: Object.getPrototypeOf(value),
173
+ };
174
+ } catch {
175
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage receipt must be bounded plain JSON');
176
+ }
177
+ }
178
+
179
+ function assertBoundedReceiptArray(value, descriptors, prototype, state, depth) {
180
+ const keys = Reflect.ownKeys(descriptors);
181
+ if (prototype !== Array.prototype || keys.length !== value.length + 1) {
182
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage receipt arrays must be dense');
183
+ }
184
+ for (let index = 0; index < value.length; index += 1) {
185
+ const descriptor = descriptors[String(index)];
186
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
187
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage receipt must not contain accessors');
188
+ }
189
+ assertBoundedReceiptValue(descriptor.value, state, depth + 1);
190
+ }
191
+ }
192
+
193
+ function assertBoundedReceiptObject(descriptors, prototype, state, depth) {
194
+ if (prototype !== Object.prototype) {
195
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage receipt objects must be plain');
196
+ }
197
+ for (const descriptor of Object.values(descriptors)) {
198
+ if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) {
199
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage receipt must not contain accessors');
200
+ }
201
+ assertBoundedReceiptValue(descriptor.value, state, depth + 1);
202
+ }
203
+ }
204
+
205
+ function assertBoundedReceiptValue(value, state, depth = 0) {
206
+ const traversal = state || { ancestors: new WeakSet(), nodes: 0 };
207
+ traversal.nodes += 1;
208
+ if (traversal.nodes > TRACE_MAX_RECEIPT_NODES || depth > TRACE_MAX_RECEIPT_DEPTH) {
209
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage receipt exceeds canonical bounds');
210
+ }
211
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
212
+ if (typeof value === 'number' && Number.isFinite(value)) return;
213
+ if (!value || typeof value !== 'object' || isProxy(value) || traversal.ancestors.has(value)) {
214
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage receipt must be bounded plain JSON');
215
+ }
216
+ const { descriptors, prototype } = readReceiptShape(value);
217
+ const keys = Reflect.ownKeys(descriptors);
218
+ if (keys.some(key => typeof key !== 'string')) {
219
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage receipt must not contain symbol keys');
220
+ }
221
+ traversal.ancestors.add(value);
222
+ try {
223
+ if (Array.isArray(value)) {
224
+ assertBoundedReceiptArray(value, descriptors, prototype, traversal, depth);
225
+ return;
226
+ }
227
+ assertBoundedReceiptObject(descriptors, prototype, traversal, depth);
228
+ } finally {
229
+ traversal.ancestors.delete(value);
230
+ }
231
+ }
232
+
233
+ // Contract hashes use ECMAScript's default UTF-16 code-unit key order, not locale collation.
234
+ function compareCanonicalReceiptKeys(left, right) {
235
+ if (left < right) return -1;
236
+ if (left > right) return 1;
237
+ return 0;
238
+ }
239
+
240
+ function canonicalReceiptStringify(value) {
241
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
242
+ if (Array.isArray(value)) return `[${value.map(canonicalReceiptStringify).join(',')}]`;
243
+ return `{${Object.keys(value).sort(compareCanonicalReceiptKeys)
244
+ .map(key => `${JSON.stringify(key)}:${canonicalReceiptStringify(value[key])}`)
245
+ .join(',')}}`;
246
+ }
247
+
248
+ function snapshotReceipt(receipt) {
249
+ assertBoundedReceiptValue(receipt);
250
+ return JSON.parse(canonicalReceiptStringify(receipt));
251
+ }
252
+
253
+ function normalizeTraceRepository(value) {
254
+ return typeof value === 'string' ? value.trim().toLowerCase() : value;
255
+ }
256
+
257
+ function snapshotPrLinkageInput(input) {
258
+ if (!input || typeof input !== 'object') return input;
259
+ return {
260
+ phase: input.phase,
261
+ git_common_dir: input.git_common_dir,
262
+ repo: normalizeTraceRepository(input.repo),
263
+ number: input.number,
264
+ url: input.url,
265
+ branch: input.branch,
266
+ work_packet: snapshotReceipt(input.work_packet),
267
+ run_receipt: snapshotReceipt(input.run_receipt),
268
+ occurred_at: input.occurred_at || new Date().toISOString(),
269
+ worktree_id: input.worktree_id,
270
+ issue_id: input.issue_id,
271
+ journal_ptr: input.journal_ptr,
272
+ id: input.id,
273
+ };
274
+ }
275
+
276
+ function receiptContentHash(receipt) {
277
+ const withoutHash = {};
278
+ for (const [key, value] of Object.entries(receipt)) {
279
+ if (key !== 'content_hash') withoutHash[key] = value;
280
+ }
281
+ const canonical = canonicalReceiptStringify(withoutHash);
282
+ if (Buffer.byteLength(canonical, 'utf8') > TRACE_MAX_RECEIPT_BYTES) {
283
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage receipt exceeds byte bound');
284
+ }
285
+ return createHash('sha256').update(canonical, 'utf8').digest('hex');
286
+ }
287
+
288
+ function assertReceiptEnvelope(receipt, schemaId) {
289
+ assertBoundedReceiptValue(receipt);
290
+ if (!receipt || receipt.schema_id !== schemaId || receipt.schema_version !== 1
291
+ || !TRACE_SHA256.test(receipt.content_hash)
292
+ || receiptContentHash(receipt) !== receipt.content_hash
293
+ || !receipt.payload || typeof receipt.payload !== 'object' || Array.isArray(receipt.payload)) {
294
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', `recordPrLinkage requires a valid ${schemaId} receipt`);
295
+ }
296
+ return receipt;
297
+ }
298
+
299
+ function derivePrEvidence(input) {
300
+ const packet = assertReceiptEnvelope(input?.work_packet, 'forge.memory.work-packet.v1');
301
+ const receipt = assertReceiptEnvelope(input?.run_receipt, 'forge.memory.run-receipt.v1');
302
+ const packetPayload = packet.payload;
303
+ const receiptPayload = receipt.payload;
304
+ if (typeof packetPayload.issue_id !== 'string' || !packetPayload.issue_id
305
+ || !Number.isInteger(packetPayload.expected_issue_revision) || packetPayload.expected_issue_revision < 0
306
+ || packetPayload.authority?.issue_revision !== packetPayload.expected_issue_revision
307
+ || typeof packetPayload.packet_id !== 'string' || !packetPayload.packet_id || packetPayload.packet_id.length > 512
308
+ || typeof packetPayload.repository_id !== 'string' || !packetPayload.repository_id
309
+ || !Number.isSafeInteger(packetPayload.packet_revision) || packetPayload.packet_revision < 1
310
+ || !TRACE_SHA256.test(packetPayload.capability_manifest_digest)
311
+ || typeof packetPayload.workflow_config_revision !== 'string' || !packetPayload.workflow_config_revision
312
+ || !TRACE_FULL_HEAD.test(packetPayload.target_head)
313
+ || !TRACE_SHA256.test(packetPayload.risk_manifest_digest)) {
314
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage WorkPacket authority binding is incomplete');
315
+ }
316
+ const gateIds = packetPayload.receipt_requirements?.gate_ids;
317
+ if (!Array.isArray(gateIds) || gateIds.length === 0 || gateIds.length > TRACE_MAX_GATES
318
+ || new Set(gateIds).size !== gateIds.length
319
+ || gateIds.some(gateId => typeof gateId !== 'string' || gateId.length === 0 || gateId.length > 128)) {
320
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage WorkPacket gate requirements are incomplete');
321
+ }
322
+ if (!Array.isArray(packetPayload.allowed_mutations)
323
+ || !packetPayload.allowed_mutations.includes(`pr.${input.phase}`)) {
324
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', `recordPrLinkage WorkPacket does not authorize pr.${input.phase}`);
325
+ }
326
+ if (receiptPayload.status !== 'PASS' || receiptPayload.validation?.status !== 'PASS') {
327
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage requires a PASS RunReceipt');
328
+ }
329
+ for (const field of ['run_id', 'attempt_id']) {
330
+ const value = receiptPayload[field];
331
+ const hasControl = typeof value === 'string' && [...value].some(character => {
332
+ const code = character.charCodeAt(0);
333
+ return code <= 31 || code === 127;
334
+ });
335
+ if (typeof value !== 'string' || value.length === 0 || value.length > 512 || hasControl) {
336
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', `recordPrLinkage RunReceipt ${field} is invalid`);
337
+ }
338
+ }
339
+ const requiredMutation = `pr.${input.phase}`;
340
+ if (!Array.isArray(receiptPayload.mutations_attempted)
341
+ || !receiptPayload.mutations_attempted.includes(requiredMutation)
342
+ || !Array.isArray(receiptPayload.mutations_authorized)
343
+ || !receiptPayload.mutations_authorized.includes(requiredMutation)) {
344
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', `recordPrLinkage RunReceipt must attempt and authorize ${requiredMutation}`);
345
+ }
346
+ const bindingsMatch = receiptPayload.packet_hash === packet.content_hash
347
+ && receiptPayload.exact_head === packetPayload.target_head
348
+ && receiptPayload.packet_revision === packetPayload.packet_revision
349
+ && receiptPayload.manifest_digest === packetPayload.capability_manifest_digest
350
+ && receiptPayload.workflow_config_revision === packetPayload.workflow_config_revision;
351
+ if (!bindingsMatch) {
352
+ throw traceError('FORGE_TRACE_EVIDENCE_CONFLICT', 'recordPrLinkage WorkPacket and RunReceipt bindings conflict');
353
+ }
354
+ if (input.issue_id && input.issue_id !== packetPayload.issue_id) {
355
+ throw traceError('FORGE_TRACE_EVIDENCE_CONFLICT', 'recordPrLinkage explicit issue_id conflicts with WorkPacket authority');
356
+ }
357
+ if (packetPayload.repository_id !== input.repo) {
358
+ throw traceError('FORGE_TRACE_EVIDENCE_CONFLICT', 'recordPrLinkage repository conflicts with WorkPacket authority');
359
+ }
360
+ return {
361
+ issue_id: packetPayload.issue_id,
362
+ issue_revision: packetPayload.expected_issue_revision,
363
+ head_sha: packetPayload.target_head,
364
+ packet_id: packetPayload.packet_id,
365
+ packet_revision: packetPayload.packet_revision,
366
+ work_packet_hash: packet.content_hash,
367
+ work_packet_identity: stableStringify([
368
+ packetPayload.issue_id,
369
+ packetPayload.expected_issue_revision,
370
+ packetPayload.packet_id,
371
+ packetPayload.packet_revision,
372
+ packetPayload.repository_id,
373
+ packetPayload.target_head,
374
+ ]),
375
+ run_receipt_hash: receipt.content_hash,
376
+ run_id: receiptPayload.run_id,
377
+ attempt_id: receiptPayload.attempt_id,
378
+ risk_manifest_digest: packetPayload.risk_manifest_digest,
379
+ gate_ids: gateIds,
380
+ };
381
+ }
382
+
383
+ function parseTracePayload(row) {
384
+ try {
385
+ return JSON.parse(row?.payload_json || '{}');
386
+ } catch {
387
+ return {};
388
+ }
389
+ }
390
+
391
+ function isNewerGateCandidate(candidate, current) {
392
+ return !current || candidate.eventAtMs > current.eventAtMs
393
+ || (candidate.eventAtMs === current.eventAtMs && candidate.order > current.order);
394
+ }
395
+
396
+ function resolveGateReceipts(events, gateIds, occurredAt) {
397
+ const occurredAtMs = Date.parse(occurredAt);
398
+ if (!Number.isFinite(occurredAtMs)) {
399
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage occurred_at must be a valid timestamp');
400
+ }
401
+ const gateEvents = new Map();
402
+ for (const [order, event] of events.entries()) {
403
+ if (!['gate.approved', 'gate.rejected'].includes(event.event_type)) continue;
404
+ const eventAtMs = Date.parse(event.created_at);
405
+ if (!Number.isFinite(eventAtMs) || eventAtMs > occurredAtMs) continue;
406
+ const payload = parseTracePayload(event);
407
+ const candidate = { event, payload, eventAtMs, order };
408
+ if (gateIds.includes(payload.gate) && isNewerGateCandidate(candidate, gateEvents.get(payload.gate))) {
409
+ // listKernelEvents supplies rowid order for equal timestamps; later rows win explicitly.
410
+ gateEvents.set(payload.gate, candidate);
411
+ }
412
+ }
413
+ return gateIds.map(gateId => {
414
+ const latest = gateEvents.get(gateId);
415
+ const expiry = latest?.payload?.expires_at;
416
+ const active = latest?.event?.event_type === 'gate.approved'
417
+ && (!expiry || (Number.isFinite(Date.parse(expiry)) && Date.parse(occurredAt) < Date.parse(expiry)));
418
+ if (!active || typeof latest.event.idempotency_key !== 'string' || !latest.event.idempotency_key) {
419
+ throw traceError('FORGE_TRACE_GATE_UNAVAILABLE', `recordPrLinkage gate ${gateId} lacks an active approval receipt`);
420
+ }
421
+ return latest.event.idempotency_key;
422
+ });
423
+ }
424
+
425
+ function readTraceArtifact(projectRoot, workFolder, filename) {
426
+ if (!workFolder) return { artifact: null, gap: filename.replace('.md', '') };
427
+ const root = fs.realpathSync(path.resolve(projectRoot));
428
+ const candidateFolder = path.resolve(root, workFolder);
429
+ if (!fs.existsSync(candidateFolder)) return { artifact: null, gap: filename.replace('.md', '') };
430
+ const folder = fs.realpathSync(candidateFolder);
431
+ const relativeFolder = path.relative(root, folder);
432
+ if (relativeFolder.startsWith('..') || path.isAbsolute(relativeFolder)) {
433
+ return { artifact: null, gap: `${filename.replace('.md', '')}:outside_project` };
434
+ }
435
+ const file = path.join(folder, filename);
436
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return { artifact: null, gap: filename.replace('.md', '') };
437
+ const resolvedFile = fs.realpathSync(file);
438
+ const relativeFile = path.relative(root, resolvedFile);
439
+ if (relativeFile.startsWith('..') || path.isAbsolute(relativeFile)) {
440
+ return { artifact: null, gap: `${filename.replace('.md', '')}:outside_project` };
441
+ }
442
+ if (fs.statSync(resolvedFile).size > TRACE_MAX_ARTIFACT_BYTES) {
443
+ return { artifact: null, gap: `${filename.replace('.md', '')}:oversized` };
444
+ }
445
+ return {
446
+ artifact: {
447
+ path: path.posix.join(String(workFolder).replaceAll('\\', '/'), filename),
448
+ content: fs.readFileSync(resolvedFile, 'utf8'),
449
+ },
450
+ gap: null,
451
+ };
452
+ }
453
+
454
+ function buildTraceEnvelope(projectRoot, target, rows) {
455
+ const artifactReads = Object.fromEntries(['plan', 'tasks', 'decisions'].map(name => (
456
+ [name, readTraceArtifact(projectRoot, rows.work_folder, `${name}.md`)]
457
+ )));
458
+ const artifacts = Object.fromEntries(Object.entries(artifactReads).map(([name, value]) => [name, value.artifact]));
459
+ const gaps = [];
460
+ if (!rows.issue) gaps.push('issue');
461
+ if (!rows.worktree) gaps.push('worktree');
462
+ if (!rows.work_folder) gaps.push('work_folder');
463
+ for (const name of ['plan', 'tasks', 'decisions']) if (artifactReads[name].gap) gaps.push(artifactReads[name].gap);
464
+ if (rows.pull_requests.length === 0) gaps.push('pull_requests');
465
+ for (const gap of rows.gaps || []) if (!gaps.includes(gap)) gaps.push(gap);
466
+ return {
467
+ schema_version: 'forge.trace.v1',
468
+ target: target.issue_id
469
+ ? { kind: 'issue', id: target.issue_id }
470
+ : { kind: 'pr', number: Number(target.pr_number), repo: target.repo ?? null },
471
+ issue: rows.issue,
472
+ worktree: rows.worktree,
473
+ work_folder: rows.work_folder,
474
+ artifacts,
475
+ pull_requests: rows.pull_requests,
476
+ gaps,
477
+ };
478
+ }
479
+
480
+ function assertCompatiblePrEvidence(existingEvent, payload) {
481
+ if (!existingEvent || stableStringify(normalizePayload(existingEvent)) === stableStringify(payload)) return;
482
+ throw traceError('FORGE_TRACE_EVIDENCE_CONFLICT', 'INCOMPLETE: conflicting evidence for idempotency key');
483
+ }
484
+
485
+ const PR_LINKAGE_DRIVER_METHODS = [
486
+ 'exec', 'resolvePrLinkage', 'upsertPr', 'retirePr', 'readTrace',
487
+ 'listKernelEvents', 'loadKernelEventByIdempotencyKey', 'loadPrEventsByRunId', 'insertKernelEvent', 'enqueueKernelProjection',
488
+ ];
489
+
490
+ function preparePrLinkageInput(input, driver) {
491
+ const linkageInput = snapshotPrLinkageInput(input);
492
+ if (!linkageInput || !['opened', 'merged'].includes(linkageInput.phase)) {
493
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordPrLinkage phase must be opened or merged');
494
+ }
495
+ for (const field of ['git_common_dir', 'repo', 'number', 'branch']) {
496
+ if (linkageInput[field] === undefined || linkageInput[field] === null || linkageInput[field] === '') {
497
+ throw traceError('FORGE_TRACE_INVALID_RECEIPT', `recordPrLinkage requires ${field}`);
498
+ }
499
+ }
500
+ for (const method of PR_LINKAGE_DRIVER_METHODS) requireDriverMethod(driver, method);
501
+ return linkageInput;
502
+ }
503
+
504
+ function assertResolvedPrLinkage(inferred, evidence) {
505
+ if (!inferred.issue_id || inferred.issue_revision === null) {
506
+ throw traceError('FORGE_TRACE_EVIDENCE_CONFLICT', 'recordPrLinkage requires resolved issue linkage');
507
+ }
508
+ if (!inferred.worktree_id || !inferred.worktree_matches) {
509
+ throw traceError('FORGE_TRACE_EVIDENCE_CONFLICT', 'recordPrLinkage requires a matching active worktree binding');
510
+ }
511
+ if (inferred.inferred_issue_id && evidence.issue_id !== inferred.inferred_issue_id) {
512
+ throw traceError('FORGE_TRACE_EVIDENCE_CONFLICT', 'recordPrLinkage WorkPacket issue conflicts with inferred worktree issue');
513
+ }
514
+ if (evidence.issue_revision !== inferred.issue_revision) {
515
+ throw traceError(
516
+ 'FORGE_TRACE_STALE_AUTHORITY',
517
+ `recordPrLinkage stale issue revision: expected ${evidence.issue_revision}, actual ${inferred.issue_revision}`,
518
+ );
519
+ }
520
+ }
521
+
522
+ function findPriorPr(pullRequests, input) {
523
+ return pullRequests.find(candidate => (
524
+ candidate.git_common_dir === input.git_common_dir
525
+ && candidate.repo === input.repo
526
+ && Number(candidate.number) === Number(input.number)
527
+ )) || null;
528
+ }
529
+
530
+ function buildPrLinkagePayload(input, evidence, worktreeId, gateReceipts) {
531
+ return {
532
+ phase: input.phase,
533
+ issue_id: evidence.issue_id,
534
+ issue_revision: evidence.issue_revision,
535
+ worktree_id: worktreeId,
536
+ repo: input.repo,
537
+ number: input.number,
538
+ git_common_dir: input.git_common_dir,
539
+ branch: input.branch,
540
+ url: input.url ?? null,
541
+ head_sha: evidence.head_sha,
542
+ work_packet_hash: evidence.work_packet_hash,
543
+ work_packet_identity: evidence.work_packet_identity,
544
+ packet_id: evidence.packet_id,
545
+ packet_revision: evidence.packet_revision,
546
+ run_receipt_hash: evidence.run_receipt_hash,
547
+ run_id: evidence.run_id,
548
+ attempt_id: evidence.attempt_id,
549
+ risk_manifest_digest: evidence.risk_manifest_digest,
550
+ gate_receipts: gateReceipts,
551
+ };
552
+ }
553
+
554
+ function assertTerminalPrCompatibility(terminalPr, input, evidence, worktreeId) {
555
+ if (!terminalPr) return;
556
+ const conflicts = evidence.head_sha !== terminalPr.head_sha
557
+ || (input.branch != null && input.branch !== terminalPr.branch)
558
+ || evidence.issue_id !== terminalPr.issue_id
559
+ || (worktreeId != null && worktreeId !== terminalPr.worktree_id)
560
+ || (input.journal_ptr != null && input.journal_ptr !== terminalPr.journal_ptr);
561
+ if (conflicts) throw traceError('FORGE_TRACE_TERMINAL_CONFLICT', 'recordPrLinkage terminal PR linkage conflict');
562
+ }
563
+
564
+ function assertTerminalPrEvent(terminalPr, existingEvent) {
565
+ if (terminalPr && !existingEvent) {
566
+ throw traceError('FORGE_TRACE_TERMINAL_CONFLICT', 'recordPrLinkage terminal PR linkage conflict');
567
+ }
568
+ }
569
+
570
+ function assertAtomicPrEvidence(trace, input, evidence) {
571
+ if (input.phase !== 'opened') return;
572
+ if (trace.gaps?.some(gap => gap === 'pull_requests:overflow'
573
+ || /^pull_requests:.*:unlinked_issue$/.test(gap)
574
+ || /^iterations:.*:(?:missing|incomplete|overflow)$/.test(gap))) {
575
+ throw traceError('FORGE_TRACE_EVIDENCE_CONFLICT', 'recordPrLinkage cannot prove complete semantic identity evidence');
576
+ }
577
+ for (const request of trace.pull_requests || []) {
578
+ const conflicting = (request.iterations || []).some(iteration => {
579
+ if (iteration.type !== 'pr.opened') return false;
580
+ if (iteration.work_packet_identity !== evidence.work_packet_identity) return false;
581
+ const sameTarget = request.git_common_dir === input.git_common_dir
582
+ && request.repo === input.repo && Number(request.number) === Number(input.number);
583
+ const sameContent = iteration.work_packet_hash === evidence.work_packet_hash
584
+ && iteration.run_receipt_hash === evidence.run_receipt_hash;
585
+ return !sameTarget || !sameContent;
586
+ });
587
+ if (conflicting) {
588
+ throw traceError('FORGE_TRACE_EVIDENCE_CONFLICT', 'recordPrLinkage semantic identity conflicts with durable PR evidence');
589
+ }
590
+ }
591
+ }
592
+
593
+ function assertAtomicRunIdentity(events, payload) {
594
+ const identityFields = ['issue_id', 'repo', 'number', 'git_common_dir', 'branch', 'head_sha',
595
+ 'work_packet_identity', 'packet_id', 'packet_revision', 'run_id', 'attempt_id'];
596
+ for (const event of events) {
597
+ const existing = normalizePayload(event);
598
+ if (identityFields.some(field => stableStringify(existing[field]) !== stableStringify(payload[field]))) {
599
+ throw traceError('FORGE_TRACE_EVIDENCE_CONFLICT', 'recordPrLinkage run identity conflicts with durable PR evidence');
600
+ }
601
+ }
602
+ }
603
+
604
+ async function assertExactLifecycleOwnership(driver, evidence, context, config, now) {
605
+ for (const method of ['loadActiveKernelClaim', 'loadKernelEntity']) requireDriverMethod(driver, method);
606
+ if (typeof context.actor !== 'string' || !context.actor || typeof context.sessionId !== 'string' || !context.sessionId) {
607
+ throw traceError('FORGE_TRACE_EVIDENCE_CONFLICT', 'recordOpenedPrLinkage requires exact actor and session authority');
608
+ }
609
+ const [issue, claim] = await Promise.all([
610
+ driver.loadKernelEntity('issue', evidence.issue_id, context, config),
611
+ driver.loadActiveKernelClaim(evidence.issue_id, context, config),
612
+ ]);
613
+ if (!issue || isTerminalStatus(issue.status) || !claim || claim.state !== 'active'
614
+ || claim.actor !== context.actor || claim.session_id !== context.sessionId || isLeaseExpired(claim, now)) {
615
+ throw traceError('FORGE_TRACE_EVIDENCE_CONFLICT', 'recordOpenedPrLinkage claimant no longer owns the live issue');
616
+ }
617
+ }
618
+
619
+ function isDatabaseLocked(error) {
620
+ return /database is locked|SQLITE_BUSY/i.test(String(error?.message || error));
621
+ }
622
+
623
+ async function beginImmediate(driver, config) {
624
+ await driver.exec('PRAGMA busy_timeout=0;', config);
625
+ for (let attempt = 0; attempt < 500; attempt += 1) {
626
+ try {
627
+ await driver.exec('BEGIN IMMEDIATE;', config);
628
+ return;
629
+ } catch (error) {
630
+ if (!isDatabaseLocked(error) || attempt === 499) {
631
+ await restoreBusyTimeout(driver, config);
632
+ throw error;
633
+ }
634
+ await new Promise(resolve => setTimeout(resolve, 10));
635
+ }
636
+ }
637
+ }
638
+
639
+ async function restoreBusyTimeout(driver, config) {
640
+ try {
641
+ await driver.exec(`PRAGMA busy_timeout=${config.busyTimeoutMs ?? 5000};`, config);
642
+ } catch {
643
+ // Recovery must never hide the authoritative linkage result.
644
+ }
645
+ }
646
+
120
647
  // The migration ledger records which migration ids have been applied so initialize()
121
648
  // applies each migration AT MOST ONCE — no full re-run, no blanket duplicate-error
122
649
  // swallow. The ledger table is broker-managed bookkeeping (created before the plan is
@@ -266,6 +793,13 @@ function buildClaimScope(event) {
266
793
  };
267
794
  }
268
795
 
796
+ function terminalClaimValidationError(issueId, status) {
797
+ const error = new Error(`Cannot claim a terminal (${status}) issue ${issueId}`);
798
+ error.isIssueValidation = true;
799
+ error.validationField = 'status';
800
+ return error;
801
+ }
802
+
269
803
  const GUARDED_DRIVER_METHODS = Object.freeze([
270
804
  'exec',
271
805
  'loadKernelEntity',
@@ -365,6 +899,12 @@ async function commitGuardedAccept({ driver, event, evaluation, claimScope, acti
365
899
 
366
900
  await driver.exec('BEGIN IMMEDIATE;', config);
367
901
  try {
902
+ if (claimScope) {
903
+ const issue = await driver.loadKernelEntity('issue', claimScope.issue_id, context, config);
904
+ if (isTerminalStatus(issue?.status)) {
905
+ throw terminalClaimValidationError(claimScope.issue_id, issue.status);
906
+ }
907
+ }
368
908
  if (claimPlan) {
369
909
  if (claimPlan.action === 'reclaim') {
370
910
  await driver.updateKernelClaimState(
@@ -448,9 +988,39 @@ function nextCommandsFor(commandId) {
448
988
  return contract ? [...contract.nextCommands] : [];
449
989
  }
450
990
 
451
- // First non-flag token (the issue id for update/close/comment).
991
+ const VALUE_TAKING_ISSUE_FLAGS = new Set([
992
+ 'acceptance', 'assignee', 'blocks', 'body', 'description', 'design', 'expires',
993
+ 'id', 'issue', 'label', 'metadata', 'notes', 'parent', 'priority', 'reason',
994
+ 'status', 'title', 'type', 'close-reason', 'closed-at', 'created-by', 'dep-type',
995
+ 'priority-rank',
996
+ ]);
997
+
998
+ // Return bare operands while skipping the value consumed by each known
999
+ // space-separated issue flag. Boolean/unknown flags (for example --json) do not
1000
+ // consume the following operand; --key=value has no separate value.
1001
+ function positionalArgs(args = []) {
1002
+ const positionals = [];
1003
+ for (let index = 0; index < (args || []).length; index += 1) {
1004
+ const token = args[index];
1005
+ if (typeof token !== 'string') continue;
1006
+ if (token.startsWith('--')) {
1007
+ const flagName = token.slice(2).split('=', 1)[0];
1008
+ if (VALUE_TAKING_ISSUE_FLAGS.has(flagName)
1009
+ && !token.includes('=')
1010
+ && typeof args[index + 1] === 'string'
1011
+ && !args[index + 1].startsWith('--')) {
1012
+ index += 1;
1013
+ }
1014
+ continue;
1015
+ }
1016
+ if (!token.startsWith('-')) positionals.push(token);
1017
+ }
1018
+ return positionals;
1019
+ }
1020
+
1021
+ // First bare operand (the issue id for update/close/comment).
452
1022
  function firstPositionalArg(args = []) {
453
- return (args || []).find(value => typeof value === 'string' && !value.startsWith('-'));
1023
+ return positionalArgs(args)[0];
454
1024
  }
455
1025
 
456
1026
  // Parse the long-flag pairs CLI mutations carry. Supports BOTH forms:
@@ -588,9 +1158,11 @@ function buildCommentPayload(issueId, args) {
588
1158
  // edge insert is malformed — previously a positional invocation left issue_id
589
1159
  // undefined and the INSERT failed with a SQLite bind error).
590
1160
  function resolveDependencyEndpoints(flags, args = []) {
591
- const positionals = args.filter(arg => typeof arg === 'string' && !arg.startsWith('-'));
1161
+ const positionals = positionalArgs(args);
592
1162
  const issueId = typeof flags.issue === 'string' ? flags.issue : positionals[0];
593
- const blocksIssueId = typeof flags.blocks === 'string' ? flags.blocks : positionals[1];
1163
+ const blocksIssueId = typeof flags.blocks === 'string'
1164
+ ? flags.blocks
1165
+ : (typeof flags.issue === 'string' ? positionals[0] : positionals[1]);
594
1166
  return {
595
1167
  issue_id: typeof issueId === 'string' ? issueId : undefined,
596
1168
  blocks_issue_id: typeof blocksIssueId === 'string' ? blocksIssueId : undefined,
@@ -714,6 +1286,37 @@ function assertValidIssueMutationPayload(payload, isCreate) {
714
1286
  }
715
1287
  }
716
1288
 
1289
+ function issueNotFoundError(issueId) {
1290
+ const notFound = new Error(`Issue ${issueId ?? '<missing id>'} not found`);
1291
+ notFound.isIssueNotFound = true;
1292
+ return notFound;
1293
+ }
1294
+
1295
+ async function loadRequiredIssue(driver, issueId, context, config) {
1296
+ if (typeof issueId !== 'string' || issueId.trim() === '') {
1297
+ throw issueNotFoundError(issueId);
1298
+ }
1299
+ const entity = await driver.loadKernelEntity('issue', issueId, context, config);
1300
+ if (!entity) throw issueNotFoundError(issueId);
1301
+ return entity;
1302
+ }
1303
+
1304
+ function assertClaimEligible(operation, claimEntity, claimIssueId) {
1305
+ if (operation !== 'claim') return;
1306
+ if (isTerminalStatus(claimEntity.status)) {
1307
+ throw terminalClaimValidationError(claimIssueId, claimEntity.status);
1308
+ }
1309
+ if (claimEntity.status === 'backlog') {
1310
+ const error = new Error(
1311
+ `Cannot claim a parked (backlog) issue ${claimIssueId}: promote it first `
1312
+ + `(forge issue update ${claimIssueId} --status open)`,
1313
+ );
1314
+ error.isIssueValidation = true;
1315
+ error.validationField = 'status';
1316
+ throw error;
1317
+ }
1318
+ }
1319
+
717
1320
  // Construct the kernel event for a mutation op. For update/close/comment, the
718
1321
  // expected_revision is the FRESHLY READ stored revision (so runIssueOperation can
719
1322
  // never manufacture a stale CAS — a behind revision only arrives via a raw
@@ -741,41 +1344,28 @@ async function buildIssueMutationEvent(driver, operation, args, context, config)
741
1344
  // Dependency / claim ops target their own entity stream (no issue-row CAS): the
742
1345
  // event entity_type is 'dependency'/'claim' and entity_id is a fresh dep/claim id.
743
1346
  if (DEPENDENCY_OPERATIONS.has(operation)) {
1347
+ const endpoints = resolveDependencyEndpoints(flags, args);
1348
+ await loadRequiredIssue(driver, endpoints.issue_id, context, config);
1349
+ await loadRequiredIssue(driver, endpoints.blocks_issue_id, context, config);
744
1350
  return buildDependencyMutationEvent(operation, flags, actor, origin, context, args);
745
1351
  }
746
1352
  if (CLAIM_OPERATIONS.has(operation)) {
1353
+ const claimIssueId = typeof flags.issue === 'string' ? flags.issue : firstPositionalArg(args);
1354
+ const claimEntity = await loadRequiredIssue(driver, claimIssueId, context, config);
747
1355
  // Claim eligibility: a `backlog` (parked) issue is NOT workable — beginning it
748
1356
  // would jump backlog->in_progress and skip the required promote (backlog->open).
749
1357
  // Reject the claim here rather than minting a lease that silently activates parked
750
1358
  // work. `release` is never gated (an over-eager park must always be releasable).
751
- if (operation === 'claim') {
752
- const claimIssueId = typeof flags.issue === 'string' ? flags.issue : firstPositionalArg(args);
753
- const claimEntity = await driver.loadKernelEntity('issue', claimIssueId, context, config);
754
- if (claimEntity && claimEntity.status === 'backlog') {
755
- const err = new Error(
756
- `Cannot claim a parked (backlog) issue ${claimIssueId}: promote it first `
757
- + `(forge issue update ${claimIssueId} --status open)`,
758
- );
759
- err.isIssueValidation = true;
760
- err.validationField = 'status';
761
- throw err;
762
- }
763
- }
1359
+ assertClaimEligible(operation, claimEntity, claimIssueId);
764
1360
  return buildClaimMutationEvent(operation, flags, actor, origin, context, args);
765
1361
  }
766
1362
 
767
1363
  const issueId = firstPositionalArg(args);
768
- const entity = await driver.loadKernelEntity('issue', issueId, context, config);
769
- // Existence guard: update/close/comment target an EXISTING issue. Without this a
770
- // missing issue yields entity=null -> expected_revision 0, which the row-level CAS
1364
+ const entity = await loadRequiredIssue(driver, issueId, context, config);
1365
+ // loadRequiredIssue above enforces that update/close/comment target an EXISTING issue.
771
1366
  // accepts as a fresh insert — silently minting a phantom issue on a typo'd id (data
772
1367
  // corruption; Beads errors not-found). Signal not-found so runIssueMutation returns
773
1368
  // the contract FORGE_ISSUE_NOT_FOUND / exit 3 instead of writing a row.
774
- if (!entity) {
775
- const notFound = new Error(`Issue ${issueId ?? '<missing id>'} not found`);
776
- notFound.isIssueNotFound = true;
777
- throw notFound;
778
- }
779
1369
  const expectedRevision = Number(entity?.entity_revision || 0);
780
1370
  // For close, the driver maps event_type='issue.close' to a terminal status; an
781
1371
  // explicit --status flag (rework move) still wins via the update payload.
@@ -938,6 +1528,7 @@ async function mapMutationResult(driver, operation, event, result, context, conf
938
1528
 
939
1529
  function createLocalBroker(options = {}) {
940
1530
  const driver = options.driver;
1531
+ const now = typeof options.now === 'function' ? options.now : () => new Date().toISOString();
941
1532
  let cachedConfig;
942
1533
 
943
1534
  // Memoized config build AND the structural home of the D19 default-on
@@ -1045,6 +1636,8 @@ function createLocalBroker(options = {}) {
1045
1636
  let event;
1046
1637
  try {
1047
1638
  event = await buildIssueMutationEvent(driver, operation, args, context, config);
1639
+ const result = await runGuardedEventImpl(event, context);
1640
+ return mapMutationResult(driver, operation, event, result, context, config);
1048
1641
  } catch (err) {
1049
1642
  if (err && err.isIssueNotFound) {
1050
1643
  // Missing target for update/close/comment: return the contract not-found
@@ -1069,8 +1662,6 @@ function createLocalBroker(options = {}) {
1069
1662
  }
1070
1663
  throw err;
1071
1664
  }
1072
- const result = await runGuardedEventImpl(event, context);
1073
- return mapMutationResult(driver, operation, event, result, context, config);
1074
1665
  }
1075
1666
 
1076
1667
  return {
@@ -1211,6 +1802,107 @@ function createLocalBroker(options = {}) {
1211
1802
  return driver.upsertPr(row, context, getConfig());
1212
1803
  },
1213
1804
 
1805
+ async recordPrLinkage(input, context = {}) {
1806
+ if (typeof driver.forkConnection !== 'function') {
1807
+ throw traceError('FORGE_TRACE_UNAVAILABLE', 'recordPrLinkage requires an isolated transaction connection');
1808
+ }
1809
+ let transactionDriver;
1810
+ const config = getConfig();
1811
+ try {
1812
+ transactionDriver = driver.forkConnection(config);
1813
+ } catch {
1814
+ throw traceError('FORGE_TRACE_UNAVAILABLE', 'recordPrLinkage could not create an isolated transaction connection');
1815
+ }
1816
+ if (!transactionDriver || transactionDriver === driver || typeof transactionDriver.close !== 'function') {
1817
+ throw traceError('FORGE_TRACE_UNAVAILABLE', 'recordPrLinkage received an invalid isolated transaction connection');
1818
+ }
1819
+ try {
1820
+ return await withPrLinkageTurn(transactionDriver, async () => {
1821
+ const linkageInput = preparePrLinkageInput(input, transactionDriver);
1822
+ const evidence = derivePrEvidence(linkageInput);
1823
+ let transactionOpen = false;
1824
+ try {
1825
+ await beginImmediate(transactionDriver, config);
1826
+ transactionOpen = true;
1827
+ if (linkageInput.phase === 'opened' || context.requireExactLifecycleOwnership === true) {
1828
+ await assertExactLifecycleOwnership(transactionDriver, evidence, context, config, now());
1829
+ }
1830
+ const inferred = await transactionDriver.resolvePrLinkage({ ...linkageInput, issue_id: evidence.issue_id }, context, config);
1831
+ assertResolvedPrLinkage(inferred, evidence);
1832
+ const issueEvents = await transactionDriver.listKernelEvents('issue', evidence.issue_id, context, config);
1833
+ const gateReceipts = resolveGateReceipts(issueEvents, evidence.gate_ids, linkageInput.occurred_at);
1834
+ const issueRows = await transactionDriver.readTrace({ issue_id: evidence.issue_id, pr_number: linkageInput.number,
1835
+ repo: linkageInput.repo, git_common_dir: linkageInput.git_common_dir }, context, config);
1836
+ assertAtomicPrEvidence(issueRows, linkageInput, evidence);
1837
+ const priorPr = findPriorPr(issueRows.pull_requests, linkageInput);
1838
+ const worktreeId = inferred.worktree_id;
1839
+ const eventType = `pr.${linkageInput.phase}`;
1840
+ const payload = buildPrLinkagePayload(linkageInput, evidence, worktreeId, gateReceipts);
1841
+ assertAtomicRunIdentity(await transactionDriver.loadPrEventsByRunId(evidence.run_id, context, config), payload);
1842
+ if (priorPr && priorPr.head_sha !== evidence.head_sha) {
1843
+ throw traceError('FORGE_TRACE_STALE_AUTHORITY', 'recordPrLinkage receipt head conflicts with current PR authority');
1844
+ }
1845
+ const terminalPr = priorPr && ['merged', 'closed'].includes(priorPr.state) ? priorPr : null;
1846
+ assertTerminalPrCompatibility(terminalPr, linkageInput, evidence, worktreeId);
1847
+ const linked = terminalPr ? { ok: true, ...terminalPr } : await transactionDriver.upsertPr({
1848
+ ...linkageInput, head_sha: evidence.head_sha, issue_id: evidence.issue_id,
1849
+ worktree_id: worktreeId, registered_at: linkageInput.occurred_at,
1850
+ }, context, config);
1851
+ const idempotencyKey = `${eventType}:${linked.id}:${evidence.head_sha}`;
1852
+ const existingEvent = await transactionDriver.loadKernelEventByIdempotencyKey(idempotencyKey, context, config);
1853
+ assertCompatiblePrEvidence(existingEvent, payload);
1854
+ assertTerminalPrEvent(terminalPr, existingEvent);
1855
+ let iteration;
1856
+ if (existingEvent) {
1857
+ iteration = { decision: 'duplicate', originalEvent: existingEvent, projection: false };
1858
+ } else {
1859
+ const event = await transactionDriver.insertKernelEvent({
1860
+ entity_type: 'pr', entity_id: linked.id, event_type: eventType,
1861
+ idempotency_key: idempotencyKey, expected_revision: 0,
1862
+ actor: context.actor || 'forge', origin: context.origin || 'cli',
1863
+ created_at: linkageInput.occurred_at, payload,
1864
+ }, context, config);
1865
+ const outboxEntry = await transactionDriver.enqueueKernelProjection(
1866
+ buildProjectionOutboxEntry(event, context.projectionTarget || 'jsonl', linkageInput.occurred_at),
1867
+ context, config,
1868
+ );
1869
+ iteration = { decision: 'accept', event, outboxEntry, projection: true };
1870
+ }
1871
+ if (linkageInput.phase === 'merged' && !terminalPr) {
1872
+ await transactionDriver.retirePr({ git_common_dir: linked.git_common_dir, repo: linked.repo, number: linked.number },
1873
+ { state: 'merged', retired_at: linkageInput.occurred_at }, context, config);
1874
+ }
1875
+ await transactionDriver.exec('COMMIT;', config);
1876
+ transactionOpen = false;
1877
+ await restoreBusyTimeout(transactionDriver, config);
1878
+ return { link: linked, iteration };
1879
+ } catch (error) {
1880
+ if (transactionOpen) {
1881
+ try { await transactionDriver.exec('ROLLBACK;', config); } catch { /* preserve original error */ }
1882
+ }
1883
+ await restoreBusyTimeout(transactionDriver, config);
1884
+ throw error;
1885
+ }
1886
+ });
1887
+ } finally {
1888
+ transactionDriver.close();
1889
+ }
1890
+ },
1891
+
1892
+ async recordOpenedPrLinkage(input, context = {}) {
1893
+ if (input?.phase !== 'opened') throw traceError('FORGE_TRACE_INVALID_RECEIPT', 'recordOpenedPrLinkage requires opened phase');
1894
+ return this.recordPrLinkage(input, { ...context, requireExactLifecycleOwnership: true });
1895
+ },
1896
+
1897
+ async readTrace(target, context = {}) {
1898
+ if (!target || (!target.issue_id && target.pr_number === undefined)) {
1899
+ throw new Error('readTrace requires issue_id or pr_number');
1900
+ }
1901
+ requireDriverMethod(driver, 'readTrace');
1902
+ const config = getConfig();
1903
+ return buildTraceEnvelope(config.projectRoot, target, await driver.readTrace(target, context, config));
1904
+ },
1905
+
1214
1906
  async updatePrVerdict(key, patch = {}, context = {}) {
1215
1907
  requireDriverMethod(driver, 'updatePrVerdict');
1216
1908
  return driver.updatePrVerdict(key, patch, context, getConfig());