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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -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,47 +1344,40 @@ 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.
782
1372
  const payload = operation === 'comment'
783
1373
  ? buildCommentPayload(issueId, args)
784
1374
  : buildUpdatePayload(flags);
1375
+ // `close` already means the terminal `done` transition. Treat an explicit
1376
+ // `--status done` as that same default close intent so it does not get checked
1377
+ // as the generic (and illegal) open -> done update transition below.
1378
+ if (operation === 'close' && payload.status === 'done') {
1379
+ delete payload.status;
1380
+ }
785
1381
  // update/close validate only the supplied taxonomy fields (no mandatory title);
786
1382
  // comment carries no taxonomy fields, so it is exempt.
787
1383
  if (operation !== 'comment') {
@@ -932,6 +1528,7 @@ async function mapMutationResult(driver, operation, event, result, context, conf
932
1528
 
933
1529
  function createLocalBroker(options = {}) {
934
1530
  const driver = options.driver;
1531
+ const now = typeof options.now === 'function' ? options.now : () => new Date().toISOString();
935
1532
  let cachedConfig;
936
1533
 
937
1534
  // Memoized config build AND the structural home of the D19 default-on
@@ -1039,6 +1636,8 @@ function createLocalBroker(options = {}) {
1039
1636
  let event;
1040
1637
  try {
1041
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);
1042
1641
  } catch (err) {
1043
1642
  if (err && err.isIssueNotFound) {
1044
1643
  // Missing target for update/close/comment: return the contract not-found
@@ -1063,8 +1662,6 @@ function createLocalBroker(options = {}) {
1063
1662
  }
1064
1663
  throw err;
1065
1664
  }
1066
- const result = await runGuardedEventImpl(event, context);
1067
- return mapMutationResult(driver, operation, event, result, context, config);
1068
1665
  }
1069
1666
 
1070
1667
  return {
@@ -1177,6 +1774,16 @@ function createLocalBroker(options = {}) {
1177
1774
  return driver.importIssues(kernel, options, {}, config);
1178
1775
  },
1179
1776
 
1777
+ // Bulk activity read for `forge insights` (Slice C2). Read-only SELECT over
1778
+ // kernel_events, newest first, bounded by an optional `since` ISO cutoff + `limit`.
1779
+ // Additive: deliberately NOT registered in GUARDED_DRIVER_METHODS (the write-path
1780
+ // contract), so existing driver stubs that never call it stay valid. Imported beads
1781
+ // interactions live here as `beads.interaction.<kind>` events. Creates/migrates nothing.
1782
+ async listRecentEvents(options = {}, context = {}) {
1783
+ requireDriverMethod(driver, 'listRecentKernelEvents');
1784
+ return driver.listRecentKernelEvents(options, context, getConfig());
1785
+ },
1786
+
1180
1787
  // PR reconcile-ledger read (autonomous-shepherd design §3.4). Read-only SELECT of
1181
1788
  // the open `pr` rows for a repo (keyed by git_common_dir); creates/migrates nothing.
1182
1789
  // Consumed later by prime and the reconciler to enumerate PRs under shepherd.
@@ -1195,6 +1802,107 @@ function createLocalBroker(options = {}) {
1195
1802
  return driver.upsertPr(row, context, getConfig());
1196
1803
  },
1197
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
+
1198
1906
  async updatePrVerdict(key, patch = {}, context = {}) {
1199
1907
  requireDriverMethod(driver, 'updatePrVerdict');
1200
1908
  return driver.updatePrVerdict(key, patch, context, getConfig());