forge-workflow 0.0.9 → 0.1.0-beta.2

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 (479) hide show
  1. package/.claude/rules/{greptile-review-process.md → review-process.md} +56 -41
  2. package/.claude/scripts/{greptile-resolve.sh → review-resolve.sh} +13 -3
  3. package/.cursor/rules/permissions-guidance.mdc +2 -2
  4. package/.forge/hooks/check-tdd.js +3 -0
  5. package/.forge/hooks/forge-native-hook.js +245 -0
  6. package/.forge/protected-paths.yaml +157 -0
  7. package/AGENTS.md +151 -61
  8. package/CHANGELOG.md +681 -0
  9. package/CLAUDE.md +9 -106
  10. package/QUICKSTART.md +171 -0
  11. package/README.md +271 -363
  12. package/bin/forge-cmd.js +120 -9
  13. package/bin/forge-preflight.js +26 -5
  14. package/bin/forge.js +466 -489
  15. package/docs/INDEX.md +93 -0
  16. package/docs/PROJECT_DESIGN.md +685 -0
  17. package/docs/architecture/index.md +66 -0
  18. package/docs/architecture/notes/README.md +35 -0
  19. package/docs/architecture/subsystems/README.md +46 -0
  20. package/docs/{TOOLCHAIN.md → forge/TOOLCHAIN.md} +56 -47
  21. package/docs/forge/VALIDATION.md +82 -0
  22. package/docs/{AGENT_INSTALL_PROMPT.md → guides/AGENT_INSTALL_PROMPT.md} +3 -3
  23. package/docs/guides/BEADS_GITHUB_SYNC.md +32 -0
  24. package/docs/{ENHANCED_ONBOARDING.md → guides/ENHANCED_ONBOARDING.md} +16 -12
  25. package/docs/guides/GREPTILE_SETUP.md +46 -0
  26. package/docs/guides/MANUAL_REVIEW_GUIDE.md +58 -0
  27. package/docs/guides/MIGRATION.md +56 -0
  28. package/docs/guides/SETUP.md +118 -0
  29. package/docs/guides/SUPPORT.md +185 -0
  30. package/docs/guides/WORKFLOW_TEMPLATES.md +74 -0
  31. package/docs/guides/memory-backends.md +183 -0
  32. package/docs/reference/ADAPTERS.md +128 -0
  33. package/docs/reference/AGENT_SKILL_PARITY.md +175 -0
  34. package/docs/reference/COMMANDS.md +205 -0
  35. package/docs/reference/DECISION_DRIFT_GUARDS.md +97 -0
  36. package/docs/{EXAMPLES.md → reference/EXAMPLES.md} +7 -5
  37. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +135 -0
  38. package/docs/reference/HERMES_INTEGRATION.md +118 -0
  39. package/docs/reference/INSIGHTS_RECAP.md +63 -0
  40. package/docs/reference/INSTALL.md +164 -0
  41. package/docs/reference/KERNEL_TAXONOMY_VALIDATION.md +161 -0
  42. package/docs/reference/PROTECTED_PATH_MANIFEST.md +25 -0
  43. package/docs/reference/RELEASE.md +68 -0
  44. package/docs/reference/RESEARCH_TEMPLATE.md +292 -0
  45. package/docs/{ROADMAP.md → reference/ROADMAP.md} +12 -9
  46. package/docs/reference/SKILLS.md +35 -0
  47. package/docs/reference/STATUS_BOARD.md +80 -0
  48. package/docs/reference/TEMPLATES.md +106 -0
  49. package/docs/reference/TOOLCHAIN.md +658 -0
  50. package/docs/reference/VALIDATION.md +82 -0
  51. package/docs/reference/agent-permissions.md +169 -0
  52. package/docs/reference/beads-to-kernel-migration-ux.md +61 -0
  53. package/docs/reference/control-plane-guarantees.md +125 -0
  54. package/docs/reference/dependency-chain.md +331 -0
  55. package/docs/reference/forge-kernel-issue-command-contract.md +161 -0
  56. package/docs/reference/forge-kernel-schema.md +72 -0
  57. package/docs/reference/kernel-conflict-evaluators.md +27 -0
  58. package/docs/reference/patch-md-format.md +77 -0
  59. package/docs/reference/protected-state-surfaces.md +59 -0
  60. package/docs/reference/shepherd.md +115 -0
  61. package/docs/reference/superpowers-analysis.md +320 -0
  62. package/docs/reference/superpowers-integration-options.md +404 -0
  63. package/docs/reference/test-environment.md +519 -0
  64. package/docs/reference/upgrade-safety.md +59 -0
  65. package/lefthook.yml +18 -0
  66. package/lib/adapter-cli.js +307 -0
  67. package/lib/adapters/beads-issue-adapter.js +127 -0
  68. package/lib/adapters/beads-kernel-compat.js +1042 -0
  69. package/lib/adapters/greptile-review-adapter.js +141 -0
  70. package/lib/adapters/kernel-issue-adapter.js +101 -0
  71. package/lib/adapters/pr-state-adapter.js +484 -0
  72. package/lib/adoption-profiles.js +126 -0
  73. package/lib/agents/README.md +2 -6
  74. package/lib/agents/claude.plugin.json +3 -8
  75. package/lib/agents/codex.plugin.json +9 -1
  76. package/lib/agents/cursor.plugin.json +2 -6
  77. package/lib/agents/hermes.plugin.json +22 -0
  78. package/lib/agents-config.js +39 -1236
  79. package/lib/audit-evidence.js +282 -0
  80. package/lib/beads-setup.js +225 -28
  81. package/lib/beads-sync-scaffold.js +36 -107
  82. package/lib/codex-skills.js +51 -1
  83. package/lib/commands/_issue.js +744 -70
  84. package/lib/commands/_manifest.js +91 -0
  85. package/lib/commands/_registry.js +85 -34
  86. package/lib/commands/_resolve-command-opts.js +261 -0
  87. package/lib/commands/_serve-security.js +270 -0
  88. package/lib/commands/adapter.js +12 -0
  89. package/lib/commands/add.js +118 -0
  90. package/lib/commands/audit.js +70 -0
  91. package/lib/commands/blocked.js +5 -0
  92. package/lib/commands/board.js +64 -0
  93. package/lib/commands/claim.js +21 -2
  94. package/lib/commands/claims.js +7 -0
  95. package/lib/commands/clean.js +485 -75
  96. package/lib/commands/close.js +2 -2
  97. package/lib/commands/comment.js +5 -0
  98. package/lib/commands/control.js +148 -0
  99. package/lib/commands/create.js +2 -2
  100. package/lib/commands/dev.js +185 -7
  101. package/lib/commands/doc-gate.js +336 -0
  102. package/lib/commands/doctor.js +156 -0
  103. package/lib/commands/explain.js +15 -0
  104. package/lib/commands/export.js +237 -0
  105. package/lib/commands/gate.js +192 -0
  106. package/lib/commands/hooks.js +242 -0
  107. package/lib/commands/inbox.js +118 -0
  108. package/lib/commands/init.js +598 -0
  109. package/lib/commands/insights.js +79 -0
  110. package/lib/commands/issue.js +12 -1
  111. package/lib/commands/issues.js +66 -0
  112. package/lib/commands/lint.js +5 -0
  113. package/lib/commands/list.js +2 -2
  114. package/lib/commands/merge.js +312 -0
  115. package/lib/commands/migrate.js +523 -0
  116. package/lib/commands/new.js +12 -0
  117. package/lib/commands/options.js +241 -0
  118. package/lib/commands/orient.js +13 -0
  119. package/lib/commands/orphans.js +5 -0
  120. package/lib/commands/patch.js +67 -0
  121. package/lib/commands/plan.js +436 -24
  122. package/lib/commands/preflight.js +211 -0
  123. package/lib/commands/prime.js +13 -0
  124. package/lib/commands/push.js +69 -2
  125. package/lib/commands/ready.js +2 -2
  126. package/lib/commands/recall.js +116 -0
  127. package/lib/commands/recap.js +61 -0
  128. package/lib/commands/recommend.js +22 -2
  129. package/lib/commands/release.js +91 -0
  130. package/lib/commands/remember.js +74 -0
  131. package/lib/commands/role.js +99 -0
  132. package/lib/commands/serve.js +581 -0
  133. package/lib/commands/setup.js +851 -979
  134. package/lib/commands/shepherd.js +436 -0
  135. package/lib/commands/ship.js +23 -1
  136. package/lib/commands/show.js +2 -2
  137. package/lib/commands/stage.js +192 -0
  138. package/lib/commands/stale.js +5 -0
  139. package/lib/commands/status.js +329 -11
  140. package/lib/commands/sync.js +34 -46
  141. package/lib/commands/team.js +15 -2
  142. package/lib/commands/test.js +58 -7
  143. package/lib/commands/update.js +2 -2
  144. package/lib/commands/upgrade.js +47 -0
  145. package/lib/commands/validate.js +56 -25
  146. package/lib/commands/worktree.js +308 -128
  147. package/lib/config-writer.js +202 -0
  148. package/lib/control-plane.js +236 -0
  149. package/lib/core/runtime-graph.js +946 -0
  150. package/lib/dep-guard/keyword-ripple.js +184 -0
  151. package/lib/deprecated-sync-cleanup.js +362 -0
  152. package/lib/detect-agent.js +2 -28
  153. package/lib/detect-worktree.js +42 -17
  154. package/lib/doc-gate/declaration.js +177 -0
  155. package/lib/doc-gate/detect.js +289 -0
  156. package/lib/doc-gate/gate.js +375 -0
  157. package/lib/doc-gate/okf-config.js +128 -0
  158. package/lib/doc-gate/okf.js +429 -0
  159. package/lib/docs-command.js +1161 -6
  160. package/lib/forge-issues.js +697 -0
  161. package/lib/forge-lock.js +262 -0
  162. package/lib/gate-events.js +193 -0
  163. package/lib/global-flags.js +74 -0
  164. package/lib/greptile-match.js +7 -63
  165. package/lib/harness-capability-matrix.js +380 -0
  166. package/lib/hook-global-installer.js +347 -0
  167. package/lib/hook-renderer.js +451 -0
  168. package/lib/inbox.js +391 -0
  169. package/lib/insights.js +397 -0
  170. package/lib/issue-adapter.js +156 -0
  171. package/lib/issue-backend.js +145 -0
  172. package/lib/issue-render.js +220 -0
  173. package/lib/issue-sync/authority.js +100 -0
  174. package/lib/issue-sync/github-pull.js +184 -0
  175. package/lib/issue-sync/import-primitives.js +98 -0
  176. package/lib/issue-sync/legacy-link-bridge.js +436 -0
  177. package/lib/issue-sync/link-store.js +292 -0
  178. package/lib/issue-sync/project-github.js +123 -0
  179. package/lib/issue-sync/reconcile.js +195 -0
  180. package/lib/issue-sync/schema.js +126 -0
  181. package/lib/kernel/backing-issue.js +305 -0
  182. package/lib/kernel/broker.js +1218 -0
  183. package/lib/kernel/cli-broker-factory.js +130 -0
  184. package/lib/kernel/conflict-signal.js +82 -0
  185. package/lib/kernel/evaluators.js +195 -0
  186. package/lib/kernel/fs-class.js +495 -0
  187. package/lib/kernel/issue-command-contract.js +559 -0
  188. package/lib/kernel/issue-id-resolver.js +186 -0
  189. package/lib/kernel/lease-enforcer.js +158 -0
  190. package/lib/kernel/migrations.js +333 -0
  191. package/lib/kernel/planning-buckets-schema.js +109 -0
  192. package/lib/kernel/projection-jsonl-writer.js +450 -0
  193. package/lib/kernel/readiness-model.js +329 -0
  194. package/lib/kernel/schema.js +356 -0
  195. package/lib/kernel/sqlite-driver.js +2504 -0
  196. package/lib/kernel/taxonomy-validator.js +394 -0
  197. package/lib/lefthook-check.js +8 -4
  198. package/lib/lefthook-wiring.js +413 -0
  199. package/lib/mcp-config-renderer.js +288 -0
  200. package/lib/memory/graphiti-mcp.js +106 -0
  201. package/lib/memory/router.js +387 -0
  202. package/lib/memory/typed-api.js +102 -0
  203. package/lib/memory-digest.js +195 -0
  204. package/lib/merge-rules.js +395 -0
  205. package/lib/migrate-dry-run.js +466 -0
  206. package/lib/orientation.js +863 -0
  207. package/lib/package-manager-remediation.js +103 -0
  208. package/lib/package-root.js +381 -0
  209. package/lib/patch-intent.js +890 -0
  210. package/lib/plugin-catalog.js +3 -4
  211. package/lib/plugin-manager.js +0 -5
  212. package/lib/pr-bundle.js +186 -0
  213. package/lib/pr-monitor/differ.js +195 -0
  214. package/lib/pr-monitor/events.js +0 -0
  215. package/lib/pr-monitor/gather.js +124 -0
  216. package/lib/pr-monitor/journal.js +299 -0
  217. package/lib/pr-monitor/monitor.js +146 -0
  218. package/lib/pr-monitor/render-sticky.js +157 -0
  219. package/lib/pr-monitor/watch-lifecycle.js +95 -0
  220. package/lib/pr-monitor/watch.js +247 -0
  221. package/lib/pr-pull.js +1273 -0
  222. package/lib/pr-shepherd.js +494 -0
  223. package/lib/pr-state-validator.js +59 -0
  224. package/lib/preflight/gates.js +237 -0
  225. package/lib/preflight/runner.js +116 -0
  226. package/lib/project-discovery.js +0 -53
  227. package/lib/project-memory.js +166 -0
  228. package/lib/protected-path-manifest.js +281 -0
  229. package/lib/protected-state-surfaces.js +387 -0
  230. package/lib/release-readiness.js +2089 -0
  231. package/lib/reset.js +59 -45
  232. package/lib/review-adapter.js +68 -0
  233. package/lib/rules-sync.js +260 -0
  234. package/lib/runtime-health.js +332 -23
  235. package/lib/safety-config-renderer.js +268 -0
  236. package/lib/setup-action-log.js +1 -7
  237. package/lib/setup.js +27 -65
  238. package/lib/shell-utils.js +76 -6
  239. package/lib/skills-sync.js +330 -0
  240. package/lib/smart-status/conflicts.js +205 -0
  241. package/lib/smart-status/scoring.js +191 -0
  242. package/lib/status/beads-snapshot.js +145 -0
  243. package/lib/status/presenter.js +216 -0
  244. package/lib/status/snapshot.js +186 -0
  245. package/lib/sync-backend.js +202 -0
  246. package/lib/untrusted-content.js +52 -0
  247. package/lib/upgrade-safety.js +199 -0
  248. package/lib/workflow/enforce-stage.js +298 -47
  249. package/lib/workflow/stage-transition.js +115 -0
  250. package/lib/workflow/stages.js +30 -6
  251. package/lib/workflow/state-manager.js +159 -14
  252. package/lib/workflow/state.js +23 -1
  253. package/lib/workflow-profiles.js +17 -5
  254. package/package.json +46 -36
  255. package/rules/documentation.md +19 -0
  256. package/rules/kernel-tracking.md +26 -0
  257. package/rules/security.md +22 -0
  258. package/rules/tdd.md +20 -0
  259. package/rules/workflow.md +27 -0
  260. package/scripts/auto-backing-issue.js +47 -0
  261. package/scripts/beads-context.sh +165 -22
  262. package/scripts/beads-migrate-to-dolt.sh +7 -0
  263. package/scripts/beads-upgrade-smoke.sh +284 -0
  264. package/scripts/behavioral-judge.sh +115 -11
  265. package/scripts/benchmark.js +349 -63
  266. package/scripts/bootstrap-windows-tools.sh +78 -0
  267. package/scripts/branch-protection.js +2 -3
  268. package/scripts/check-agents.js +34 -137
  269. package/scripts/commitlint.js +3 -1
  270. package/scripts/conflict-detect.sh +3 -0
  271. package/scripts/dep-guard-analyze.js +52 -17
  272. package/scripts/dep-guard-keyword-ripple.js +29 -0
  273. package/scripts/dep-guard-render-review.js +86 -0
  274. package/scripts/dep-guard.sh +64 -232
  275. package/scripts/file-index.sh +3 -0
  276. package/scripts/forge-team/lib/claim.sh +34 -18
  277. package/scripts/forge-team/lib/dashboard.sh +61 -86
  278. package/scripts/forge-team/lib/epic.sh +99 -263
  279. package/scripts/forge-team/lib/hooks.sh +26 -28
  280. package/scripts/forge-team/lib/identity.sh +4 -4
  281. package/scripts/forge-team/lib/sync-github.sh +144 -47
  282. package/scripts/forge-team/lib/verify.sh +93 -83
  283. package/scripts/forge-team/lib/workload.sh +41 -65
  284. package/scripts/forge-team/tests/claim.test.sh +25 -19
  285. package/scripts/forge-team/tests/dashboard.test.sh +31 -46
  286. package/scripts/forge-team/tests/epic.test.sh +52 -71
  287. package/scripts/forge-team/tests/hooks.test.sh +38 -50
  288. package/scripts/forge-team/tests/identity.test.sh +3 -3
  289. package/scripts/forge-team/tests/integration.test.sh +44 -66
  290. package/scripts/forge-team/tests/sync-github.test.sh +183 -79
  291. package/scripts/forge-team/tests/verify.test.sh +37 -46
  292. package/scripts/forge-team/tests/workflow-integration.test.sh +4 -4
  293. package/scripts/forge-team/tests/workload.test.sh +32 -66
  294. package/scripts/gen-command-manifest.js +153 -0
  295. package/scripts/gen-embedded-assets.mjs +129 -0
  296. package/scripts/install.ps1 +139 -0
  297. package/scripts/install.sh +268 -0
  298. package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
  299. package/scripts/lib/release-asset.mjs +84 -0
  300. package/scripts/parity-check.mjs +145 -0
  301. package/scripts/parity-check.test.mjs +58 -0
  302. package/scripts/pin-agentic-workflow-images.js +112 -0
  303. package/scripts/pr-coordinator.sh +3 -0
  304. package/scripts/preflight-sonar.eslint.config.mjs +44 -0
  305. package/scripts/preflight.sh +108 -0
  306. package/scripts/protected-state-check.js +104 -0
  307. package/scripts/smart-status-score.js +31 -0
  308. package/scripts/smart-status-sessions.js +51 -0
  309. package/scripts/smart-status.sh +117 -369
  310. package/scripts/spikes/config-race-bench.js +111 -0
  311. package/scripts/spikes/harness-capability-matrix.js +13 -0
  312. package/scripts/spikes/patch-anchor-stability-bench.js +125 -0
  313. package/scripts/spikes/protected-path-manifest.js +20 -0
  314. package/scripts/spikes/skill-auto-invoke-parity.js +292 -0
  315. package/scripts/sync-agent-skills.js +62 -0
  316. package/scripts/sync-agentic-workflow.js +48 -0
  317. package/scripts/sync-utils.sh +3 -0
  318. package/scripts/test-ci-shard.js +251 -0
  319. package/scripts/test-dashboard.js +188 -52
  320. package/scripts/test-full-suite.js +186 -0
  321. package/scripts/test-profile.js +278 -0
  322. package/scripts/test.js +302 -28
  323. package/scripts/validate.js +143 -0
  324. package/scripts/validate.sh +18 -1
  325. package/skills/claim-safety/SKILL.md +102 -0
  326. package/skills/claim-safety/evals/evals.json +46 -0
  327. package/{.github/prompts/dev.prompt.md → skills/dev/SKILL.md} +46 -52
  328. package/skills/dev/evals/evals.json +50 -0
  329. package/skills/hermes-forge/SKILL.md +185 -0
  330. package/skills/hermes-forge/evals/evals.json +46 -0
  331. package/skills/issue-basics/SKILL.md +111 -0
  332. package/skills/issue-basics/evals/evals.json +46 -0
  333. package/skills/kernel/SKILL.md +166 -0
  334. package/skills/kernel/evals/evals.json +50 -0
  335. package/skills/memory/SKILL.md +102 -0
  336. package/skills/parallel-deep-research/SKILL.md +14 -11
  337. package/skills/parallel-deep-research/evals/evals.json +11 -27
  338. package/{.github/prompts/plan.prompt.md → skills/plan/SKILL.md} +134 -159
  339. package/skills/plan/evals/evals.json +42 -0
  340. package/skills/research/SKILL.md +195 -0
  341. package/skills/research/evals/evals.json +42 -0
  342. package/{.github/prompts/review.prompt.md → skills/review/SKILL.md} +98 -62
  343. package/skills/review/evals/evals.json +42 -0
  344. package/skills/rollback/SKILL.md +110 -0
  345. package/skills/rollback/evals/evals.json +46 -0
  346. package/skills/rollback/references/methods.md +204 -0
  347. package/{.cursor/commands/rollback.md → skills/rollback/references/workflow-integration.md} +10 -284
  348. package/skills/shepherd/SKILL.md +66 -0
  349. package/skills/shepherd/evals/evals.json +42 -0
  350. package/skills/ship/SKILL.md +251 -0
  351. package/skills/ship/evals/evals.json +42 -0
  352. package/skills/smith/SKILL.md +142 -0
  353. package/skills/smith/evals/evals.json +46 -0
  354. package/skills/smith/references/autonomy-and-gates.md +94 -0
  355. package/{.github/prompts/sonarcloud.prompt.md → skills/sonarcloud/SKILL.md} +14 -3
  356. package/skills/sonarcloud/evals/evals.json +46 -0
  357. package/skills/sonarcloud-analysis/SKILL.md +18 -13
  358. package/skills/sonarcloud-analysis/evals/evals.json +11 -15
  359. package/skills/status/SKILL.md +102 -0
  360. package/skills/status/evals/evals.json +50 -0
  361. package/skills/triage-ready/SKILL.md +121 -0
  362. package/skills/triage-ready/evals/evals.json +42 -0
  363. package/{.github/prompts/validate.prompt.md → skills/validate/SKILL.md} +52 -29
  364. package/skills/validate/evals/evals.json +42 -0
  365. package/skills/verify/SKILL.md +299 -0
  366. package/skills/verify/evals/evals.json +50 -0
  367. package/.claude/commands/dev.md +0 -345
  368. package/.claude/commands/plan.md +0 -566
  369. package/.claude/commands/premerge.md +0 -186
  370. package/.claude/commands/research.md +0 -42
  371. package/.claude/commands/review.md +0 -451
  372. package/.claude/commands/rollback.md +0 -721
  373. package/.claude/commands/ship.md +0 -213
  374. package/.claude/commands/sonarcloud.md +0 -152
  375. package/.claude/commands/status.md +0 -90
  376. package/.claude/commands/validate.md +0 -288
  377. package/.claude/commands/verify.md +0 -269
  378. package/.claude/rules/workflow.md +0 -121
  379. package/.cline/workflows/dev.md +0 -342
  380. package/.cline/workflows/plan.md +0 -563
  381. package/.cline/workflows/premerge.md +0 -183
  382. package/.cline/workflows/research.md +0 -39
  383. package/.cline/workflows/review.md +0 -448
  384. package/.cline/workflows/rollback.md +0 -718
  385. package/.cline/workflows/ship.md +0 -210
  386. package/.cline/workflows/sonarcloud.md +0 -146
  387. package/.cline/workflows/status.md +0 -87
  388. package/.cline/workflows/validate.md +0 -285
  389. package/.cline/workflows/verify.md +0 -266
  390. package/.codex/config.toml +0 -11
  391. package/.codex/skills/dev/SKILL.md +0 -345
  392. package/.codex/skills/plan/SKILL.md +0 -566
  393. package/.codex/skills/premerge/SKILL.md +0 -186
  394. package/.codex/skills/research/SKILL.md +0 -42
  395. package/.codex/skills/review/SKILL.md +0 -451
  396. package/.codex/skills/rollback/SKILL.md +0 -721
  397. package/.codex/skills/ship/SKILL.md +0 -213
  398. package/.codex/skills/sonarcloud/SKILL.md +0 -149
  399. package/.codex/skills/status/SKILL.md +0 -90
  400. package/.codex/skills/validate/SKILL.md +0 -288
  401. package/.codex/skills/verify/SKILL.md +0 -269
  402. package/.cursor/commands/dev.md +0 -342
  403. package/.cursor/commands/plan.md +0 -563
  404. package/.cursor/commands/premerge.md +0 -183
  405. package/.cursor/commands/research.md +0 -39
  406. package/.cursor/commands/review.md +0 -448
  407. package/.cursor/commands/ship.md +0 -210
  408. package/.cursor/commands/sonarcloud.md +0 -146
  409. package/.cursor/commands/status.md +0 -87
  410. package/.cursor/commands/validate.md +0 -285
  411. package/.cursor/commands/verify.md +0 -266
  412. package/.cursorrules +0 -149
  413. package/.github/prompts/premerge.prompt.md +0 -188
  414. package/.github/prompts/research.prompt.md +0 -44
  415. package/.github/prompts/rollback.prompt.md +0 -723
  416. package/.github/prompts/ship.prompt.md +0 -215
  417. package/.github/prompts/status.prompt.md +0 -92
  418. package/.github/prompts/verify.prompt.md +0 -271
  419. package/.github/workflows/beads-to-github.yml +0 -56
  420. package/.github/workflows/github-to-beads.yml +0 -97
  421. package/.kilocode/workflows/dev.md +0 -346
  422. package/.kilocode/workflows/plan.md +0 -567
  423. package/.kilocode/workflows/premerge.md +0 -187
  424. package/.kilocode/workflows/research.md +0 -43
  425. package/.kilocode/workflows/review.md +0 -452
  426. package/.kilocode/workflows/rollback.md +0 -722
  427. package/.kilocode/workflows/ship.md +0 -214
  428. package/.kilocode/workflows/sonarcloud.md +0 -150
  429. package/.kilocode/workflows/status.md +0 -91
  430. package/.kilocode/workflows/validate.md +0 -289
  431. package/.kilocode/workflows/verify.md +0 -270
  432. package/.opencode/commands/dev.md +0 -345
  433. package/.opencode/commands/plan.md +0 -566
  434. package/.opencode/commands/premerge.md +0 -186
  435. package/.opencode/commands/research.md +0 -42
  436. package/.opencode/commands/review.md +0 -451
  437. package/.opencode/commands/rollback.md +0 -721
  438. package/.opencode/commands/ship.md +0 -213
  439. package/.opencode/commands/sonarcloud.md +0 -149
  440. package/.opencode/commands/status.md +0 -90
  441. package/.opencode/commands/validate.md +0 -288
  442. package/.opencode/commands/verify.md +0 -269
  443. package/.roo/commands/dev.md +0 -346
  444. package/.roo/commands/plan.md +0 -567
  445. package/.roo/commands/premerge.md +0 -187
  446. package/.roo/commands/research.md +0 -43
  447. package/.roo/commands/review.md +0 -452
  448. package/.roo/commands/rollback.md +0 -722
  449. package/.roo/commands/ship.md +0 -214
  450. package/.roo/commands/sonarcloud.md +0 -150
  451. package/.roo/commands/status.md +0 -91
  452. package/.roo/commands/validate.md +0 -289
  453. package/.roo/commands/verify.md +0 -270
  454. package/docs/BEADS_GITHUB_SYNC.md +0 -255
  455. package/docs/GREPTILE_SETUP.md +0 -400
  456. package/docs/MANUAL_REVIEW_GUIDE.md +0 -106
  457. package/docs/SETUP.md +0 -663
  458. package/docs/VALIDATION.md +0 -363
  459. package/lib/agents/cline.plugin.json +0 -29
  460. package/lib/agents/copilot.plugin.json +0 -24
  461. package/lib/agents/kilocode.plugin.json +0 -22
  462. package/lib/agents/opencode.plugin.json +0 -23
  463. package/lib/agents/roo.plugin.json +0 -30
  464. package/lib/beads-health-check.js +0 -143
  465. package/lib/commands/commands-reset.js +0 -147
  466. package/opencode.json +0 -67
  467. package/scripts/beads-context.test.js +0 -567
  468. package/scripts/github-beads-sync/comment.mjs +0 -64
  469. package/scripts/github-beads-sync/config.mjs +0 -148
  470. package/scripts/github-beads-sync/github-api.mjs +0 -131
  471. package/scripts/github-beads-sync/index.mjs +0 -332
  472. package/scripts/github-beads-sync/label-mapper.mjs +0 -54
  473. package/scripts/github-beads-sync/mapping.mjs +0 -78
  474. package/scripts/github-beads-sync/reverse-sync-cli.mjs +0 -31
  475. package/scripts/github-beads-sync/reverse-sync.mjs +0 -138
  476. package/scripts/github-beads-sync/run-bd.mjs +0 -161
  477. package/scripts/github-beads-sync/sanitize.mjs +0 -121
  478. package/scripts/github-beads-sync.config.json +0 -26
  479. package/scripts/sync-commands.js +0 -600
@@ -0,0 +1,1218 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('node:child_process');
4
+ const path = require('node:path');
5
+ const { randomUUID } = require('node:crypto');
6
+ const { buildConflict, evaluateKernelEvent, normalizePayload } = require('./evaluators');
7
+ const { CONFLICT_SIGNAL, classifyConflictSignal } = require('./conflict-signal');
8
+ const { buildKernelMigrationPlan } = require('./migrations');
9
+ const { buildClaimConflict, isValidExpiresAt, planClaimAcquisition } = require('./lease-enforcer');
10
+ const {
11
+ ISSUE_COMMAND_EXIT_CODES,
12
+ ISSUE_COMMAND_SCHEMA_VERSION,
13
+ formatIssueCommandError,
14
+ getIssueCommandContract,
15
+ isValidIssuePriority,
16
+ normalizePriority,
17
+ } = require('./issue-command-contract');
18
+ const { isValidIssueStatus, isValidStatusTransition, rankForPriorityLabel } = require('./taxonomy-validator');
19
+ const { assertFilesystemSafeForKernel } = require('./fs-class');
20
+ const { resolveIssueIdArgs } = require('./issue-id-resolver');
21
+
22
+ const LOCAL_BROKER_PRAGMAS = Object.freeze([
23
+ 'PRAGMA journal_mode=WAL;',
24
+ 'PRAGMA synchronous=NORMAL;',
25
+ 'PRAGMA foreign_keys=ON;',
26
+ 'PRAGMA busy_timeout=5000;',
27
+ ]);
28
+
29
+ // Bounded wall-clock ceiling for the single `git rev-parse --git-common-dir`
30
+ // probe below. This synchronous spawn has intermittently hung on Windows/Node
31
+ // (issue ba388d01), and because it runs on every kernel broker init it can wedge
32
+ // any command that touches the store (e.g. `forge remember`) until an outer
33
+ // timeout kills it. The bound only guards against a *pathological* hang: the
34
+ // original hang ran past 15s before it was SIGTERM'd, so 30s still fails-fast on
35
+ // a true wedge while tolerating a legitimately-slow git on a loaded CI runner (a
36
+ // 5s bound false-timed-out a healthy ~5.3s git call on the windows/Node22
37
+ // matrix). Do not go back to unbounded.
38
+ const GIT_COMMON_DIR_TIMEOUT_MS = 30000;
39
+
40
+ function normalizeExecOutput(output) {
41
+ if (Buffer.isBuffer(output)) {
42
+ return output.toString('utf8');
43
+ }
44
+ return String(output || '');
45
+ }
46
+
47
+ // Conventional common dir for the ordinary (non-worktree) case. Used as the
48
+ // graceful fallback when the git probe is too slow, hangs, or errors — a working
49
+ // store location beats crashing every store-touching command.
50
+ function defaultGitCommonDir(projectRoot) {
51
+ return path.resolve(projectRoot, '.git');
52
+ }
53
+
54
+ function resolveGitCommonDir(projectRoot, deps = {}) {
55
+ if (!projectRoot) {
56
+ throw new Error('projectRoot is required to resolve the Kernel broker common-dir');
57
+ }
58
+
59
+ const exec = deps.execFileSync || execFileSync;
60
+ let rawCommonDir;
61
+ try {
62
+ rawCommonDir = normalizeExecOutput(exec('git', [
63
+ '-C',
64
+ projectRoot,
65
+ 'rev-parse',
66
+ '--git-common-dir',
67
+ ], { encoding: 'utf8', timeout: GIT_COMMON_DIR_TIMEOUT_MS })).trim();
68
+ } catch (error) {
69
+ // A timed-out (ETIMEDOUT) or otherwise-failing git MUST NOT crash the
70
+ // caller: `resolveGitCommonDir` runs on every kernel broker init, so a
71
+ // throw here breaks `forge remember`/`recall` and every store-touching
72
+ // command. Degrade gracefully to the conventional <projectRoot>/.git so
73
+ // the store still resolves. This is correct for the common non-worktree
74
+ // case; inside a worktree it would ideally share the main repo's .git, but
75
+ // a working fallback beats a hard failure on a pathological git hang.
76
+ const warn = deps.warn || ((message) => console.warn(message));
77
+ warn(`[forge] git rev-parse --git-common-dir failed (${error.code || error.message}); `
78
+ + `falling back to ${defaultGitCommonDir(projectRoot)}`);
79
+ return defaultGitCommonDir(projectRoot);
80
+ }
81
+
82
+ if (!rawCommonDir) {
83
+ // Empty output is not a hang, but treat it as the same graceful fallback
84
+ // rather than throwing so an odd git build cannot brick the store.
85
+ return defaultGitCommonDir(projectRoot);
86
+ }
87
+
88
+ return path.resolve(path.isAbsolute(rawCommonDir)
89
+ ? rawCommonDir
90
+ : path.join(projectRoot, rawCommonDir));
91
+ }
92
+
93
+ function buildLocalBrokerConfig(options = {}) {
94
+ const projectRoot = options.projectRoot || process.cwd();
95
+ const gitCommonDir = path.resolve(options.gitCommonDir || resolveGitCommonDir(projectRoot, options));
96
+ const brokerDir = path.join(gitCommonDir, 'forge');
97
+
98
+ return {
99
+ mode: 'local',
100
+ storage: 'sqlite',
101
+ journalMode: 'WAL',
102
+ synchronous: 'NORMAL',
103
+ foreignKeys: true,
104
+ busyTimeoutMs: 5000,
105
+ projectRoot,
106
+ gitCommonDir,
107
+ brokerDir,
108
+ databasePath: options.databasePath || path.join(brokerDir, 'kernel.sqlite'),
109
+ pragmas: [...LOCAL_BROKER_PRAGMAS],
110
+ migrationPlan: options.migrationPlan || buildKernelMigrationPlan(),
111
+ };
112
+ }
113
+
114
+ function requireDriverMethod(driver, methodName) {
115
+ if (!driver || typeof driver[methodName] !== 'function') {
116
+ throw new Error(`Kernel local broker driver must provide ${methodName}()`);
117
+ }
118
+ }
119
+
120
+ // The migration ledger records which migration ids have been applied so initialize()
121
+ // applies each migration AT MOST ONCE — no full re-run, no blanket duplicate-error
122
+ // swallow. The ledger table is broker-managed bookkeeping (created before the plan is
123
+ // consulted), so it is intentionally NOT part of the migration schema/plan.
124
+ const MIGRATION_LEDGER_TABLE = 'kernel_migrations';
125
+ const MIGRATION_LEDGER_CREATE = `CREATE TABLE IF NOT EXISTS ${MIGRATION_LEDGER_TABLE} (\n id TEXT NOT NULL PRIMARY KEY,\n applied_at TEXT NOT NULL\n);`;
126
+
127
+ // Guarded ADD COLUMN: a PRECONDITION check (PRAGMA table_info) plus a TOCTOU catch
128
+ // fallback. SQLite has no `ADD COLUMN IF NOT EXISTS`, and the target column can already
129
+ // exist on a fresh DB (001 never seeds migration-added columns, but a pre-ledger DB the
130
+ // old re-run model already migrated will have it). Skip the ADD when the column is
131
+ // already present; if a concurrent initialize() adds it between the check and the run,
132
+ // the catch re-verifies the post-state and skips too. ANY other SQL error still
133
+ // propagates — the discarded blanket swallow could not, because it trusted the error
134
+ // string instead of confirming the actual column state.
135
+ function parseAddColumnTarget(statement) {
136
+ const match = /^\s*ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)\b/i.exec(String(statement || ''));
137
+ return match ? { table: match[1], column: match[2] } : null;
138
+ }
139
+
140
+ async function columnExists(driver, table, column, config) {
141
+ const rows = await driver.queryAll(`PRAGMA table_info(${table});`, config);
142
+ return Array.isArray(rows) && rows.some(row => row && row.name === column);
143
+ }
144
+
145
+ // Record a migration as applied. The id is a controlled internal constant, validated to
146
+ // a ledger-safe charset before interpolation (the broker driver exposes only param-less
147
+ // exec). INSERT OR IGNORE makes a concurrent double-init a harmless no-op.
148
+ function recordMigrationSql(id) {
149
+ if (typeof id !== 'string' || !/^[0-9a-z_]+$/i.test(id)) {
150
+ throw new Error(`Kernel migration id is not ledger-safe: ${id}`);
151
+ }
152
+ return `INSERT OR IGNORE INTO ${MIGRATION_LEDGER_TABLE} (id, applied_at) VALUES ('${id}', '${new Date().toISOString()}');`;
153
+ }
154
+
155
+ // Conflict classification is delegated to the typed classifier at the driver
156
+ // boundary (lib/kernel/conflict-signal.js). The broker branches on stable TYPED
157
+ // codes and NEVER parses raw SQLite error text — that error-dialect knowledge
158
+ // lives in one place, so a future backend (libSQL/Turso/CF) is decoupled here.
159
+ function isIdempotencyConflict(error) {
160
+ return classifyConflictSignal(error) === CONFLICT_SIGNAL.UNIQUE_IDEMPOTENCY;
161
+ }
162
+
163
+ // The active-lease partial UNIQUE index (kernel_claims.issue_id) — a distinct code
164
+ // from a duplicate-id (kernel_claims.id) violation, which classifies as null and is
165
+ // re-thrown rather than misclassified as a lease conflict.
166
+ function isClaimLeaseConflict(error) {
167
+ return classifyConflictSignal(error) === CONFLICT_SIGNAL.UNIQUE_CLAIM_LEASE;
168
+ }
169
+
170
+ // The driver's applyAcceptedIssueMutation tags a lost-update collision structurally
171
+ // (its row-level CAS UPDATE matched 0 rows because the issue's revision moved
172
+ // between the evaluator's out-of-transaction pre-read and the serialized commit).
173
+ // classifyConflictSignal maps that driver-supplied tag to CAS_STALE.
174
+ function isRevisionConflict(error) {
175
+ return classifyConflictSignal(error) === CONFLICT_SIGNAL.CAS_STALE;
176
+ }
177
+
178
+ async function execMigrationStatement(driver, statement, config) {
179
+ const addColumn = parseAddColumnTarget(statement);
180
+ if (addColumn && await columnExists(driver, addColumn.table, addColumn.column, config)) {
181
+ return { skipped: true, reason: 'column-already-exists' };
182
+ }
183
+ try {
184
+ await driver.exec(statement, config);
185
+ } catch (error) {
186
+ // TOCTOU: a concurrent initialize() can add this column between our pre-check
187
+ // and this exec — both readers saw it missing; the other writer won. Re-verify
188
+ // the actual post-state via PRAGMA: if the column is now present the migration's
189
+ // intent is satisfied, so skip. This is post-condition verification, NOT the
190
+ // discarded error-string swallow. Any other failure — and any failure where the
191
+ // column still isn't there — propagates. If the re-check itself throws, surface
192
+ // the ORIGINAL exec error, not the re-check's.
193
+ if (addColumn) {
194
+ try {
195
+ if (await columnExists(driver, addColumn.table, addColumn.column, config)) {
196
+ return { skipped: true, reason: 'column-added-concurrently' };
197
+ }
198
+ } catch {
199
+ throw error; // re-check failed — surface the ORIGINAL exec error
200
+ }
201
+ }
202
+ throw error;
203
+ }
204
+ return { skipped: false };
205
+ }
206
+
207
+ function buildProjectionOutboxEntry(event, target, now) {
208
+ return {
209
+ event_id: event.id,
210
+ target,
211
+ status: 'pending',
212
+ attempts: 0,
213
+ created_at: now,
214
+ };
215
+ }
216
+
217
+ function parseEventPayload(event = {}) {
218
+ if (event.payload) return event.payload;
219
+ if (!event.payload_json) return {};
220
+ try {
221
+ return JSON.parse(event.payload_json);
222
+ } catch {
223
+ return {};
224
+ }
225
+ }
226
+
227
+ function buildDependencyScope(event) {
228
+ if (event.event_type !== 'dependency.add') return null;
229
+ const payload = parseEventPayload(event);
230
+ if (!payload.issue_id || !payload.blocks_issue_id) return null;
231
+ return {
232
+ issue_id: payload.issue_id,
233
+ blocks_issue_id: payload.blocks_issue_id,
234
+ dependency_type: payload.dependency_type || 'blocks',
235
+ entity_type: event.entity_type,
236
+ entity_id: event.entity_id,
237
+ };
238
+ }
239
+
240
+ // A claim.create event targets entity_type='claim'/entity_id=<claim id>, so the
241
+ // issue being claimed lives in the payload (exactly like dependency.add). Returns
242
+ // null for non-claim events AND for malformed claim.create events (missing
243
+ // issue_id or invalid expires_at) — the broker turns the latter into an
244
+ // invalid_claim_scope quarantine rather than letting them through unscoped.
245
+ function buildClaimScope(event) {
246
+ if (event.event_type !== 'claim.create') return null;
247
+ // A claim.create MUST be on the claim entity stream (entity_type='claim'). A
248
+ // claim.create carried on another entity (e.g. an 'issue' event) is malformed:
249
+ // it would otherwise insert a kernel_claims lease while the event/outbox are
250
+ // recorded on the wrong stream. Reject the scope so it quarantines.
251
+ if (event.entity_type !== 'claim') return null;
252
+ // Read from the SAME normalized payload the evaluator persists, so the lease
253
+ // can never describe a different issue than the accepted event/outbox.
254
+ const payload = normalizePayload(event);
255
+ // issue_id must be a non-empty STRING: a truthy non-string ({}, [], a number)
256
+ // would otherwise be coerced into a bogus lease key or surface as a raw FK
257
+ // error instead of the documented invalid_claim_scope quarantine.
258
+ if (!payload || typeof payload.issue_id !== 'string' || !payload.issue_id) return null;
259
+ // Reject a malformed expires_at up front: a junk value would make
260
+ // isLeaseExpired meaningless (lock the issue forever or look instantly stale).
261
+ if (!isValidExpiresAt(payload.expires_at)) return null;
262
+ return {
263
+ issue_id: payload.issue_id,
264
+ entity_type: event.entity_type,
265
+ entity_id: event.entity_id,
266
+ };
267
+ }
268
+
269
+ const GUARDED_DRIVER_METHODS = Object.freeze([
270
+ 'exec',
271
+ 'loadKernelEntity',
272
+ 'listKernelEvents',
273
+ 'loadKernelEventByIdempotencyKey',
274
+ 'insertKernelConflict',
275
+ 'insertKernelEvent',
276
+ 'enqueueKernelProjection',
277
+ ]);
278
+
279
+ function requireGuardedDriverMethods(driver) {
280
+ for (const methodName of GUARDED_DRIVER_METHODS) {
281
+ requireDriverMethod(driver, methodName);
282
+ }
283
+ }
284
+
285
+ // Insert a quarantined claim-conflict row and return the quarantine result.
286
+ async function insertClaimConflictResult(driver, event, currentClaim, reason, context, config, now) {
287
+ const conflict = await driver.insertKernelConflict({
288
+ ...buildClaimConflict(event, currentClaim, reason),
289
+ created_at: event.created_at || now,
290
+ }, context, config);
291
+ return { decision: 'quarantine', reason, conflict, projection: false };
292
+ }
293
+
294
+ // Re-read the idempotency key and, if a committed winner now exists, return a
295
+ // duplicate replay. Used wherever a same-key retry might otherwise be misread as
296
+ // a fresh conflict — the parallel guard reads are not a consistent snapshot, so
297
+ // the idempotency lookup can miss a winner that a later read (or an insert
298
+ // collision) then observes.
299
+ async function replayDuplicateIfIdempotent(driver, event, context, config) {
300
+ if (!event.idempotency_key) return null;
301
+ const existingEvent = await driver.loadKernelEventByIdempotencyKey(event.idempotency_key, context, config);
302
+ if (!existingEvent) return null;
303
+ return { decision: 'duplicate', event, originalEvent: existingEvent, projection: false };
304
+ }
305
+
306
+ // Decide how an accepted claim.create maps to a claim row. Returns {terminal} to
307
+ // short-circuit runGuardedEvent (duplicate replay or claim_conflict quarantine),
308
+ // or {claimPlan} (possibly null for non-claim events) to proceed to the commit.
309
+ async function resolveClaimAcquisition({ driver, event, claimScope, activeClaim, context, config, now }) {
310
+ if (!claimScope) return { claimPlan: null };
311
+ const claimPlan = planClaimAcquisition({ event, activeClaim, now });
312
+ if (claimPlan.action === 'conflict') {
313
+ // Split-read guard: the idempotency lookup may have run before the winner
314
+ // committed while the active-claim lookup ran after, so a legitimate
315
+ // same-key retry can reach here. Re-check before quarantining.
316
+ const terminal = (await replayDuplicateIfIdempotent(driver, event, context, config))
317
+ || await insertClaimConflictResult(driver, event, activeClaim, 'claim_conflict', context, config, now);
318
+ return { terminal };
319
+ }
320
+ if (claimPlan.action === 'reclaim') {
321
+ requireDriverMethod(driver, 'updateKernelClaimState');
322
+ }
323
+ return { claimPlan };
324
+ }
325
+
326
+ // Recover from a failed accept transaction (after ROLLBACK). A committed
327
+ // idempotency winner replays as a duplicate regardless of which unique index
328
+ // tripped (the claim insert precedes the event insert, so the active-lease index
329
+ // can throw before the events idempotency index); a genuine cross-owner lease
330
+ // collision becomes a claim_conflict quarantine; anything else rethrows.
331
+ async function recoverGuardedFailure({ driver, err, event, claimScope, context, config, now }) {
332
+ const isUniqueConflict = isIdempotencyConflict(err) || (claimScope && isClaimLeaseConflict(err));
333
+ if (isUniqueConflict) {
334
+ const replay = await replayDuplicateIfIdempotent(driver, event, context, config);
335
+ if (replay) return replay;
336
+ }
337
+ if (claimScope && isClaimLeaseConflict(err)) {
338
+ const winner = await driver.loadActiveKernelClaim(claimScope.issue_id, context, config);
339
+ return insertClaimConflictResult(driver, event, winner, 'claim_conflict', context, config, now);
340
+ }
341
+ // Issue-revision lost-update detected by the driver's row-level CAS: a same-key
342
+ // retry of an already-committed winner still replays as a duplicate; otherwise
343
+ // quarantine as stale_revision (byte-identical to an evaluator-produced conflict)
344
+ // so mapMutationResult surfaces it as a RETRYABLE conflict — the caller re-reads
345
+ // the bumped revision and retries.
346
+ if (isRevisionConflict(err)) {
347
+ const replay = await replayDuplicateIfIdempotent(driver, event, context, config);
348
+ if (replay) return replay;
349
+ const conflict = await driver.insertKernelConflict({
350
+ ...buildConflict(event, 'stale_revision', err.actualRevision),
351
+ created_at: event.created_at || now,
352
+ }, context, config);
353
+ return { decision: 'quarantine', reason: 'stale_revision', conflict, projection: false };
354
+ }
355
+ throw err;
356
+ }
357
+
358
+ // Commit an accepted event: acquire/supersede the claim lease (if any), insert
359
+ // the event, and enqueue the projection outbox entry — all inside one
360
+ // BEGIN IMMEDIATE transaction, with ROLLBACK + recovery on failure.
361
+ async function commitGuardedAccept({ driver, event, evaluation, claimScope, activeClaim, context, config, now }) {
362
+ const resolved = await resolveClaimAcquisition({ driver, event, claimScope, activeClaim, context, config, now });
363
+ if (resolved.terminal) return resolved.terminal;
364
+ const { claimPlan } = resolved;
365
+
366
+ await driver.exec('BEGIN IMMEDIATE;', config);
367
+ try {
368
+ if (claimPlan) {
369
+ if (claimPlan.action === 'reclaim') {
370
+ await driver.updateKernelClaimState(
371
+ claimPlan.supersede.claimId, claimPlan.supersede.toState, context, config,
372
+ );
373
+ }
374
+ await driver.insertKernelClaim(claimPlan.claim, context, config);
375
+ }
376
+ const acceptedEvent = await driver.insertKernelEvent({
377
+ ...evaluation.event,
378
+ created_at: evaluation.event.created_at || now,
379
+ }, context, config);
380
+ // Apply the accepted event's authority-table effect (issue upsert / revision
381
+ // bump / comment insert) on the SAME connection, inside this transaction, so
382
+ // the event and its issue-row effect commit or roll back atomically. The hook
383
+ // is OPTIONAL: drivers that only supply event-store primitives (and every
384
+ // inline broker test fake) omit it, leaving the append/CAS path unchanged.
385
+ let mutation;
386
+ if (typeof driver.applyAcceptedIssueMutation === 'function') {
387
+ mutation = await driver.applyAcceptedIssueMutation(acceptedEvent, context, config);
388
+ }
389
+ const outboxEntry = await driver.enqueueKernelProjection(
390
+ buildProjectionOutboxEntry(acceptedEvent, context.projectionTarget || 'beads', now),
391
+ context,
392
+ config,
393
+ );
394
+ await driver.exec('COMMIT;', config);
395
+ return { ...evaluation, event: acceptedEvent, outboxEntry, mutation };
396
+ } catch (err) {
397
+ await driver.exec('ROLLBACK;', config);
398
+ return recoverGuardedFailure({ driver, err, event, claimScope, context, config, now });
399
+ }
400
+ }
401
+
402
+ // --- Issue-mutation routing (K-DRV Wave 3) ------------------------------------
403
+ // The KernelIssueAdapter routes every op to runIssueOperation. Reads delegate
404
+ // straight to driver.issueOperation; mutations must instead flow through the
405
+ // guarded-event path so CAS/idempotency/quarantine (proven in #220) apply. This
406
+ // surface builds a kernel event from the CLI args, runs runGuardedEvent, and maps
407
+ // the decision back to an issue-command-contract response — without changing
408
+ // runGuardedEvent itself.
409
+
410
+ // Ops that must go through runGuardedEvent. Reads stay on driver.issueOperation.
411
+ const ISSUE_MUTATION_OPERATIONS = Object.freeze(new Set([
412
+ 'create', 'update', 'close', 'comment',
413
+ 'dep.add', 'dep.remove', 'claim', 'release',
414
+ ]));
415
+
416
+ // Ops whose kernel event targets a non-issue entity stream (a dependency edge or a
417
+ // claim lease). These carry entity_type 'dependency'/'claim' and an entity_id that
418
+ // is the dep/claim row id — NOT the issue id — so they must never fall into the
419
+ // issue-upsert apply branch in the driver.
420
+ const DEPENDENCY_OPERATIONS = Object.freeze(new Set(['dep.add', 'dep.remove']));
421
+ const CLAIM_OPERATIONS = Object.freeze(new Set(['claim', 'release']));
422
+
423
+ // Map an issue mutation op to its event_type and the contract command id used for
424
+ // response/error shaping.
425
+ const ISSUE_MUTATION_EVENT_TYPES = Object.freeze({
426
+ create: 'issue.create',
427
+ update: 'issue.update',
428
+ close: 'issue.close',
429
+ comment: 'issue.comment',
430
+ 'dep.add': 'dependency.add',
431
+ 'dep.remove': 'dependency.remove',
432
+ claim: 'claim.create',
433
+ release: 'claim.release',
434
+ });
435
+ const ISSUE_MUTATION_COMMAND_IDS = Object.freeze({
436
+ create: 'issue.create',
437
+ update: 'issue.update',
438
+ close: 'issue.close',
439
+ comment: 'issue.comment',
440
+ 'dep.add': 'issue.dep.add',
441
+ 'dep.remove': 'issue.dep.remove',
442
+ claim: 'claim',
443
+ release: 'release',
444
+ });
445
+
446
+ function nextCommandsFor(commandId) {
447
+ const contract = getIssueCommandContract(commandId);
448
+ return contract ? [...contract.nextCommands] : [];
449
+ }
450
+
451
+ // First non-flag token (the issue id for update/close/comment).
452
+ function firstPositionalArg(args = []) {
453
+ return (args || []).find(value => typeof value === 'string' && !value.startsWith('-'));
454
+ }
455
+
456
+ // Parse the long-flag pairs CLI mutations carry. Supports BOTH forms:
457
+ // --key value (space-separated)
458
+ // --key=value (=-joined, e.g. --reason="...")
459
+ // Only --key flags are captured; bare positionals are ignored here. The =-form
460
+ // is required because the CLI delegates issue flags verbatim (bin parseFlags
461
+ // early-returns for passthrough commands), so --reason=... arrives as one token.
462
+ function parseFlagPairs(args = []) {
463
+ const flags = {};
464
+ for (let index = 0; index < args.length; index += 1) {
465
+ const token = args[index];
466
+ if (typeof token !== 'string' || !token.startsWith('--')) continue;
467
+ const body = token.slice(2);
468
+ const eq = body.indexOf('=');
469
+ if (eq !== -1) {
470
+ // --key=value (value may itself contain '='; only split on the first).
471
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
472
+ continue;
473
+ }
474
+ const key = body;
475
+ const next = args[index + 1];
476
+ if (typeof next === 'string' && !next.startsWith('--')) {
477
+ flags[key] = next;
478
+ index += 1;
479
+ } else {
480
+ flags[key] = true;
481
+ }
482
+ }
483
+ return flags;
484
+ }
485
+
486
+ // KAP-4: parseFlagPairs is last-value-wins, so repeated --label flags do NOT
487
+ // accumulate. The CLI surface is a single COMMA-SEPARATED flag (--label "a,b");
488
+ // split on ',', trim each, and drop empties into a string[]. A non-string flag
489
+ // (bare --label with no value) yields []. The driver persists this as JSON-array
490
+ // TEXT; the read side (parseLabels/rowToIssueSummary) already surfaces labels[].
491
+ function parseLabelFlag(value) {
492
+ if (typeof value !== 'string') return [];
493
+ return value.split(',').map(label => label.trim()).filter(Boolean);
494
+ }
495
+
496
+ // Normalize the priority flags into the canonical stored form shared by create and
497
+ // update. `--priority` is canonicalized to its P0..P4 label (the bare ranks 0..4 and
498
+ // the P-labels map to the SAME label), and `priority_rank` is DERIVED from that label
499
+ // so the kernel can order by priority — unless `--priority-rank` is given explicitly,
500
+ // which still wins. A junk/out-of-range priority is left UNNORMALIZED so the validation
501
+ // gate (assertValidIssueMutationPayload) rejects it instead of clamping it.
502
+ function applyPriorityFlags(payload, flags) {
503
+ if (typeof flags.priority === 'string') payload.priority = normalizePriority(flags.priority);
504
+ if (flags['priority-rank'] !== undefined) {
505
+ payload.priority_rank = Number(flags['priority-rank']);
506
+ } else if (typeof payload.priority === 'string') {
507
+ payload.priority_rank = rankForPriorityLabel(payload.priority);
508
+ }
509
+ }
510
+
511
+ // Build the create payload from flags. --id is optional (minted when absent); the
512
+ // minted/declared id becomes the event entity_id and the new issue's primary key.
513
+ function buildCreatePayload(flags) {
514
+ const id = typeof flags.id === 'string' ? flags.id : randomUUID();
515
+ const payload = { id };
516
+ if (typeof flags.title === 'string') payload.title = flags.title;
517
+ // Beads stored the long-form description in `body` (beads-kernel-compat maps
518
+ // description ?? body ?? notes -> body). --body wins when both are supplied;
519
+ // otherwise --description populates the same body column so it is never dropped.
520
+ if (typeof flags.body === 'string') payload.body = flags.body;
521
+ else if (typeof flags.description === 'string') payload.body = flags.description;
522
+ payload.type = typeof flags.type === 'string' ? flags.type : 'task';
523
+ payload.status = typeof flags.status === 'string' ? flags.status : 'open';
524
+ applyPriorityFlags(payload, flags);
525
+ if (typeof flags.parent === 'string') payload.parent_id = flags.parent;
526
+ if (typeof flags.label === 'string') payload.labels = parseLabelFlag(flags.label);
527
+ // KAP-10 (acceptance/design/notes) + KAP-11 (assignee): authored content fields
528
+ // and a persistent assignee. The driver persists each to its own column.
529
+ if (typeof flags.acceptance === 'string') payload.acceptance_criteria = flags.acceptance;
530
+ if (typeof flags.design === 'string') payload.design = flags.design;
531
+ if (typeof flags.notes === 'string') payload.notes = flags.notes;
532
+ if (typeof flags.assignee === 'string') payload.assignee = flags.assignee;
533
+ // Beads full-fidelity import: author, close timestamp + raw close reason, and a
534
+ // verbatim metadata JSON blob (stored as-is). --close-reason is distinct from the
535
+ // --reason close-event flag; the importer sets these so no beads field is dropped.
536
+ if (typeof flags['created-by'] === 'string') payload.created_by = flags['created-by'];
537
+ if (typeof flags['closed-at'] === 'string') payload.closed_at = flags['closed-at'];
538
+ if (typeof flags['close-reason'] === 'string') payload.close_reason = flags['close-reason'];
539
+ if (typeof flags.metadata === 'string') payload.metadata = flags.metadata;
540
+ return payload;
541
+ }
542
+
543
+ // Build the update/close payload from flags (status/title/etc. when present).
544
+ function buildUpdatePayload(flags) {
545
+ const payload = {};
546
+ if (typeof flags.status === 'string') payload.status = flags.status;
547
+ if (typeof flags.title === 'string') payload.title = flags.title;
548
+ // --body wins when both are supplied; otherwise --description rewrites the same
549
+ // body column (parity with create — beads stored the description in body).
550
+ if (typeof flags.body === 'string') payload.body = flags.body;
551
+ else if (typeof flags.description === 'string') payload.body = flags.description;
552
+ applyPriorityFlags(payload, flags);
553
+ // KAP-4: --label reparents the full label set (last-value-wins, comma-split).
554
+ if (typeof flags.label === 'string') payload.labels = parseLabelFlag(flags.label);
555
+ // KAP-5: --parent reparents on update (create already maps --parent → parent_id).
556
+ if (typeof flags.parent === 'string') payload.parent_id = flags.parent;
557
+ // KAP-5: --reason is captured in the close EVENT payload (no column/migration);
558
+ // kernel_events already persists payload_json. The close op uses buildUpdatePayload.
559
+ if (typeof flags.reason === 'string') payload.reason = flags.reason;
560
+ // KAP-10 (acceptance/design/notes) + KAP-11 (assignee): update overwrites each
561
+ // content field / reassigns the assignee when its flag is supplied.
562
+ if (typeof flags.acceptance === 'string') payload.acceptance_criteria = flags.acceptance;
563
+ if (typeof flags.design === 'string') payload.design = flags.design;
564
+ if (typeof flags.notes === 'string') payload.notes = flags.notes;
565
+ if (typeof flags.assignee === 'string') payload.assignee = flags.assignee;
566
+ // Beads full-fidelity import: overwrite author/close timestamp/raw close reason/
567
+ // metadata when supplied. --close-reason is a distinct COLUMN from the --reason
568
+ // close-event payload, and metadata is stored verbatim.
569
+ if (typeof flags['created-by'] === 'string') payload.created_by = flags['created-by'];
570
+ if (typeof flags['closed-at'] === 'string') payload.closed_at = flags['closed-at'];
571
+ if (typeof flags['close-reason'] === 'string') payload.close_reason = flags['close-reason'];
572
+ if (typeof flags.metadata === 'string') payload.metadata = flags.metadata;
573
+ return payload;
574
+ }
575
+
576
+ // The comment body is the first non-id positional (forge comment <id> <body...>).
577
+ function buildCommentPayload(issueId, args) {
578
+ const positionals = (args || []).filter(value => typeof value === 'string' && !value.startsWith('-'));
579
+ const body = positionals.slice(1).join(' ');
580
+ return { issue_id: issueId, body, comment_id: randomUUID() };
581
+ }
582
+
583
+ // Resolve the (issue_id, blocks_issue_id) endpoints a dependency op names. The
584
+ // DOCUMENTED CLI is positional — `forge issue dep add <issue-id> <blocks-issue-id>`
585
+ // — so the first two positionals are the endpoints; the --issue/--blocks flag pair
586
+ // is an explicit alternative that takes precedence when present. Both MUST resolve so
587
+ // buildDependencyScope can scope the event (else the cycle guard is skipped and the
588
+ // edge insert is malformed — previously a positional invocation left issue_id
589
+ // undefined and the INSERT failed with a SQLite bind error).
590
+ function resolveDependencyEndpoints(flags, args = []) {
591
+ const positionals = args.filter(arg => typeof arg === 'string' && !arg.startsWith('-'));
592
+ const issueId = typeof flags.issue === 'string' ? flags.issue : positionals[0];
593
+ const blocksIssueId = typeof flags.blocks === 'string' ? flags.blocks : positionals[1];
594
+ return {
595
+ issue_id: typeof issueId === 'string' ? issueId : undefined,
596
+ blocks_issue_id: typeof blocksIssueId === 'string' ? blocksIssueId : undefined,
597
+ dependency_type: typeof flags['dep-type'] === 'string' ? flags['dep-type'] : 'blocks',
598
+ };
599
+ }
600
+
601
+ // Build the kernel event for a dependency.add / dependency.remove op. The event is
602
+ // scoped on the 'dependency' entity stream: a freshly minted entity_id becomes the
603
+ // dependency row id (add) and the cycle-guard / edge-write key. The idempotency key
604
+ // is derived from the edge endpoints so a retry of the same edge replays rather
605
+ // than minting a second row.
606
+ function buildDependencyMutationEvent(operation, flags, actor, origin, context, args = []) {
607
+ const endpoints = resolveDependencyEndpoints(flags, args);
608
+ const dependencyId = context.dependencyId || randomUUID();
609
+ const eventType = ISSUE_MUTATION_EVENT_TYPES[operation];
610
+ const idempotencyKey = context.idempotencyKey
611
+ || `${eventType}:${endpoints.issue_id}:${endpoints.blocks_issue_id}`;
612
+ return {
613
+ entity_type: 'dependency',
614
+ entity_id: dependencyId,
615
+ event_type: eventType,
616
+ idempotency_key: idempotencyKey,
617
+ expected_revision: 0,
618
+ actor,
619
+ origin,
620
+ payload: {
621
+ issue_id: endpoints.issue_id,
622
+ blocks_issue_id: endpoints.blocks_issue_id,
623
+ dependency_type: endpoints.dependency_type,
624
+ },
625
+ };
626
+ }
627
+
628
+ // Build the kernel event for a claim / release op. The event is scoped on the
629
+ // 'claim' entity stream (entity_id = claim lease id). The default idempotency key
630
+ // is keyed on issue_id + actor + session so a same-SESSION retry replays as a
631
+ // duplicate while a different session (or actor) produces a distinct key that reaches
632
+ // the lease-conflict path.
633
+ //
634
+ // SESSION-SCOPED KEY (kernel d71a824b): keying on `actor` alone was a correctness
635
+ // hole — `actor` defaults to a shared 'forge' and two concurrent agents acting as the
636
+ // SAME human actor on the SAME issue collided on ONE key, so the loser's claim
637
+ // collapsed to an idempotent duplicate-replay (ok:true) instead of reaching the
638
+ // claim_conflict lease guard, and BOTH agents then passed owns() → silent double-work.
639
+ // Appending the per-agent session-id gives distinct sessions distinct keys, so exactly
640
+ // one wins the partial-UNIQUE active lease and the other is correctly quarantined. A
641
+ // session-less caller (sessionId null) keeps the historical
642
+ // `${eventType}:${issueId}:${actor}` key BYTE-FOR-BYTE, so single-agent same-key
643
+ // retries still replay as duplicates and existing callers/tests are unaffected.
644
+ //
645
+ // The DOCUMENTED CLI is positional — `forge claim <id>` / `forge release <id>` —
646
+ // so the issue id is the first positional, matching update/close/dep. The --issue
647
+ // flag remains an explicit alternative that takes precedence when present. Reading
648
+ // only flags.issue previously left issue_id undefined on the CLI path: claim then
649
+ // failed buildClaimScope (invalid_claim_scope quarantine) and release bound undefined
650
+ // to the lease-clear UPDATE ("cannot be bound to SQLite parameter 1").
651
+ function buildClaimMutationEvent(operation, flags, actor, origin, context, args = []) {
652
+ const issueId = typeof flags.issue === 'string' ? flags.issue : firstPositionalArg(args);
653
+ const claimId = context.claimId || randomUUID();
654
+ const eventType = ISSUE_MUTATION_EVENT_TYPES[operation];
655
+ // Treat an empty/whitespace-only session-id as session-LESS: it must never append to
656
+ // the idempotency key and never be stored as a non-null session_id. This uses the
657
+ // SAME trim-truthy emptiness test as the owns() read (lib/kernel/sqlite-driver.js), so
658
+ // '' can never count as "present" at the write site and "absent" at the read site.
659
+ const sessionId = typeof context.sessionId === 'string' && context.sessionId.trim() !== ''
660
+ ? context.sessionId
661
+ : null;
662
+ const idempotencyKey = context.idempotencyKey
663
+ || (sessionId
664
+ ? `${eventType}:${issueId}:${actor}:${sessionId}`
665
+ : `${eventType}:${issueId}:${actor}`);
666
+ const payload = { issue_id: issueId };
667
+ if (typeof flags.expires === 'string') payload.expires_at = flags.expires;
668
+ return {
669
+ entity_type: 'claim',
670
+ entity_id: claimId,
671
+ event_type: eventType,
672
+ idempotency_key: idempotencyKey,
673
+ expected_revision: 0,
674
+ actor,
675
+ origin,
676
+ session_id: sessionId,
677
+ worktree_id: context.worktreeId ?? null,
678
+ // Carry the lease TTL (ms) so buildClaimRow can derive expires_at = claimed_at + ttl
679
+ // when no explicit --expires is given. Absent/non-positive → null expiry (never
680
+ // expires), matching the historical broker-direct default. The CLI boundary
681
+ // (lib/forge-issues.js) supplies context.leaseTtlMs; broker-direct callers omit it.
682
+ lease_ttl_ms: context.leaseTtlMs ?? null,
683
+ payload,
684
+ };
685
+ }
686
+
687
+ // Reject an out-of-contract create/update payload before it reaches the CAS path,
688
+ // throwing a tagged error runIssueMutation maps to the contract validation error
689
+ // (exit 6). Beads refused these too: an empty/whitespace (or, on create, missing)
690
+ // title, and any status/type/priority outside the canonical vocabulary, are rejected
691
+ // rather than silently stored. On UPDATE only the fields actually supplied are
692
+ // checked; on CREATE a title is mandatory.
693
+ function assertValidIssueMutationPayload(payload, isCreate) {
694
+ const reject = (message, field) => {
695
+ const error = new Error(message);
696
+ error.isIssueValidation = true;
697
+ error.validationField = field;
698
+ throw error;
699
+ };
700
+ const hasTitle = typeof payload.title === 'string';
701
+ if ((isCreate || hasTitle) && (!hasTitle || payload.title.trim() === '')) {
702
+ reject('Issue title must be a non-empty string', 'title');
703
+ }
704
+ // NOTE: issue `type` is intentionally NOT validated here. `feature`/`story`/etc.
705
+ // are labels, not canonical types (D18), but forge-internal callers (e.g.
706
+ // lib/commands/plan.js) and the documented CLI still create with --type=feature,
707
+ // which the kernel stores verbatim. Rejecting it would be an out-of-scope regression;
708
+ // this gate matches Beads on title/status/priority only.
709
+ if (payload.status !== undefined && !isValidIssueStatus(payload.status)) {
710
+ reject(`Unknown issue status: ${payload.status}`, 'status');
711
+ }
712
+ if (payload.priority !== undefined && !isValidIssuePriority(payload.priority)) {
713
+ reject(`Invalid issue priority: ${payload.priority}`, 'priority');
714
+ }
715
+ }
716
+
717
+ // Construct the kernel event for a mutation op. For update/close/comment, the
718
+ // expected_revision is the FRESHLY READ stored revision (so runIssueOperation can
719
+ // never manufacture a stale CAS — a behind revision only arrives via a raw
720
+ // runGuardedEvent call). entity_id is the issue id; payload carries the change.
721
+ async function buildIssueMutationEvent(driver, operation, args, context, config) {
722
+ const flags = parseFlagPairs(args);
723
+ const actor = context.actor || 'forge';
724
+ const origin = context.origin || 'cli';
725
+
726
+ if (operation === 'create') {
727
+ const payload = buildCreatePayload(flags);
728
+ assertValidIssueMutationPayload(payload, true);
729
+ return {
730
+ entity_type: 'issue',
731
+ entity_id: payload.id,
732
+ event_type: ISSUE_MUTATION_EVENT_TYPES.create,
733
+ idempotency_key: context.idempotencyKey || `issue.create:${payload.id}`,
734
+ expected_revision: 0,
735
+ actor,
736
+ origin,
737
+ payload,
738
+ };
739
+ }
740
+
741
+ // Dependency / claim ops target their own entity stream (no issue-row CAS): the
742
+ // event entity_type is 'dependency'/'claim' and entity_id is a fresh dep/claim id.
743
+ if (DEPENDENCY_OPERATIONS.has(operation)) {
744
+ return buildDependencyMutationEvent(operation, flags, actor, origin, context, args);
745
+ }
746
+ if (CLAIM_OPERATIONS.has(operation)) {
747
+ // Claim eligibility: a `backlog` (parked) issue is NOT workable — beginning it
748
+ // would jump backlog->in_progress and skip the required promote (backlog->open).
749
+ // Reject the claim here rather than minting a lease that silently activates parked
750
+ // 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
+ }
764
+ return buildClaimMutationEvent(operation, flags, actor, origin, context, args);
765
+ }
766
+
767
+ 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
771
+ // accepts as a fresh insert — silently minting a phantom issue on a typo'd id (data
772
+ // corruption; Beads errors not-found). Signal not-found so runIssueMutation returns
773
+ // 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
+ const expectedRevision = Number(entity?.entity_revision || 0);
780
+ // For close, the driver maps event_type='issue.close' to a terminal status; an
781
+ // explicit --status flag (rework move) still wins via the update payload.
782
+ const payload = operation === 'comment'
783
+ ? buildCommentPayload(issueId, args)
784
+ : buildUpdatePayload(flags);
785
+ // update/close validate only the supplied taxonomy fields (no mandatory title);
786
+ // comment carries no taxonomy fields, so it is exempt.
787
+ if (operation !== 'comment') {
788
+ assertValidIssueMutationPayload(payload, false);
789
+ }
790
+ // Enforce the stored status-transition graph on any status CHANGE the payload
791
+ // carries (an `update --status`, or a `close --status` rework move). Without this
792
+ // the row-level CAS happily persists an illegal jump — done->open resurrects a
793
+ // terminal issue, backlog->in_progress skips the required promote. isValidStatusTransition
794
+ // treats an unchanged status as an idempotent no-op, so a re-set to the same value passes.
795
+ // Only gate when the CURRENT status is a known-valid stored value: if the loaded entity
796
+ // carries no readable status (minimal driver fakes; a not-yet-materialized row), fall
797
+ // through to the CAS/quarantine path rather than manufacturing a false validation error.
798
+ if (payload.status !== undefined
799
+ && isValidIssueStatus(entity.status)
800
+ && !isValidStatusTransition(entity.status, payload.status)) {
801
+ const err = new Error(`Illegal status transition: ${entity.status} -> ${payload.status}`);
802
+ err.isIssueValidation = true;
803
+ err.validationField = 'status';
804
+ throw err;
805
+ }
806
+ // Comments never bump entity_revision, so a revision-based key would collide for
807
+ // every comment on the same issue (second → false duplicate → silently dropped).
808
+ // Key on the minted comment_id instead. For update/close the synthesized key uses
809
+ // the current revision: a CLI retry re-reads the bumped revision and re-applies
810
+ // (not truly idempotent) — callers needing retry-safety pass context.idempotencyKey.
811
+ const idempotencyKey = context.idempotencyKey || (operation === 'comment'
812
+ ? `${ISSUE_MUTATION_EVENT_TYPES.comment}:${issueId}:${payload.comment_id}`
813
+ : `${ISSUE_MUTATION_EVENT_TYPES[operation]}:${issueId}:rev-${expectedRevision}`);
814
+ return {
815
+ entity_type: 'issue',
816
+ entity_id: issueId,
817
+ event_type: ISSUE_MUTATION_EVENT_TYPES[operation],
818
+ idempotency_key: idempotencyKey,
819
+ expected_revision: expectedRevision,
820
+ actor,
821
+ origin,
822
+ payload,
823
+ };
824
+ }
825
+
826
+ function okMutationResponse(commandId, data) {
827
+ return {
828
+ ok: true,
829
+ schema_version: ISSUE_COMMAND_SCHEMA_VERSION,
830
+ command: commandId,
831
+ data,
832
+ next_commands: nextCommandsFor(commandId),
833
+ };
834
+ }
835
+
836
+ // The issue id a mutation event touches: for issue ops it IS the entity_id; for
837
+ // dependency/claim ops the entity_id is the dep/claim row id, so the issue lives in
838
+ // the payload. Used to report a stable issue revision on a duplicate replay.
839
+ function mutationIssueId(operation, event) {
840
+ if (DEPENDENCY_OPERATIONS.has(operation) || CLAIM_OPERATIONS.has(operation)) {
841
+ const payload = parseEventPayload(event);
842
+ return payload.issue_id;
843
+ }
844
+ return event.entity_id;
845
+ }
846
+
847
+ // Build the accept-decision mutation response data (id + revision plus the op-specific
848
+ // id echoes). Extracted from mapMutationResult so that function's cognitive complexity
849
+ // stays low as op-specific fields accrue (comment_id / dependency_id / claim_id /
850
+ // KAP-8 newly_unblocked).
851
+ function buildAcceptMutationData(operation, event, mutation) {
852
+ const isDep = DEPENDENCY_OPERATIONS.has(operation);
853
+ const isClaim = CLAIM_OPERATIONS.has(operation);
854
+ // data.id is the ISSUE id consumers key on. For dep/claim ops the event entity_id
855
+ // is the dependency/claim ROW id, so resolve the issue id from the payload; the row
856
+ // id is echoed separately via dependency_id / claim_id.
857
+ const data = {
858
+ id: (isDep || isClaim) ? mutationIssueId(operation, event) : (mutation.id ?? event.entity_id),
859
+ revision: Number(mutation.revision ?? 0),
860
+ };
861
+ // Surface the canonical priority the write persisted (create/update with --priority)
862
+ // so the mutation response is self-describing — the bare-int and P-label inputs both
863
+ // echo the SAME normalized label. dep/claim payloads carry no priority, so it is omitted.
864
+ const payload = parseEventPayload(event);
865
+ if (typeof payload.priority === 'string') data.priority = payload.priority;
866
+ if (mutation.comment_id) data.comment_id = mutation.comment_id;
867
+ if (isDep) {
868
+ data.dependency_id = mutation.dependency_id ?? event.entity_id;
869
+ }
870
+ if (isClaim) {
871
+ data.claim_id = mutation.claim_id ?? event.entity_id;
872
+ }
873
+ // KAP-8: a close surfaces the ids that became ready now that this issue is done.
874
+ // Only the close path's driver mutation populates newly_unblocked; echo it when
875
+ // present (an empty array is meaningful — "nothing unblocked" — so include it).
876
+ if (operation === 'close' && Array.isArray(mutation.newly_unblocked)) {
877
+ data.newly_unblocked = mutation.newly_unblocked;
878
+ }
879
+ return data;
880
+ }
881
+
882
+ // Map a runGuardedEvent result to an issue-command-contract mutation response (or
883
+ // error). A duplicate replay reports the existing single row; a quarantine becomes
884
+ // a (sometimes retryable) conflict error; an accept returns
885
+ // {id, revision, comment_id?/dependency_id?/claim_id?}.
886
+ async function mapMutationResult(driver, operation, event, result, context, config) {
887
+ const commandId = ISSUE_MUTATION_COMMAND_IDS[operation];
888
+
889
+ if (result.decision === 'accept') {
890
+ return okMutationResponse(commandId, buildAcceptMutationData(operation, event, result.mutation || {}));
891
+ }
892
+
893
+ if (result.decision === 'duplicate' || result.decision === 'dedupe' || result.decision === 'projection_echo') {
894
+ // Idempotent replay / equivalent write: no double-write. Report the host
895
+ // issue's current persisted revision so the caller sees a stable result. For
896
+ // dep/claim ops the issue id comes from the payload, not the entity_id — and
897
+ // data.id is that issue id (the dep/claim ROW id is echoed via *_id below).
898
+ const issueId = mutationIssueId(operation, event);
899
+ const entity = await driver.loadKernelEntity('issue', issueId, context, config);
900
+ const data = {
901
+ id: issueId,
902
+ revision: Number(entity?.entity_revision || 0),
903
+ };
904
+ if (DEPENDENCY_OPERATIONS.has(operation)) data.dependency_id = event.entity_id;
905
+ if (CLAIM_OPERATIONS.has(operation)) data.claim_id = event.entity_id;
906
+ return okMutationResponse(commandId, data);
907
+ }
908
+
909
+ if (result.decision === 'quarantine') {
910
+ const retryable = result.reason === 'stale_revision';
911
+ return formatIssueCommandError({
912
+ command: commandId,
913
+ code: `FORGE_ISSUE_${String(result.reason || 'CONFLICT').toUpperCase()}`,
914
+ message: `Issue mutation ${operation} on ${event.entity_id} was quarantined: ${result.reason}`,
915
+ exitCode: ISSUE_COMMAND_EXIT_CODES.conflict,
916
+ retryable,
917
+ details: { reason: result.reason, entity_id: event.entity_id },
918
+ nextCommands: nextCommandsFor(commandId),
919
+ });
920
+ }
921
+
922
+ // Any other decision (should not occur for issue mutations) is an internal error.
923
+ return formatIssueCommandError({
924
+ command: commandId,
925
+ code: 'FORGE_ISSUE_INTERNAL',
926
+ message: `Issue mutation ${operation} returned an unexpected decision: ${result.decision}`,
927
+ exitCode: ISSUE_COMMAND_EXIT_CODES.internal,
928
+ retryable: false,
929
+ nextCommands: nextCommandsFor(commandId),
930
+ });
931
+ }
932
+
933
+ function createLocalBroker(options = {}) {
934
+ const driver = options.driver;
935
+ let cachedConfig;
936
+
937
+ // Memoized config build AND the structural home of the D19 default-on
938
+ // filesystem gate. getConfig() is the single chokepoint every DB-creating
939
+ // broker path funnels through (initialize, runIssueOperation, runGuardedEvent,
940
+ // the projection-outbox surface, and the public `config` getter), so asserting
941
+ // here guards them ALL — not just initialize(). This closes the D19-B2 bypass
942
+ // where runIssueOperation / listProjectionOutbox reached driver.exec → getDatabase
943
+ // → createDatabase on a refuse-class FS without ever calling initialize().
944
+ //
945
+ // FAIL-CLOSED ordering: build a LOCAL config, assert, and ONLY THEN populate the
946
+ // memo. If we cached before asserting, the first call would throw but a second
947
+ // call would see a populated memo, skip the (re-)assert, and proceed to create
948
+ // the DB — reintroducing the bypass. env/classifier/warn are injectable via
949
+ // createLocalBroker options for deterministic tests. Throws on refuse unless
950
+ // FORGE_KERNEL_ALLOW_UNSAFE_FS is set; warns (never throws) on warn classes.
951
+ function getConfig() {
952
+ if (!cachedConfig) {
953
+ const config = buildLocalBrokerConfig(options);
954
+ assertFilesystemSafeForKernel(config.databasePath, {
955
+ env: options.env,
956
+ warn: options.warn,
957
+ classifyFilesystem: options.classifyFilesystem,
958
+ });
959
+ cachedConfig = config;
960
+ }
961
+ return cachedConfig;
962
+ }
963
+
964
+ // The guarded-event pipeline (CAS/idempotency/lease/quarantine + atomic commit).
965
+ // Extracted to a local function so runIssueOperation can route issue mutations
966
+ // through it without re-implementing the path or going through `this`.
967
+ async function runGuardedEventImpl(event, context = {}) {
968
+ requireGuardedDriverMethods(driver);
969
+
970
+ const config = getConfig();
971
+ const now = context.now || new Date().toISOString();
972
+ const normalizedEvent = {
973
+ ...event,
974
+ created_at: event.created_at || now,
975
+ };
976
+ const dependencyScope = buildDependencyScope(normalizedEvent);
977
+ if (dependencyScope) {
978
+ requireDriverMethod(driver, 'listKernelDependencies');
979
+ }
980
+ const claimScope = buildClaimScope(normalizedEvent);
981
+ // A claim.create with a missing/invalid payload (or wrong entity) has no
982
+ // scope. Quarantine it instead of falling through as a non-claim event:
983
+ // the generic evaluator would otherwise accept and persist the event +
984
+ // outbox without ever creating a kernel_claims lease, so a later claim on
985
+ // the real issue would proceed as if nothing were claimed. BUT a retry of
986
+ // an already-accepted claim (same idempotency_key) must still replay as a
987
+ // duplicate even if this retry's payload is malformed, so check that first.
988
+ if (normalizedEvent.event_type === 'claim.create' && !claimScope) {
989
+ return (await replayDuplicateIfIdempotent(driver, normalizedEvent, context, config))
990
+ || insertClaimConflictResult(driver, normalizedEvent, null, 'invalid_claim_scope', context, config, now);
991
+ }
992
+ if (claimScope) {
993
+ requireDriverMethod(driver, 'loadActiveKernelClaim');
994
+ requireDriverMethod(driver, 'insertKernelClaim');
995
+ }
996
+
997
+ const [entity, entityEvents, idempotencyEvent, dependencies, activeClaim] = await Promise.all([
998
+ driver.loadKernelEntity(normalizedEvent.entity_type, normalizedEvent.entity_id, context, config),
999
+ driver.listKernelEvents(normalizedEvent.entity_type, normalizedEvent.entity_id, context, config),
1000
+ driver.loadKernelEventByIdempotencyKey(normalizedEvent.idempotency_key, context, config),
1001
+ dependencyScope
1002
+ ? driver.listKernelDependencies(dependencyScope, context, config)
1003
+ : Promise.resolve([]),
1004
+ claimScope
1005
+ ? driver.loadActiveKernelClaim(claimScope.issue_id, context, config)
1006
+ : Promise.resolve(null),
1007
+ ]);
1008
+ const priorEvents = idempotencyEvent
1009
+ ? [idempotencyEvent, ...entityEvents.filter(candidate => candidate.id !== idempotencyEvent.id)]
1010
+ : entityEvents;
1011
+ const evaluation = evaluateKernelEvent({
1012
+ event: normalizedEvent,
1013
+ entity,
1014
+ priorEvents,
1015
+ dependencies,
1016
+ });
1017
+
1018
+ if (evaluation.decision === 'quarantine') {
1019
+ const conflict = await driver.insertKernelConflict({
1020
+ ...evaluation.conflict,
1021
+ created_at: evaluation.conflict.created_at || now,
1022
+ }, context, config);
1023
+ return { ...evaluation, conflict };
1024
+ }
1025
+
1026
+ if (evaluation.decision !== 'accept') {
1027
+ return evaluation;
1028
+ }
1029
+
1030
+ return commitGuardedAccept({
1031
+ driver, event: normalizedEvent, evaluation, claimScope, activeClaim, context, config, now,
1032
+ });
1033
+ }
1034
+
1035
+ // Route an issue mutation op through the guarded-event path: build the event,
1036
+ // run it, and shape the issue-command-contract response/error.
1037
+ async function runIssueMutation(operation, args, context) {
1038
+ const config = getConfig();
1039
+ let event;
1040
+ try {
1041
+ event = await buildIssueMutationEvent(driver, operation, args, context, config);
1042
+ } catch (err) {
1043
+ if (err && err.isIssueNotFound) {
1044
+ // Missing target for update/close/comment: return the contract not-found
1045
+ // error (exit 3) instead of letting the CAS mint a phantom row.
1046
+ return formatIssueCommandError({
1047
+ command: ISSUE_MUTATION_COMMAND_IDS[operation],
1048
+ code: 'FORGE_ISSUE_NOT_FOUND',
1049
+ message: err.message,
1050
+ exitCode: ISSUE_COMMAND_EXIT_CODES.notFound,
1051
+ });
1052
+ }
1053
+ if (err && err.isIssueValidation) {
1054
+ // Out-of-contract input (empty title / invalid status/type/priority):
1055
+ // return the contract validation error (exit 6) without writing a row.
1056
+ return formatIssueCommandError({
1057
+ command: ISSUE_MUTATION_COMMAND_IDS[operation],
1058
+ code: 'FORGE_ISSUE_VALIDATION',
1059
+ message: err.message,
1060
+ exitCode: ISSUE_COMMAND_EXIT_CODES.validation,
1061
+ details: err.validationField ? { field: err.validationField } : undefined,
1062
+ });
1063
+ }
1064
+ throw err;
1065
+ }
1066
+ const result = await runGuardedEventImpl(event, context);
1067
+ return mapMutationResult(driver, operation, event, result, context, config);
1068
+ }
1069
+
1070
+ return {
1071
+ get config() {
1072
+ return getConfig();
1073
+ },
1074
+
1075
+ async initialize() {
1076
+ // D19 default-on filesystem gate now lives in getConfig() (the structural
1077
+ // chokepoint shared by every DB-creating path), so the first getConfig()
1078
+ // call here asserts the FS is safe BEFORE any driver.exec — the SQLite file
1079
+ // is never created on a refuse-class (cloud/network) FS. Throws on refuse
1080
+ // unless FORGE_KERNEL_ALLOW_UNSAFE_FS; injectable via createLocalBroker opts.
1081
+ const config = getConfig();
1082
+ requireDriverMethod(driver, 'exec');
1083
+ requireDriverMethod(driver, 'queryAll');
1084
+ for (const statement of config.pragmas) {
1085
+ await driver.exec(statement, config);
1086
+ }
1087
+ // Apply only un-applied migrations, tracked in the ledger — no re-run of the
1088
+ // whole plan on every init, and no blanket duplicate-error swallow.
1089
+ await driver.exec(MIGRATION_LEDGER_CREATE, config);
1090
+ const appliedRows = await driver.queryAll(`SELECT id FROM ${MIGRATION_LEDGER_TABLE};`, config);
1091
+ const applied = new Set((appliedRows || []).map(row => row?.id));
1092
+ const newlyApplied = [];
1093
+ for (const migration of config.migrationPlan.migrations) {
1094
+ if (applied.has(migration.id)) continue;
1095
+ // Concurrency scope: execMigrationStatement only makes ADD COLUMN
1096
+ // race-safe (pre-check + TOCTOU catch). This apply loop is NOT
1097
+ // serialized, so a future non-idempotent, non-ADD-COLUMN migration
1098
+ // (data backfill, non-`IF NOT EXISTS` DDL) would reintroduce a race —
1099
+ // it would need `BEGIN IMMEDIATE` + an in-lock ledger re-read.
1100
+ for (const statement of migration.apply || []) {
1101
+ await execMigrationStatement(driver, statement, config);
1102
+ }
1103
+ await driver.exec(recordMigrationSql(migration.id), config);
1104
+ newlyApplied.push(migration.id);
1105
+ }
1106
+
1107
+ return {
1108
+ success: true,
1109
+ mode: config.mode,
1110
+ databasePath: config.databasePath,
1111
+ gitCommonDir: config.gitCommonDir,
1112
+ journalMode: config.journalMode,
1113
+ synchronous: config.synchronous,
1114
+ foreignKeys: config.foreignKeys,
1115
+ migrationsApplied: config.migrationPlan.migrations.map(migration => migration.id),
1116
+ migrationsNewlyApplied: newlyApplied,
1117
+ };
1118
+ },
1119
+
1120
+ async runIssueOperation(operation, args = [], context = {}) {
1121
+ // Git-style short issue-id support (kernel 9556660b): resolve any hex id
1122
+ // prefix (>= 6 chars) in the id-carrying slots to the stored full id ONCE,
1123
+ // here at the single boundary every kernel issue op flows through — so
1124
+ // every subcommand, the batch-close fan-out, and the gate.issue_verify
1125
+ // read-back all consume the RESOLVED id. Full UUIDs and legacy non-hex ids
1126
+ // never trigger a lookup (byte-identical passthrough); the lookup — and
1127
+ // therefore getConfig()'s FS gate — only fires when a token actually looks
1128
+ // like a prefix, keeping mock-driver and gate ordering behavior unchanged.
1129
+ const resolution = await resolveIssueIdArgs(operation, args, async (prefix, limit) => {
1130
+ requireDriverMethod(driver, 'findIssueIdsByPrefix');
1131
+ return driver.findIssueIdsByPrefix(prefix, limit, context, getConfig());
1132
+ });
1133
+ if (resolution.error) {
1134
+ return formatIssueCommandError({
1135
+ command: ISSUE_MUTATION_COMMAND_IDS[operation] || `issue.${operation}`,
1136
+ code: resolution.error.code,
1137
+ message: resolution.error.message,
1138
+ exitCode: resolution.error.exitCode,
1139
+ details: resolution.error.details,
1140
+ });
1141
+ }
1142
+ // Mutations (create/update/close/comment) flow through the guarded-event
1143
+ // path to preserve CAS/idempotency/quarantine; reads delegate directly to
1144
+ // the driver's parameterized SELECT branch.
1145
+ if (ISSUE_MUTATION_OPERATIONS.has(operation)) {
1146
+ requireGuardedDriverMethods(driver);
1147
+ requireDriverMethod(driver, 'applyAcceptedIssueMutation');
1148
+ return runIssueMutation(operation, resolution.args, context);
1149
+ }
1150
+ requireDriverMethod(driver, 'issueOperation');
1151
+ return driver.issueOperation(operation, resolution.args, context, getConfig());
1152
+ },
1153
+
1154
+ async runGuardedEvent(event, context = {}) {
1155
+ return runGuardedEventImpl(event, context);
1156
+ },
1157
+
1158
+ // Faithful-import write path (the `forge migrate` enabler PR-3 calls). Consumes
1159
+ // the records importBeadsSnapshot(snapshot).kernel produces — `{ issues, comments,
1160
+ // dependencies, events }` — and writes them DIRECTLY to the authority tables via
1161
+ // the driver's import primitive, PRESERVING each issue's original
1162
+ // created_at/updated_at, terminal status (done/cancelled), priority(+rank),
1163
+ // labels, acceptance/content + beads-fidelity columns, plus its comments and
1164
+ // dependency edges. Distinct from the create/update guarded path (which now-stamps
1165
+ // + bumps CAS); the normal mutation contract is unchanged.
1166
+ //
1167
+ // PRECONDITION: initialize() must have run first (this does NOT auto-init) so the
1168
+ // schema/migrations exist. The canonical input is the `.kernel` records object; the
1169
+ // full importBeadsSnapshot result is accepted as a convenience and unwrapped.
1170
+ // Idempotent (re-import skips existing ids) and transactional. Returns
1171
+ // { issues, comments, dependencies, events } each { inserted, skipped } — where events
1172
+ // are the legacy beads activity/interaction records landed in kernel_events.
1173
+ async importIssues(records = {}, options = {}) {
1174
+ requireDriverMethod(driver, 'importIssues');
1175
+ const config = getConfig();
1176
+ const kernel = records && records.kernel ? records.kernel : records;
1177
+ return driver.importIssues(kernel, options, {}, config);
1178
+ },
1179
+
1180
+ // --- Projection-outbox read/update surface (D16) -----------------------
1181
+ // Additive read/update methods for projection consumers. These never touch
1182
+ // the append/CAS path above (runGuardedEvent / enqueueKernelProjection);
1183
+ // they only read and update existing outbox rows or dead-letter them.
1184
+
1185
+ async listProjectionOutbox(filter = {}, context = {}) {
1186
+ requireDriverMethod(driver, 'listProjectionOutbox');
1187
+ return driver.listProjectionOutbox(filter, context, getConfig());
1188
+ },
1189
+
1190
+ async loadProjectionModel(context = {}) {
1191
+ requireDriverMethod(driver, 'loadProjectionModel');
1192
+ return driver.loadProjectionModel(context, getConfig());
1193
+ },
1194
+
1195
+ async markProjectionDelivered(ids, meta = {}, context = {}) {
1196
+ requireDriverMethod(driver, 'markProjectionDelivered');
1197
+ return driver.markProjectionDelivered(ids, meta, context, getConfig());
1198
+ },
1199
+
1200
+ async recordProjectionFailure(record, context = {}) {
1201
+ requireDriverMethod(driver, 'recordProjectionFailure');
1202
+ return driver.recordProjectionFailure(record, context, getConfig());
1203
+ },
1204
+
1205
+ async deadLetterProjection(record, context = {}) {
1206
+ requireDriverMethod(driver, 'deadLetterProjection');
1207
+ return driver.deadLetterProjection(record, context, getConfig());
1208
+ },
1209
+ };
1210
+ }
1211
+
1212
+ module.exports = {
1213
+ LOCAL_BROKER_PRAGMAS,
1214
+ buildLocalBrokerConfig,
1215
+ createLocalBroker,
1216
+ execMigrationStatement,
1217
+ resolveGitCommonDir,
1218
+ };