forge-workflow 0.0.10 → 0.1.0-beta.3

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 (468) 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 +82 -5
  5. package/.forge/hooks/forge-native-hook.js +431 -0
  6. package/.forge/protected-paths.yaml +157 -0
  7. package/AGENTS.md +151 -61
  8. package/CHANGELOG.md +709 -0
  9. package/CLAUDE.md +9 -118
  10. package/QUICKSTART.md +175 -0
  11. package/README.md +275 -365
  12. package/bin/forge-cmd.js +120 -9
  13. package/bin/forge-preflight.js +26 -5
  14. package/bin/forge.js +532 -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/forge/TOOLCHAIN.md +670 -0
  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 +121 -0
  29. package/docs/guides/SUPPORT.md +190 -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 +214 -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/{TOOLCHAIN.md → reference/TOOLCHAIN.md} +62 -47
  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 +155 -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/activation/ensure-forge-home.js +135 -0
  67. package/lib/adapter-cli.js +307 -0
  68. package/lib/adapters/beads-issue-adapter.js +127 -0
  69. package/lib/adapters/beads-kernel-compat.js +1109 -0
  70. package/lib/adapters/greptile-review-adapter.js +141 -0
  71. package/lib/adapters/kernel-issue-adapter.js +101 -0
  72. package/lib/adapters/pr-state-adapter.js +484 -0
  73. package/lib/adoption-profiles.js +139 -0
  74. package/lib/agents/README.md +2 -6
  75. package/lib/agents/claude.plugin.json +3 -8
  76. package/lib/agents/codex.plugin.json +9 -1
  77. package/lib/agents/cursor.plugin.json +2 -6
  78. package/lib/agents/hermes.plugin.json +22 -0
  79. package/lib/agents-config.js +39 -1236
  80. package/lib/audit-evidence.js +282 -0
  81. package/lib/beads-detect.js +60 -0
  82. package/lib/beads-nudge.js +91 -0
  83. package/lib/beads-setup.js +121 -0
  84. package/lib/beads-sync-scaffold.js +25 -101
  85. package/lib/codex-skills.js +51 -1
  86. package/lib/commands/_aliases.js +248 -0
  87. package/lib/commands/_issue.js +780 -77
  88. package/lib/commands/_manifest.js +93 -0
  89. package/lib/commands/_registry.js +99 -34
  90. package/lib/commands/_resolve-command-opts.js +230 -0
  91. package/lib/commands/_serve-security.js +270 -0
  92. package/lib/commands/adapter.js +12 -0
  93. package/lib/commands/add.js +118 -0
  94. package/lib/commands/audit.js +70 -0
  95. package/lib/commands/blocked.js +5 -0
  96. package/lib/commands/board.js +64 -0
  97. package/lib/commands/claim.js +21 -2
  98. package/lib/commands/claims.js +7 -0
  99. package/lib/commands/clean.js +485 -75
  100. package/lib/commands/close.js +2 -2
  101. package/lib/commands/comment.js +5 -0
  102. package/lib/commands/control.js +148 -0
  103. package/lib/commands/create.js +2 -2
  104. package/lib/commands/dev.js +185 -7
  105. package/lib/commands/doc-gate.js +336 -0
  106. package/lib/commands/doctor.js +156 -0
  107. package/lib/commands/explain.js +15 -0
  108. package/lib/commands/export.js +237 -0
  109. package/lib/commands/gate.js +209 -0
  110. package/lib/commands/hooks.js +377 -0
  111. package/lib/commands/inbox.js +118 -0
  112. package/lib/commands/init.js +604 -0
  113. package/lib/commands/insights.js +79 -0
  114. package/lib/commands/issue.js +12 -1
  115. package/lib/commands/issues.js +17 -0
  116. package/lib/commands/lint.js +5 -0
  117. package/lib/commands/list.js +2 -2
  118. package/lib/commands/memory.js +81 -0
  119. package/lib/commands/merge.js +312 -0
  120. package/lib/commands/migrate.js +362 -0
  121. package/lib/commands/new.js +12 -0
  122. package/lib/commands/options.js +241 -0
  123. package/lib/commands/orient.js +13 -0
  124. package/lib/commands/orphans.js +5 -0
  125. package/lib/commands/patch.js +67 -0
  126. package/lib/commands/plan.js +481 -29
  127. package/lib/commands/pr.js +88 -0
  128. package/lib/commands/preflight.js +211 -0
  129. package/lib/commands/prime.js +13 -0
  130. package/lib/commands/push.js +135 -2
  131. package/lib/commands/ready.js +2 -2
  132. package/lib/commands/recall.js +171 -0
  133. package/lib/commands/recap.js +75 -0
  134. package/lib/commands/recommend.js +0 -1
  135. package/lib/commands/release.js +104 -0
  136. package/lib/commands/remember.js +140 -0
  137. package/lib/commands/role.js +99 -0
  138. package/lib/commands/serve.js +581 -0
  139. package/lib/commands/setup.js +900 -971
  140. package/lib/commands/shepherd.js +501 -0
  141. package/lib/commands/ship.js +59 -1
  142. package/lib/commands/show.js +2 -2
  143. package/lib/commands/stage.js +192 -0
  144. package/lib/commands/stale.js +5 -0
  145. package/lib/commands/status.js +158 -21
  146. package/lib/commands/sync.js +34 -46
  147. package/lib/commands/team.js +4 -1
  148. package/lib/commands/test.js +43 -27
  149. package/lib/commands/update.js +2 -2
  150. package/lib/commands/upgrade.js +47 -0
  151. package/lib/commands/validate.js +43 -18
  152. package/lib/commands/worktree.js +362 -99
  153. package/lib/config-writer.js +202 -0
  154. package/lib/control-plane.js +236 -0
  155. package/lib/core/runtime-graph.js +977 -0
  156. package/lib/dep-guard/keyword-ripple.js +2 -2
  157. package/lib/deprecated-sync-cleanup.js +362 -0
  158. package/lib/detect-agent.js +2 -28
  159. package/lib/detect-worktree.js +35 -9
  160. package/lib/doc-gate/declaration.js +177 -0
  161. package/lib/doc-gate/detect.js +289 -0
  162. package/lib/doc-gate/gate.js +375 -0
  163. package/lib/doc-gate/okf-config.js +128 -0
  164. package/lib/doc-gate/okf.js +429 -0
  165. package/lib/docs-command.js +1161 -6
  166. package/lib/forge-issues.js +382 -11
  167. package/lib/forge-lock.js +262 -0
  168. package/lib/gate-events.js +192 -0
  169. package/lib/global-flags.js +104 -0
  170. package/lib/greptile-match.js +7 -63
  171. package/lib/grounding/context-events.js +230 -0
  172. package/lib/grounding/read-first.js +112 -0
  173. package/lib/harness-capability-matrix.js +380 -0
  174. package/lib/hook-global-installer.js +347 -0
  175. package/lib/hook-renderer.js +541 -0
  176. package/lib/inbox.js +391 -0
  177. package/lib/insights.js +397 -0
  178. package/lib/issue-adapter.js +156 -0
  179. package/lib/issue-backend.js +145 -0
  180. package/lib/issue-render.js +220 -0
  181. package/lib/kernel/backing-issue.js +311 -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/owned-kernel.js +43 -0
  192. package/lib/kernel/planning-buckets-schema.js +109 -0
  193. package/lib/kernel/projection-jsonl-writer.js +450 -0
  194. package/lib/kernel/readiness-model.js +329 -0
  195. package/lib/kernel/schema.js +356 -0
  196. package/lib/kernel/sqlite-driver.js +2540 -0
  197. package/lib/kernel/taxonomy-validator.js +394 -0
  198. package/lib/lefthook-check.js +3 -2
  199. package/lib/lefthook-wiring.js +413 -0
  200. package/lib/mcp-config-renderer.js +288 -0
  201. package/lib/memory/graphiti-mcp.js +106 -0
  202. package/lib/memory/router.js +387 -0
  203. package/lib/memory/typed-api.js +102 -0
  204. package/lib/memory-digest.js +195 -0
  205. package/lib/merge-rules.js +395 -0
  206. package/lib/migrate-dry-run.js +466 -0
  207. package/lib/orientation.js +863 -0
  208. package/lib/package-manager-remediation.js +103 -0
  209. package/lib/package-root.js +381 -0
  210. package/lib/patch-intent.js +890 -0
  211. package/lib/plugin-catalog.js +3 -4
  212. package/lib/plugin-manager.js +0 -5
  213. package/lib/pr-bundle.js +186 -0
  214. package/lib/pr-monitor/auto-actions.js +175 -0
  215. package/lib/pr-monitor/differ.js +195 -0
  216. package/lib/pr-monitor/digest.js +206 -0
  217. package/lib/pr-monitor/events.js +0 -0
  218. package/lib/pr-monitor/gather.js +124 -0
  219. package/lib/pr-monitor/journal.js +299 -0
  220. package/lib/pr-monitor/monitor.js +146 -0
  221. package/lib/pr-monitor/render-sticky.js +192 -0
  222. package/lib/pr-monitor/upsert-sticky.js +169 -0
  223. package/lib/pr-monitor/watch-lifecycle.js +95 -0
  224. package/lib/pr-monitor/watch.js +247 -0
  225. package/lib/pr-pull.js +1314 -0
  226. package/lib/pr-shepherd.js +494 -0
  227. package/lib/pr-state-validator.js +59 -0
  228. package/lib/preflight/gates.js +237 -0
  229. package/lib/preflight/runner.js +116 -0
  230. package/lib/project-discovery.js +0 -53
  231. package/lib/project-memory.js +99 -497
  232. package/lib/protected-path-manifest.js +281 -0
  233. package/lib/protected-state-surfaces.js +387 -0
  234. package/lib/release-readiness.js +2105 -0
  235. package/lib/reset.js +59 -45
  236. package/lib/review-adapter.js +68 -0
  237. package/lib/rules-sync.js +260 -0
  238. package/lib/runtime-health.js +241 -20
  239. package/lib/safety-config-renderer.js +268 -0
  240. package/lib/setup-action-log.js +1 -7
  241. package/lib/setup.js +27 -65
  242. package/lib/shell-utils.js +76 -6
  243. package/lib/skills-sync.js +330 -0
  244. package/lib/smart-status/scoring.js +17 -3
  245. package/lib/status/beads-snapshot.js +45 -2
  246. package/lib/status/presenter.js +169 -18
  247. package/lib/status/snapshot.js +186 -0
  248. package/lib/sync-backend.js +202 -0
  249. package/lib/untrusted-content.js +52 -0
  250. package/lib/upgrade-safety.js +251 -0
  251. package/lib/workflow/enforce-stage.js +351 -45
  252. package/lib/workflow/stage-transition.js +115 -0
  253. package/lib/workflow/stages.js +30 -6
  254. package/lib/workflow/state-manager.js +11 -22
  255. package/lib/workflow/state.js +23 -1
  256. package/lib/workflow-profiles.js +17 -5
  257. package/package.json +37 -35
  258. package/rules/documentation.md +19 -0
  259. package/rules/kernel-tracking.md +26 -0
  260. package/rules/security.md +22 -0
  261. package/rules/tdd.md +20 -0
  262. package/rules/workflow.md +27 -0
  263. package/scripts/auto-backing-issue.js +47 -0
  264. package/scripts/beads-context.sh +81 -57
  265. package/scripts/beads-upgrade-smoke.sh +24 -3
  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.sh +22 -3
  272. package/scripts/file-index.sh +3 -0
  273. package/scripts/forge-team/lib/claim.sh +34 -18
  274. package/scripts/forge-team/lib/dashboard.sh +61 -86
  275. package/scripts/forge-team/lib/epic.sh +99 -263
  276. package/scripts/forge-team/lib/hooks.sh +26 -28
  277. package/scripts/forge-team/lib/identity.sh +4 -4
  278. package/scripts/forge-team/lib/sync-github.sh +49 -84
  279. package/scripts/forge-team/lib/verify.sh +93 -83
  280. package/scripts/forge-team/lib/workload.sh +41 -65
  281. package/scripts/forge-team/tests/claim.test.sh +25 -19
  282. package/scripts/forge-team/tests/dashboard.test.sh +31 -46
  283. package/scripts/forge-team/tests/epic.test.sh +52 -71
  284. package/scripts/forge-team/tests/hooks.test.sh +38 -50
  285. package/scripts/forge-team/tests/identity.test.sh +3 -3
  286. package/scripts/forge-team/tests/integration.test.sh +44 -66
  287. package/scripts/forge-team/tests/sync-github.test.sh +50 -83
  288. package/scripts/forge-team/tests/verify.test.sh +37 -46
  289. package/scripts/forge-team/tests/workflow-integration.test.sh +4 -4
  290. package/scripts/forge-team/tests/workload.test.sh +32 -66
  291. package/scripts/gen-command-manifest.js +153 -0
  292. package/scripts/gen-embedded-assets.mjs +129 -0
  293. package/scripts/install.ps1 +139 -0
  294. package/scripts/install.sh +268 -0
  295. package/scripts/lib/release-asset.mjs +84 -0
  296. package/scripts/parity-check.mjs +145 -0
  297. package/scripts/parity-check.test.mjs +58 -0
  298. package/scripts/pin-agentic-workflow-images.js +112 -0
  299. package/scripts/pr-auto-actions.js +93 -0
  300. package/scripts/pr-coordinator.sh +3 -0
  301. package/scripts/pr-verdict-label.js +50 -0
  302. package/scripts/preflight-sonar.eslint.config.mjs +44 -0
  303. package/scripts/preflight.sh +21 -94
  304. package/scripts/protected-state-check.js +104 -0
  305. package/scripts/smart-status.sh +60 -57
  306. package/scripts/spikes/config-race-bench.js +111 -0
  307. package/scripts/spikes/harness-capability-matrix.js +13 -0
  308. package/scripts/spikes/patch-anchor-stability-bench.js +125 -0
  309. package/scripts/spikes/protected-path-manifest.js +20 -0
  310. package/scripts/spikes/skill-auto-invoke-parity.js +292 -0
  311. package/scripts/sync-agent-skills.js +62 -0
  312. package/scripts/sync-utils.sh +3 -0
  313. package/scripts/test-ci-shard.js +13 -6
  314. package/scripts/test.js +95 -12
  315. package/skills/claim-safety/SKILL.md +102 -0
  316. package/skills/claim-safety/evals/evals.json +46 -0
  317. package/{.github/prompts/dev.prompt.md → skills/dev/SKILL.md} +44 -50
  318. package/skills/dev/evals/evals.json +50 -0
  319. package/skills/hermes-forge/SKILL.md +185 -0
  320. package/skills/hermes-forge/evals/evals.json +46 -0
  321. package/skills/issue-basics/SKILL.md +111 -0
  322. package/skills/issue-basics/evals/evals.json +46 -0
  323. package/skills/kernel/SKILL.md +166 -0
  324. package/skills/kernel/evals/evals.json +50 -0
  325. package/skills/memory/SKILL.md +102 -0
  326. package/skills/parallel-deep-research/SKILL.md +14 -11
  327. package/skills/parallel-deep-research/evals/evals.json +11 -27
  328. package/{.github/prompts/plan.prompt.md → skills/plan/SKILL.md} +132 -157
  329. package/skills/plan/evals/evals.json +42 -0
  330. package/skills/research/SKILL.md +195 -0
  331. package/skills/research/evals/evals.json +42 -0
  332. package/{.github/prompts/review.prompt.md → skills/review/SKILL.md} +98 -62
  333. package/skills/review/evals/evals.json +42 -0
  334. package/skills/rollback/SKILL.md +110 -0
  335. package/skills/rollback/evals/evals.json +46 -0
  336. package/skills/rollback/references/methods.md +204 -0
  337. package/{.cursor/commands/rollback.md → skills/rollback/references/workflow-integration.md} +10 -284
  338. package/skills/shepherd/SKILL.md +66 -0
  339. package/skills/shepherd/evals/evals.json +42 -0
  340. package/{.github/prompts/ship.prompt.md → skills/ship/SKILL.md} +81 -45
  341. package/skills/ship/evals/evals.json +42 -0
  342. package/skills/smith/SKILL.md +142 -0
  343. package/skills/smith/evals/evals.json +46 -0
  344. package/skills/smith/references/autonomy-and-gates.md +94 -0
  345. package/{.github/prompts/sonarcloud.prompt.md → skills/sonarcloud/SKILL.md} +14 -3
  346. package/skills/sonarcloud/evals/evals.json +46 -0
  347. package/skills/sonarcloud-analysis/SKILL.md +18 -13
  348. package/skills/sonarcloud-analysis/evals/evals.json +11 -15
  349. package/{.github/prompts/status.prompt.md → skills/status/SKILL.md} +20 -10
  350. package/skills/status/evals/evals.json +50 -0
  351. package/skills/triage-ready/SKILL.md +121 -0
  352. package/skills/triage-ready/evals/evals.json +42 -0
  353. package/{.github/prompts/validate.prompt.md → skills/validate/SKILL.md} +52 -29
  354. package/skills/validate/evals/evals.json +42 -0
  355. package/skills/verify/SKILL.md +299 -0
  356. package/skills/verify/evals/evals.json +50 -0
  357. package/.claude/commands/dev.md +0 -345
  358. package/.claude/commands/plan.md +0 -566
  359. package/.claude/commands/premerge.md +0 -186
  360. package/.claude/commands/research.md +0 -42
  361. package/.claude/commands/review.md +0 -451
  362. package/.claude/commands/rollback.md +0 -721
  363. package/.claude/commands/ship.md +0 -213
  364. package/.claude/commands/sonarcloud.md +0 -152
  365. package/.claude/commands/status.md +0 -90
  366. package/.claude/commands/validate.md +0 -288
  367. package/.claude/commands/verify.md +0 -269
  368. package/.claude/rules/workflow.md +0 -121
  369. package/.cline/workflows/dev.md +0 -342
  370. package/.cline/workflows/plan.md +0 -563
  371. package/.cline/workflows/premerge.md +0 -183
  372. package/.cline/workflows/research.md +0 -39
  373. package/.cline/workflows/review.md +0 -448
  374. package/.cline/workflows/rollback.md +0 -718
  375. package/.cline/workflows/ship.md +0 -210
  376. package/.cline/workflows/sonarcloud.md +0 -146
  377. package/.cline/workflows/status.md +0 -87
  378. package/.cline/workflows/validate.md +0 -285
  379. package/.cline/workflows/verify.md +0 -266
  380. package/.codex/config.toml +0 -11
  381. package/.codex/skills/dev/SKILL.md +0 -345
  382. package/.codex/skills/plan/SKILL.md +0 -566
  383. package/.codex/skills/premerge/SKILL.md +0 -186
  384. package/.codex/skills/research/SKILL.md +0 -42
  385. package/.codex/skills/review/SKILL.md +0 -451
  386. package/.codex/skills/rollback/SKILL.md +0 -721
  387. package/.codex/skills/ship/SKILL.md +0 -213
  388. package/.codex/skills/sonarcloud/SKILL.md +0 -149
  389. package/.codex/skills/status/SKILL.md +0 -90
  390. package/.codex/skills/validate/SKILL.md +0 -288
  391. package/.codex/skills/verify/SKILL.md +0 -269
  392. package/.cursor/commands/dev.md +0 -342
  393. package/.cursor/commands/plan.md +0 -563
  394. package/.cursor/commands/premerge.md +0 -183
  395. package/.cursor/commands/research.md +0 -39
  396. package/.cursor/commands/review.md +0 -448
  397. package/.cursor/commands/ship.md +0 -210
  398. package/.cursor/commands/sonarcloud.md +0 -146
  399. package/.cursor/commands/status.md +0 -87
  400. package/.cursor/commands/validate.md +0 -285
  401. package/.cursor/commands/verify.md +0 -266
  402. package/.cursorrules +0 -149
  403. package/.github/prompts/premerge.prompt.md +0 -188
  404. package/.github/prompts/research.prompt.md +0 -44
  405. package/.github/prompts/rollback.prompt.md +0 -723
  406. package/.github/prompts/verify.prompt.md +0 -271
  407. package/.github/workflows/beads-to-github.yml +0 -89
  408. package/.github/workflows/github-to-beads.yml +0 -100
  409. package/.kilocode/workflows/dev.md +0 -346
  410. package/.kilocode/workflows/plan.md +0 -567
  411. package/.kilocode/workflows/premerge.md +0 -187
  412. package/.kilocode/workflows/research.md +0 -43
  413. package/.kilocode/workflows/review.md +0 -452
  414. package/.kilocode/workflows/rollback.md +0 -722
  415. package/.kilocode/workflows/ship.md +0 -214
  416. package/.kilocode/workflows/sonarcloud.md +0 -150
  417. package/.kilocode/workflows/status.md +0 -91
  418. package/.kilocode/workflows/validate.md +0 -289
  419. package/.kilocode/workflows/verify.md +0 -270
  420. package/.opencode/commands/dev.md +0 -345
  421. package/.opencode/commands/plan.md +0 -566
  422. package/.opencode/commands/premerge.md +0 -186
  423. package/.opencode/commands/research.md +0 -42
  424. package/.opencode/commands/review.md +0 -451
  425. package/.opencode/commands/rollback.md +0 -721
  426. package/.opencode/commands/ship.md +0 -213
  427. package/.opencode/commands/sonarcloud.md +0 -149
  428. package/.opencode/commands/status.md +0 -90
  429. package/.opencode/commands/validate.md +0 -288
  430. package/.opencode/commands/verify.md +0 -269
  431. package/.roo/commands/dev.md +0 -346
  432. package/.roo/commands/plan.md +0 -567
  433. package/.roo/commands/premerge.md +0 -187
  434. package/.roo/commands/research.md +0 -43
  435. package/.roo/commands/review.md +0 -452
  436. package/.roo/commands/rollback.md +0 -722
  437. package/.roo/commands/ship.md +0 -214
  438. package/.roo/commands/sonarcloud.md +0 -150
  439. package/.roo/commands/status.md +0 -91
  440. package/.roo/commands/validate.md +0 -289
  441. package/.roo/commands/verify.md +0 -270
  442. package/docs/BEADS_GITHUB_SYNC.md +0 -281
  443. package/docs/GREPTILE_SETUP.md +0 -400
  444. package/docs/MANUAL_REVIEW_GUIDE.md +0 -106
  445. package/docs/SETUP.md +0 -663
  446. package/docs/VALIDATION.md +0 -363
  447. package/lib/agents/cline.plugin.json +0 -29
  448. package/lib/agents/copilot.plugin.json +0 -24
  449. package/lib/agents/kilocode.plugin.json +0 -22
  450. package/lib/agents/opencode.plugin.json +0 -23
  451. package/lib/agents/roo.plugin.json +0 -30
  452. package/lib/beads-bootstrap.js +0 -225
  453. package/lib/beads-health-check.js +0 -188
  454. package/lib/commands/commands-reset.js +0 -147
  455. package/opencode.json +0 -67
  456. package/scripts/beads-context.test.js +0 -584
  457. package/scripts/github-beads-sync/comment.mjs +0 -64
  458. package/scripts/github-beads-sync/config.mjs +0 -148
  459. package/scripts/github-beads-sync/github-api.mjs +0 -131
  460. package/scripts/github-beads-sync/index.mjs +0 -356
  461. package/scripts/github-beads-sync/label-mapper.mjs +0 -54
  462. package/scripts/github-beads-sync/mapping.mjs +0 -132
  463. package/scripts/github-beads-sync/reverse-sync-cli.mjs +0 -31
  464. package/scripts/github-beads-sync/reverse-sync.mjs +0 -162
  465. package/scripts/github-beads-sync/run-bd.mjs +0 -161
  466. package/scripts/github-beads-sync/sanitize.mjs +0 -121
  467. package/scripts/github-beads-sync.config.json +0 -26
  468. package/scripts/sync-commands.js +0 -600
@@ -0,0 +1,2540 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { randomUUID } = require('node:crypto');
7
+
8
+ const {
9
+ ISSUE_COMMAND_SCHEMA_VERSION,
10
+ ISSUE_COMMAND_EXIT_CODES,
11
+ formatIssueCommandError,
12
+ normalizePriority,
13
+ resolveNextCommands,
14
+ } = require('./issue-command-contract');
15
+ const { buildReadinessIndex } = require('./readiness-model');
16
+ const { buildMemoryProjectionMigration, memoryFtsDdl } = require('./migrations');
17
+ const { rankForPriorityLabel } = require('./taxonomy-validator');
18
+ const { isLeaseExpired } = require('./lease-enforcer');
19
+ const { CONFLICT_SIGNAL, classifyConflictSignal } = require('./conflict-signal');
20
+
21
+ const BUILTIN_SQLITE_RUNTIME_ORDER = Object.freeze(['bun:sqlite', 'node:sqlite']);
22
+ let probeCounter = 0;
23
+
24
+ function isModuleUnavailable(error) {
25
+ return error && (
26
+ error.code === 'MODULE_NOT_FOUND'
27
+ || error.code === 'ERR_UNKNOWN_BUILTIN_MODULE'
28
+ || /Cannot find module|No such built-in module/i.test(String(error.message || error))
29
+ );
30
+ }
31
+
32
+ function loadRuntimeDescriptor(id, sqliteModule) {
33
+ if (id === 'bun:sqlite') {
34
+ if (typeof sqliteModule.Database !== 'function') {
35
+ throw new Error('bun:sqlite is present but does not expose Database');
36
+ }
37
+ return {
38
+ id,
39
+ module: sqliteModule,
40
+ databaseClassName: 'Database',
41
+ nativeCompileDependency: false,
42
+ experimental: false,
43
+ };
44
+ }
45
+
46
+ if (id === 'node:sqlite') {
47
+ if (typeof sqliteModule.DatabaseSync !== 'function') {
48
+ throw new Error('node:sqlite is present but does not expose DatabaseSync');
49
+ }
50
+ const hasBackupApi = typeof sqliteModule.backup === 'function'
51
+ || typeof sqliteModule.DatabaseSync.prototype.backup === 'function';
52
+ if (!hasBackupApi) {
53
+ throw new Error('node:sqlite is present but does not expose backup support; run with Node >= 22.16 or Bun >= 1.2');
54
+ }
55
+ return {
56
+ id,
57
+ module: sqliteModule,
58
+ databaseClassName: 'DatabaseSync',
59
+ nativeCompileDependency: false,
60
+ experimental: true,
61
+ };
62
+ }
63
+
64
+ throw new Error(`Unsupported builtin SQLite runtime: ${id}`);
65
+ }
66
+
67
+ // Requiring `node:sqlite` emits a one-time Node ExperimentalWarning via
68
+ // process.emitWarning AT REQUIRE TIME (before any CLI flag context exists), which
69
+ // would prepend noise to the CLI's human/JSON output. Suppress ONLY that SQLite
70
+ // warning around the require and restore process.emitWarning immediately after — every
71
+ // other warning passes through untouched. This never re-execs with --no-warnings.
72
+ function requireSqliteRuntimeModule(requireModule, id) {
73
+ if (id !== 'node:sqlite') {
74
+ return requireModule(id);
75
+ }
76
+ const originalEmitWarning = process.emitWarning;
77
+ process.emitWarning = function suppressSqliteExperimentalWarning(warning, ...rest) {
78
+ const message = typeof warning === 'string' ? warning : (warning && warning.message) || '';
79
+ if (/SQLite/i.test(String(message))) {
80
+ return undefined;
81
+ }
82
+ return originalEmitWarning.call(process, warning, ...rest);
83
+ };
84
+ try {
85
+ return requireModule(id);
86
+ } finally {
87
+ process.emitWarning = originalEmitWarning;
88
+ }
89
+ }
90
+
91
+ function selectBuiltinSQLiteRuntime(deps = {}) {
92
+ const requireModule = deps.requireModule || require;
93
+ const unavailable = [];
94
+
95
+ for (const id of BUILTIN_SQLITE_RUNTIME_ORDER) {
96
+ try {
97
+ return loadRuntimeDescriptor(id, requireSqliteRuntimeModule(requireModule, id));
98
+ } catch (error) {
99
+ if (!isModuleUnavailable(error)) {
100
+ throw error;
101
+ }
102
+ unavailable.push(`${id}: ${error.message || error}`);
103
+ }
104
+ }
105
+
106
+ throw new Error([
107
+ 'Forge Kernel requires a builtin SQLite runtime: bun:sqlite or node:sqlite.',
108
+ 'Install/run Forge with Bun >= 1.2 or Node >= 22.16 with node:sqlite backup support.',
109
+ 'No native-compile SQLite package is installed by default.',
110
+ `Detection failures: ${unavailable.join('; ')}`,
111
+ ].join(' '));
112
+ }
113
+
114
+ function ensureFileBackedDatabaseDirectory(databasePath) {
115
+ if (!databasePath || databasePath === ':memory:' || String(databasePath).startsWith('file:')) {
116
+ return;
117
+ }
118
+ const databaseDir = path.dirname(databasePath);
119
+ if (databaseDir && databaseDir !== '.') {
120
+ fs.mkdirSync(databaseDir, { recursive: true });
121
+ }
122
+ }
123
+
124
+ function createDatabase(runtime, databasePath) {
125
+ ensureFileBackedDatabaseDirectory(databasePath);
126
+ if (runtime.id === 'bun:sqlite') {
127
+ return new runtime.module.Database(databasePath, { create: true });
128
+ }
129
+ if (runtime.id === 'node:sqlite') {
130
+ return new runtime.module.DatabaseSync(databasePath);
131
+ }
132
+ throw new Error(`Unsupported builtin SQLite runtime: ${runtime.id}`);
133
+ }
134
+
135
+ function execSql(_runtime, db, sql) {
136
+ db.exec(sql);
137
+ }
138
+
139
+ function queryAll(runtime, db, sql) {
140
+ if (runtime.id === 'bun:sqlite') {
141
+ return db.query(sql).all();
142
+ }
143
+ return db.prepare(sql).all();
144
+ }
145
+
146
+ function queryOne(runtime, db, sql) {
147
+ return queryAll(runtime, db, sql)[0] || {};
148
+ }
149
+
150
+ // Parameterized statement helpers — bun:sqlite and node:sqlite both bind positional
151
+ // `?` params, but expose them through different APIs. All issue-layer SQL MUST use these
152
+ // (never string interpolation of values) to stay injection-safe.
153
+ function allParams(runtime, db, sql, params = []) {
154
+ if (runtime.id === 'bun:sqlite') {
155
+ return db.query(sql).all(...params);
156
+ }
157
+ return db.prepare(sql).all(...params);
158
+ }
159
+
160
+ // Parameterized write helper (INSERT/UPDATE/DELETE). Like allParams, both runtimes
161
+ // bind positional `?` params but expose .run() through different statement APIs. All
162
+ // mutating issue-layer SQL MUST use this (never interpolate values) to stay
163
+ // injection-safe. Native UNIQUE-constraint errors are intentionally allowed to
164
+ // propagate unmodified — the broker parses their raw message to convert an
165
+ // idempotency/lease collision into a duplicate replay.
166
+ function runParams(runtime, db, sql, params = []) {
167
+ if (runtime.id === 'bun:sqlite') {
168
+ return db.query(sql).run(...params);
169
+ }
170
+ return db.prepare(sql).run(...params);
171
+ }
172
+
173
+ // A table may not exist on a partially-migrated DB; readiness inputs degrade to empty.
174
+ // ONLY a missing-table error is tolerated — a locked/corrupt DB or a real SQL
175
+ // regression must surface, not silently produce wrong readiness/stats/projection.
176
+ function safeAll(runtime, db, sql, params = []) {
177
+ try {
178
+ return allParams(runtime, db, sql, params);
179
+ } catch (error) {
180
+ if (/no such table/i.test(String(error?.message || ''))) {
181
+ return [];
182
+ }
183
+ throw error;
184
+ }
185
+ }
186
+
187
+ // Labels are stored as a JSON-array TEXT column (canonical, written by KAP-4) but a
188
+ // legacy comma-separated value is tolerated. Always returns a string[] — [] when the
189
+ // column is null/empty/unparseable — so the projection never surfaces a raw blob.
190
+ function parseLabels(raw) {
191
+ if (raw == null || raw === '') return [];
192
+ if (Array.isArray(raw)) return raw.map(String);
193
+ if (typeof raw !== 'string') return [];
194
+ const trimmed = raw.trim();
195
+ if (!trimmed) return [];
196
+ if (trimmed.startsWith('[')) {
197
+ try {
198
+ const parsed = JSON.parse(trimmed);
199
+ return Array.isArray(parsed) ? parsed.map(String) : [];
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
204
+ return trimmed.split(',').map(value => value.trim()).filter(Boolean);
205
+ }
206
+
207
+ function rowToIssueSummary(row, readinessEntry, claimedBy = null, dependencyIds = [], dependentIds = []) {
208
+ return {
209
+ id: row.id,
210
+ title: row.title,
211
+ body: row.body ?? null,
212
+ type: row.type,
213
+ status: row.status,
214
+ // priority is the stored label (notNull default 'P2'); rank is the numeric sort key.
215
+ priority: row.priority,
216
+ rank: Number(row.priority_rank) || 0,
217
+ revision: Number(row.entity_revision) || 0,
218
+ blocked: readinessEntry ? Boolean(readinessEntry.blocked) : false,
219
+ // kernel_issues has no claimed_by column; the active lease in kernel_claims is
220
+ // the authority. Derive the holder from the issue's active claim (the
221
+ // partial-UNIQUE index guarantees at most one), defaulting to null when free.
222
+ claimed_by: claimedBy ?? row.claimed_by ?? null,
223
+ // KAP-2: parent/labels/dependencies/created_at are all stored; surface them so
224
+ // agents get the full issue shape without a second query. dependencies are the
225
+ // ids this issue depends on (blocks_issue_id where issue_id === this row).
226
+ parent_id: row.parent_id ?? null,
227
+ labels: parseLabels(row.labels),
228
+ dependencies: Array.isArray(dependencyIds) ? dependencyIds : [],
229
+ // Epic/reverse-dependency exposure: `dependents` are the ids that depend on this
230
+ // issue (the inverse of `dependencies` — issue_id where blocks_issue_id === this
231
+ // row), and `blocked_by` is the readiness model's LIVE blocker subset (dependencies
232
+ // still open; done/cancelled blockers dropped). Both are a strict additive superset
233
+ // surfaced on every read op so consumers never run a second reverse-scan query.
234
+ dependents: Array.isArray(dependentIds) ? dependentIds : [],
235
+ blocked_by: readinessEntry ? (readinessEntry.blocked_by ?? []) : [],
236
+ created_at: row.created_at,
237
+ updated_at: row.updated_at,
238
+ // KAP-10 (acceptance_criteria/design/notes) + KAP-11 (assignee): authored
239
+ // content fields and the persistent assignee, each null when unset. assignee is
240
+ // distinct from claimed_by (the transient lease holder) — both coexist.
241
+ acceptance_criteria: row.acceptance_criteria ?? null,
242
+ design: row.design ?? null,
243
+ notes: row.notes ?? null,
244
+ assignee: row.assignee ?? null,
245
+ // Beads full-fidelity import: author, close timestamp, raw close reason and the
246
+ // verbatim metadata JSON blob, each null when the column is unset.
247
+ created_by: row.created_by ?? null,
248
+ closed_at: row.closed_at ?? null,
249
+ close_reason: row.close_reason ?? null,
250
+ metadata: row.metadata ?? null,
251
+ };
252
+ }
253
+
254
+ function okIssueResponse(command, data, nextCommands) {
255
+ return {
256
+ ok: true,
257
+ schema_version: ISSUE_COMMAND_SCHEMA_VERSION,
258
+ command,
259
+ data,
260
+ // Default the read-op next_commands from the contract catalog (KAP-1's envelope
261
+ // is worthless to agents if it carries an empty array). An explicit array still
262
+ // wins; otherwise resolve + substitute the concrete id for single-issue responses.
263
+ next_commands: Array.isArray(nextCommands) ? nextCommands : resolveNextCommands(command, data),
264
+ };
265
+ }
266
+
267
+ // Derive the whole-board readiness read model (D18) from the authority tables.
268
+ function loadBoardReadiness(runtime, db, context = {}) {
269
+ const issues = allParams(runtime, db, 'SELECT * FROM kernel_issues');
270
+ const dependencies = safeAll(runtime, db, 'SELECT * FROM kernel_dependencies');
271
+ const conflicts = safeAll(runtime, db, 'SELECT * FROM kernel_conflicts');
272
+ const claims = safeAll(runtime, db, 'SELECT * FROM kernel_claims');
273
+ const index = buildReadinessIndex({
274
+ issues,
275
+ dependencies,
276
+ conflicts,
277
+ claims,
278
+ now: context.now,
279
+ actor: context.actor,
280
+ });
281
+ // Surface the active lease holder per issue for issue summaries. Filter on state
282
+ // only (matches loadActiveKernelClaimRow); the partial-UNIQUE active-lease index
283
+ // guarantees at most one active row per issue, so the map is unambiguous.
284
+ // Null-prototype map: issue ids are unconstrained external strings, so a literal `{}`
285
+ // keyed by them would be a prototype-pollution vector (matches buildReadinessIndex).
286
+ const claimedById = Object.create(null);
287
+ for (const claim of claims) {
288
+ if ((claim.state || 'active') === 'active' && claim.issue_id) {
289
+ claimedById[claim.issue_id] = claim.actor ?? null;
290
+ }
291
+ }
292
+ // Per-issue declared dependency edges (the ids each issue depends on, i.e.
293
+ // blocks_issue_id where issue_id === the dependent). Distinct from readiness'
294
+ // blocked_by, which drops done/cancelled blockers — this is the full declared set.
295
+ // Null-prototype map: issue ids are unconstrained external strings.
296
+ const dependenciesById = Object.create(null);
297
+ // Per-issue reverse edges (the ids that depend ON each issue, i.e. issue_id where
298
+ // blocks_issue_id === the blocker). This is the inverse of dependenciesById and the
299
+ // same query computeNewlyUnblocked runs on the close path, lifted to the board load
300
+ // so every read op can surface `dependents` without a second reverse scan.
301
+ // Null-prototype map: issue ids are unconstrained external strings.
302
+ const dependentsById = Object.create(null);
303
+ for (const dependency of dependencies) {
304
+ if (!dependency.issue_id || dependency.blocks_issue_id == null) continue;
305
+ const list = dependenciesById[dependency.issue_id]
306
+ || (dependenciesById[dependency.issue_id] = []);
307
+ list.push(dependency.blocks_issue_id);
308
+ const dependents = dependentsById[dependency.blocks_issue_id]
309
+ || (dependentsById[dependency.blocks_issue_id] = []);
310
+ dependents.push(dependency.issue_id);
311
+ }
312
+ for (const issueId of Object.keys(dependenciesById)) {
313
+ dependenciesById[issueId] = [...new Set(dependenciesById[issueId])]
314
+ .sort((a, b) => String(a).localeCompare(String(b)));
315
+ }
316
+ for (const issueId of Object.keys(dependentsById)) {
317
+ dependentsById[issueId] = [...new Set(dependentsById[issueId])]
318
+ .sort((a, b) => String(a).localeCompare(String(b)));
319
+ }
320
+ return { issues, index, claimedById, dependenciesById, dependentsById };
321
+ }
322
+
323
+ function firstPositional(args = []) {
324
+ return (args || []).find(value => typeof value === 'string' && !value.startsWith('-'));
325
+ }
326
+
327
+ // KAP-6: parse the `list` op's --status / --type / --label flags from the arg array.
328
+ // Accepts both `--flag value` (value is the next array element) and `--flag=value`
329
+ // (value follows the `=`). A key is only set when a value is actually present, so an
330
+ // absent flag leaves it undefined and does not constrain that dimension. Unknown flags
331
+ // are ignored. Values are never interpolated into SQL — filtering runs JS-side over the
332
+ // already-built issue summaries (status/type/labels[]).
333
+ const LIST_FILTER_FLAGS = Object.freeze(['status', 'type', 'label', 'priority']);
334
+
335
+ function parseListFilters(args = []) {
336
+ const filters = Object.create(null);
337
+ const list = args || [];
338
+ for (let i = 0; i < list.length; i += 1) {
339
+ const arg = list[i];
340
+ if (typeof arg !== 'string' || !arg.startsWith('--')) continue;
341
+ const eq = arg.indexOf('=');
342
+ const name = (eq === -1 ? arg.slice(2) : arg.slice(2, eq));
343
+ if (!LIST_FILTER_FLAGS.includes(name)) continue;
344
+ if (eq !== -1) {
345
+ // `--status=` (empty value) is treated as MISSING, not an empty-string filter
346
+ // that would match nothing — matches the documented "only set when present".
347
+ const value = arg.slice(eq + 1);
348
+ if (value !== '') {
349
+ filters[name] = value;
350
+ }
351
+ continue;
352
+ }
353
+ const next = list[i + 1];
354
+ if (typeof next === 'string' && !next.startsWith('-')) {
355
+ filters[name] = next;
356
+ i += 1;
357
+ }
358
+ }
359
+ return filters;
360
+ }
361
+
362
+ // KAP-7: parse the `stale` op's --days threshold (both `--days <n>` and `--days=<n>`
363
+ // forms). Returns the integer day window. A missing flag, or a NaN / non-positive
364
+ // value, falls back to STALE_DEFAULT_DAYS — a zero/negative window would make every
365
+ // open issue "stale", which is never the intended query. Mirrors parseListFilters'
366
+ // flag-scan; the value is never interpolated into SQL (the threshold compares JS-side).
367
+ const STALE_DEFAULT_DAYS = 14;
368
+
369
+ function parseStaleDays(args = []) {
370
+ const list = args || [];
371
+ for (let i = 0; i < list.length; i += 1) {
372
+ const arg = list[i];
373
+ if (typeof arg !== 'string') continue;
374
+ let raw;
375
+ if (arg === '--days') {
376
+ raw = list[i + 1];
377
+ } else if (arg.startsWith('--days=')) {
378
+ raw = arg.slice('--days='.length);
379
+ } else {
380
+ continue;
381
+ }
382
+ const parsed = Number(raw);
383
+ if (Number.isFinite(parsed) && parsed > 0) {
384
+ return Math.floor(parsed);
385
+ }
386
+ return STALE_DEFAULT_DAYS;
387
+ }
388
+ return STALE_DEFAULT_DAYS;
389
+ }
390
+
391
+ // KAP-7: shared list-style sort for derived read queries (rank asc, then id) — the
392
+ // exact ordering `list`/the contract tests expect, so blocked/stale/orphans are
393
+ // deterministic.
394
+ function sortIssueSummaries(summaries) {
395
+ return summaries.sort((a, b) => (a.rank - b.rank) || String(a.id).localeCompare(String(b.id)));
396
+ }
397
+
398
+ // Epic rollup over a list of child issue summaries. The kernel owns the status
399
+ // vocabulary (open|in_progress|review|done|cancelled), so emitting the rollup means
400
+ // consumers never hard-code status names (notably the beads `closed` the shells used
401
+ // to count). Locked maintainer decisions: percentage is done-ONLY (cancelled does NOT
402
+ // count toward complete) and `total` is the direct-children count. `blocked` counts
403
+ // children whose readiness model flips blocked. `by_status` is the full per-status
404
+ // histogram; an unknown status (taxonomy-validator forbids it) is counted in `total`
405
+ // but not bucketed.
406
+ const ROLLUP_STATUSES = Object.freeze(['open', 'in_progress', 'review', 'done', 'cancelled', 'backlog']);
407
+
408
+ function buildRollup(children) {
409
+ const byStatus = Object.create(null);
410
+ for (const status of ROLLUP_STATUSES) {
411
+ byStatus[status] = 0;
412
+ }
413
+ let blocked = 0;
414
+ for (const child of children) {
415
+ if (Object.prototype.hasOwnProperty.call(byStatus, child.status)) {
416
+ byStatus[child.status] += 1;
417
+ }
418
+ if (child.blocked) blocked += 1;
419
+ }
420
+ const total = children.length;
421
+ const done = byStatus.done;
422
+ const percentage = total === 0 ? 0 : Math.round((done / total) * 100);
423
+ return {
424
+ total,
425
+ done,
426
+ in_progress: byStatus.in_progress,
427
+ open: byStatus.open,
428
+ review: byStatus.review,
429
+ cancelled: byStatus.cancelled,
430
+ backlog: byStatus.backlog,
431
+ blocked,
432
+ percentage,
433
+ by_status: { ...byStatus },
434
+ };
435
+ }
436
+
437
+ // Read-side of driver.issueOperation: ready/list/show/search/stats as parameterized
438
+ // SELECTs returning issue-command-contract shapes. Mutations are handled separately
439
+ // through the broker's guarded-event path (later wave).
440
+ function runIssueReadOperation(runtime, db, operation, args, context) {
441
+ if (operation === 'list') {
442
+ const { issues, index, claimedById, dependenciesById, dependentsById } = loadBoardReadiness(runtime, db, context);
443
+ // KAP-6: server-side --status/--type/--label filtering. Filter the summaries (they
444
+ // already carry status/type/parsed labels[]) so the readiness load stays shared and
445
+ // untouched. status/type are exact-match; --label keeps issues whose labels[] include
446
+ // the value. Multiple filters AND; an absent filter does not constrain that dimension;
447
+ // an unknown value simply matches nothing (exact-match → empty result, no special case).
448
+ const filters = parseListFilters(args);
449
+ // Normalize the --priority filter ONCE to its canonical label; each candidate's
450
+ // stored priority is normalized too before the exact-match compare. Legacy rows
451
+ // store a mixed bare-int / P-label form, so '1' and 'P1' rows both match
452
+ // --priority=1 AND --priority=P1 (the filter arg and the stored value canonicalize
453
+ // to the same label). An unknown value normalizes to itself and matches nothing.
454
+ const priorityFilter = filters.priority === undefined ? undefined : normalizePriority(filters.priority);
455
+ const summaries = issues
456
+ .map(row => rowToIssueSummary(row, index.readinessById[row.id], claimedById[row.id], dependenciesById[row.id], dependentsById[row.id]))
457
+ .filter(summary => (filters.status === undefined || summary.status === filters.status)
458
+ && (filters.type === undefined || summary.type === filters.type)
459
+ && (filters.label === undefined || summary.labels.includes(filters.label))
460
+ && (priorityFilter === undefined || normalizePriority(summary.priority) === priorityFilter))
461
+ .sort((a, b) => (a.rank - b.rank) || String(a.id).localeCompare(String(b.id)));
462
+ return okIssueResponse('issue.list', { issues: summaries, count: summaries.length });
463
+ }
464
+ if (operation === 'ready') {
465
+ const { issues, index, claimedById, dependenciesById, dependentsById } = loadBoardReadiness(runtime, db, context);
466
+ const byId = new Map(issues.map(row => [row.id, row]));
467
+ const summaries = index.readyQueue.map(id => rowToIssueSummary(byId.get(id), index.readinessById[id], claimedById[id], dependenciesById[id], dependentsById[id]));
468
+ return okIssueResponse('issue.ready', { issues: summaries, count: summaries.length });
469
+ }
470
+ if (operation === 'show') {
471
+ const id = firstPositional(args);
472
+ const rows = allParams(runtime, db, 'SELECT * FROM kernel_issues WHERE id = ?', [id]);
473
+ if (!rows[0]) {
474
+ return formatIssueCommandError({
475
+ command: 'issue.show',
476
+ code: 'FORGE_ISSUE_NOT_FOUND',
477
+ message: `Issue ${id ?? '<missing id>'} not found`,
478
+ exitCode: ISSUE_COMMAND_EXIT_CODES.notFound,
479
+ });
480
+ }
481
+ const { index, claimedById, dependenciesById, dependentsById } = loadBoardReadiness(runtime, db, context);
482
+ // KAP-3: `show` (and only `show`) attaches the issue's comments, ordered oldest
483
+ // first. Map to the contract's { id, body, actor, created_at } shape — never the
484
+ // raw row — so the projection stays stable.
485
+ const commentRows = allParams(
486
+ runtime, db,
487
+ 'SELECT * FROM kernel_comments WHERE issue_id = ? ORDER BY created_at ASC, id ASC',
488
+ [id],
489
+ );
490
+ const comments = commentRows.map(comment => ({
491
+ id: comment.id,
492
+ body: comment.body ?? null,
493
+ actor: comment.actor,
494
+ created_at: comment.created_at,
495
+ }));
496
+ // f61601ab: surface the REAL workflow phase from kernel_stage_runs (latest
497
+ // active, else latest completed) so consumers read the phase instead of
498
+ // guessing from status+claim. Null when no stage runs exist.
499
+ const currentStageRow = loadCurrentStageRunRow(runtime, db, id);
500
+ return okIssueResponse('issue.show', {
501
+ ...rowToIssueSummary(rows[0], index.readinessById[id], claimedById[id], dependenciesById[id], dependentsById[id]),
502
+ current_stage: currentStageRow ? currentStageRow.stage : null,
503
+ current_stage_status: currentStageRow ? currentStageRow.status : null,
504
+ comments,
505
+ });
506
+ }
507
+ if (operation === 'owns') {
508
+ // Lease-ownership verification (kernel d71a824b). A claim returning ok:true does
509
+ // not prove the caller won the lease: a duplicate replay also returns ok:true, so
510
+ // a worker must CONFIRM it holds the live lease before mutating a claimed issue.
511
+ // `owned` is true iff the resolving actor holds the SINGLE active claim AND that
512
+ // lease has not expired. read summaries derive `claimed_by` from state='active'
513
+ // only (never expiry — see loadActiveKernelClaimRow), so owns re-applies the
514
+ // expiry check here: an expired-but-not-yet-reclaimed lease is NOT ownership
515
+ // (planClaimAcquisition would supersede it). Actor resolution mirrors the mutation
516
+ // path's `context.actor || 'forge'` default so a bare CLI invocation matches its
517
+ // own claims; `now` falls back to the wall clock like the mutation route.
518
+ const id = firstPositional(args);
519
+ const rows = allParams(runtime, db, 'SELECT * FROM kernel_issues WHERE id = ?', [id]);
520
+ if (!rows[0]) {
521
+ return formatIssueCommandError({
522
+ command: 'issue.owns',
523
+ code: 'FORGE_ISSUE_NOT_FOUND',
524
+ message: `Issue ${id ?? '<missing id>'} not found`,
525
+ exitCode: ISSUE_COMMAND_EXIT_CODES.notFound,
526
+ });
527
+ }
528
+ const now = context.now || new Date().toISOString();
529
+ const actor = context.actor || 'forge';
530
+ const claim = loadActiveKernelClaimRow(runtime, db, id);
531
+ const claimedBy = claim ? (claim.actor ?? null) : null;
532
+ const expired = claim ? isLeaseExpired(claim, now) : false;
533
+ // Ownership is per-SESSION, not just per-actor (kernel d71a824b): two agents
534
+ // sharing one human actor but running as DIFFERENT sessions must not both read
535
+ // OWNED for a lease only one of them holds. When BOTH the caller and the live
536
+ // lease carry a session-id they must match; if either side is session-less (a
537
+ // no-env caller, or a pre-session claim) we fall back to actor-only ownership so
538
+ // historical behavior is preserved byte-for-byte. An empty/whitespace-only
539
+ // session-id counts as session-LESS on BOTH sides — the SAME trim-truthy test the
540
+ // claim-key write uses (buildClaimMutationEvent in lib/kernel/broker.js) — so ''
541
+ // can never count as "present" here and "absent" there.
542
+ const normalizeSession = value => (typeof value === 'string' && value.trim() !== '' ? value : null);
543
+ const contextSession = normalizeSession(context.sessionId);
544
+ const claimSession = claim ? normalizeSession(claim.session_id) : null;
545
+ const sessionMismatch = contextSession !== null
546
+ && claimSession !== null
547
+ && contextSession !== claimSession;
548
+ const owned = Boolean(claim) && !expired && claimedBy === actor && !sessionMismatch;
549
+ return okIssueResponse('issue.owns', {
550
+ id,
551
+ actor,
552
+ claimed_by: claimedBy,
553
+ owned,
554
+ expired,
555
+ expires_at: claim ? (claim.expires_at ?? null) : null,
556
+ });
557
+ }
558
+ if (operation === 'search') {
559
+ const term = `%${firstPositional(args) || ''}%`;
560
+ const rows = allParams(
561
+ runtime, db,
562
+ 'SELECT * FROM kernel_issues WHERE title LIKE ? OR body LIKE ? ORDER BY priority_rank ASC, id ASC',
563
+ [term, term],
564
+ );
565
+ const { index, claimedById, dependenciesById, dependentsById } = loadBoardReadiness(runtime, db, context);
566
+ const summaries = rows.map(row => rowToIssueSummary(row, index.readinessById[row.id], claimedById[row.id], dependenciesById[row.id], dependentsById[row.id]));
567
+ return okIssueResponse('issue.search', { issues: summaries, count: summaries.length });
568
+ }
569
+ if (operation === 'stats') {
570
+ const { index } = loadBoardReadiness(runtime, db, context);
571
+ const statusRows = allParams(runtime, db, 'SELECT status, COUNT(*) AS n FROM kernel_issues GROUP BY status');
572
+ const counts = {};
573
+ for (const row of statusRows) {
574
+ counts[row.status] = Number(row.n);
575
+ }
576
+ const activeClaims = Number(
577
+ safeAll(runtime, db, "SELECT COUNT(*) AS n FROM kernel_claims WHERE state = 'active'")[0]?.n || 0,
578
+ );
579
+ return okIssueResponse('issue.stats', {
580
+ counts,
581
+ ready_count: index.readyQueue.length,
582
+ blocked_count: index.blocked.length,
583
+ active_claims: activeClaims,
584
+ });
585
+ }
586
+ // KAP-7: derived read query — every issue whose readiness is blocked
587
+ // (index.readinessById[id].blocked === true; dependency/conflict/quarantine).
588
+ // Summaries are sorted like `list` for deterministic output.
589
+ if (operation === 'blocked') {
590
+ const { issues, index, claimedById, dependenciesById, dependentsById } = loadBoardReadiness(runtime, db, context);
591
+ const summaries = sortIssueSummaries(
592
+ issues
593
+ .filter(row => Boolean(index.readinessById[row.id]?.blocked))
594
+ .map(row => rowToIssueSummary(row, index.readinessById[row.id], claimedById[row.id], dependenciesById[row.id], dependentsById[row.id])),
595
+ );
596
+ return okIssueResponse('issue.blocked', { issues: summaries, count: summaries.length });
597
+ }
598
+ // KAP-7: derived read query — open/in_progress issues whose updated_at is
599
+ // STRICTLY older than (now - threshold_days). Default 14 days; --days <n> /
600
+ // --days=<n> overrides (NaN/<=0 → default). "now" is context.now when the broker
601
+ // supplies a deterministic clock, else the wall clock. review/done/cancelled are
602
+ // excluded — only actively-open work can go stale. The cutoff compares the stored
603
+ // ISO updated_at lexicographically, which is correct for UTC `Z` ISO-8601 strings.
604
+ if (operation === 'stale') {
605
+ const thresholdDays = parseStaleDays(args);
606
+ // Guard a malformed context.now: Date.parse → NaN would make new Date(NaN)
607
+ // throw "Invalid time value" on toISOString(); fall back to the wall clock.
608
+ const parsedNow = typeof context?.now === 'string' ? Date.parse(context.now) : NaN;
609
+ const nowMs = Number.isFinite(parsedNow) ? parsedNow : Date.now();
610
+ const cutoffIso = new Date(nowMs - thresholdDays * 24 * 60 * 60 * 1000).toISOString();
611
+ const { issues, index, claimedById, dependenciesById, dependentsById } = loadBoardReadiness(runtime, db, context);
612
+ // Only actively-open work can go stale. backlog (parked ideas) is deliberately
613
+ // excluded alongside review/done/cancelled — parked work is never stale.
614
+ const STALE_STATUSES = new Set(['open', 'in_progress']);
615
+ const summaries = sortIssueSummaries(
616
+ issues
617
+ .filter(row => STALE_STATUSES.has(row.status) && String(row.updated_at) < cutoffIso)
618
+ .map(row => rowToIssueSummary(row, index.readinessById[row.id], claimedById[row.id], dependenciesById[row.id], dependentsById[row.id])),
619
+ );
620
+ return okIssueResponse('issue.stale', { issues: summaries, count: summaries.length, threshold_days: thresholdDays });
621
+ }
622
+ // KAP-7: derived read query — issues touched by a DANGLING dependency edge. An
623
+ // orphan edge is a kernel_dependencies row whose issue_id OR blocks_issue_id
624
+ // names an id absent from kernel_issues (normally prevented by the FK, but
625
+ // detectable if FK enforcement was ever bypassed or data was migrated in). The
626
+ // EXISTING endpoint(s) of each dangling edge are the affected issues; both
627
+ // endpoints missing contributes nothing. Results are deduped and sorted like list.
628
+ if (operation === 'orphans') {
629
+ const { issues, index, claimedById, dependenciesById, dependentsById } = loadBoardReadiness(runtime, db, context);
630
+ const byId = new Map(issues.map(row => [row.id, row]));
631
+ const dependencies = safeAll(runtime, db, 'SELECT * FROM kernel_dependencies');
632
+ const orphanIds = new Set();
633
+ for (const edge of dependencies) {
634
+ const issueExists = byId.has(edge.issue_id);
635
+ const blocksExists = byId.has(edge.blocks_issue_id);
636
+ if (issueExists && blocksExists) continue; // clean edge
637
+ // One endpoint dangles: the existing endpoint(s) are the affected issues.
638
+ if (issueExists) orphanIds.add(edge.issue_id);
639
+ if (blocksExists) orphanIds.add(edge.blocks_issue_id);
640
+ }
641
+ const summaries = sortIssueSummaries(
642
+ [...orphanIds].map(id => rowToIssueSummary(byId.get(id), index.readinessById[id], claimedById[id], dependenciesById[id], dependentsById[id])),
643
+ );
644
+ return okIssueResponse('issue.orphans', { issues: summaries, count: summaries.length });
645
+ }
646
+ // KAP-12: read-only content lint — issues that FAIL required-content validation.
647
+ // An issue FAILS iff its type is task|bug AND acceptance_criteria is null or
648
+ // empty/whitespace-only. epic/decision are EXEMPT (no acceptance_criteria
649
+ // requirement). Each failing issue carries its standard summary PLUS a
650
+ // `validation: { rules_failed: ['missing_acceptance_criteria'] }`. The predicate
651
+ // references ONLY base-existing columns (type/acceptance_criteria); both arrive on
652
+ // the loadBoardReadiness rows via `SELECT *`. Results sort like list (rank asc).
653
+ if (operation === 'lint') {
654
+ const { issues, index, claimedById, dependenciesById, dependentsById } = loadBoardReadiness(runtime, db, context);
655
+ const LINTED_TYPES = new Set(['task', 'bug']);
656
+ const summaries = sortIssueSummaries(
657
+ issues
658
+ .filter(row => LINTED_TYPES.has(row.type) && String(row.acceptance_criteria ?? '').trim() === '')
659
+ .map(row => ({
660
+ ...rowToIssueSummary(row, index.readinessById[row.id], claimedById[row.id], dependenciesById[row.id], dependentsById[row.id]),
661
+ validation: { rules_failed: ['missing_acceptance_criteria'] },
662
+ })),
663
+ );
664
+ return okIssueResponse('issue.lint', { issues: summaries, count: summaries.length });
665
+ }
666
+ // Epic support: DIRECT children of <epic> (one level — `WHERE parent_id = ?`, the
667
+ // membership model is the first-class parent_id field, NOT dependency edges) plus a
668
+ // kernel-computed rollup. The target is accepted as ANY existing id (not gated on
669
+ // type === 'epic' — parent_id is generic); a missing id returns FORGE_ISSUE_NOT_FOUND
670
+ // (mirrors `show`, and epic.sh already has a not-found path). Children carry the full
671
+ // summary (assignee/status/blocked_by/dependents), so consumers build their
672
+ // per-developer + blocked views off this single query.
673
+ if (operation === 'children') {
674
+ const epicId = firstPositional(args);
675
+ const epicRows = allParams(runtime, db, 'SELECT * FROM kernel_issues WHERE id = ?', [epicId]);
676
+ if (!epicRows[0]) {
677
+ return formatIssueCommandError({
678
+ command: 'issue.children',
679
+ code: 'FORGE_ISSUE_NOT_FOUND',
680
+ message: `Issue ${epicId ?? '<missing id>'} not found`,
681
+ exitCode: ISSUE_COMMAND_EXIT_CODES.notFound,
682
+ });
683
+ }
684
+ const epicRow = epicRows[0];
685
+ const { issues, index, claimedById, dependenciesById, dependentsById } = loadBoardReadiness(runtime, db, context);
686
+ const children = sortIssueSummaries(
687
+ issues
688
+ .filter(row => row.parent_id === epicId)
689
+ .map(row => rowToIssueSummary(row, index.readinessById[row.id], claimedById[row.id], dependenciesById[row.id], dependentsById[row.id])),
690
+ );
691
+ return okIssueResponse('issue.children', {
692
+ epic: { id: epicRow.id, title: epicRow.title, type: epicRow.type, status: epicRow.status },
693
+ children,
694
+ rollup: buildRollup(children),
695
+ count: children.length,
696
+ });
697
+ }
698
+ // Issue 7dc229d4: active lease/claims read for the dashboard live layer. The
699
+ // kernel_claims lease row carries the full who/what/where set (actor,
700
+ // session_id, worktree_id, expires_at, issue_id) — the CLI previously exposed
701
+ // only `claimed_by` (the actor). A lease is LIVE iff state='active' AND it has
702
+ // NOT expired at the read `now` (isLeaseExpired; a null expires_at never
703
+ // expires). An expired-but-not-yet-reclaimed lease is deliberately excluded:
704
+ // planClaimAcquisition would supersede it, so it is not a live presence signal
705
+ // (this mirrors the expiry check the `owns` verdict re-applies). Reads that
706
+ // derive claimed_by (loadBoardReadiness/loadActiveKernelClaimRow) filter on
707
+ // state only; THIS read additionally honors the lease TTL. Sorted by
708
+ // claimed_at (then issue_id) for deterministic output. The kernel_claims
709
+ // schema has NO `agent` column — the lease's who-dimension is actor +
710
+ // session_id + worktree_id — so no agent field is surfaced.
711
+ if (operation === 'claims') {
712
+ const nowIso = context.now || new Date().toISOString();
713
+ const rows = safeAll(runtime, db, "SELECT * FROM kernel_claims WHERE state = 'active'");
714
+ const claims = rows
715
+ .filter(row => !isLeaseExpired(row, nowIso))
716
+ .map(row => ({
717
+ id: row.id,
718
+ issue_id: row.issue_id,
719
+ actor: row.actor ?? null,
720
+ session_id: row.session_id ?? null,
721
+ worktree_id: row.worktree_id ?? null,
722
+ claimed_at: row.claimed_at ?? null,
723
+ expires_at: row.expires_at ?? null,
724
+ }))
725
+ .sort((a, b) => String(a.claimed_at).localeCompare(String(b.claimed_at))
726
+ || String(a.issue_id).localeCompare(String(b.issue_id)));
727
+ return okIssueResponse('issue.claims', { claims, count: claims.length });
728
+ }
729
+ return null;
730
+ }
731
+
732
+ // --- Event-store primitives (Wave 2) -------------------------------------------
733
+ // Low-level reads/writes over kernel_events + kernel_issues that the broker's
734
+ // guarded-event path composes. Signatures mirror the inline fake drivers in
735
+ // broker-*.test.js exactly. CAS/idempotency/lease orchestration lives in the
736
+ // broker; these stay deliberately mechanical.
737
+
738
+ const KERNEL_EVENT_COLUMNS = Object.freeze([
739
+ 'id',
740
+ 'entity_type',
741
+ 'entity_id',
742
+ 'event_type',
743
+ 'idempotency_key',
744
+ 'expected_revision',
745
+ 'actor',
746
+ 'origin',
747
+ 'payload_json',
748
+ 'created_at',
749
+ ]);
750
+
751
+ // Persist one event. The id is supplied by the caller or minted here (event ids are
752
+ // TEXT, not autoincrement). The event's payload is stored as payload_json: a
753
+ // pre-serialized payload_json wins, else the payload object is JSON-stringified. The
754
+ // native UNIQUE(idempotency_key) error is intentionally NOT caught here.
755
+ function insertKernelEventRow(runtime, db, event) {
756
+ const id = event.id || randomUUID();
757
+ const payloadJson = event.payload_json ?? JSON.stringify(event.payload ?? {});
758
+ const row = {
759
+ id,
760
+ entity_type: event.entity_type,
761
+ entity_id: event.entity_id,
762
+ event_type: event.event_type,
763
+ idempotency_key: event.idempotency_key,
764
+ expected_revision: event.expected_revision,
765
+ actor: event.actor,
766
+ origin: event.origin,
767
+ payload_json: payloadJson,
768
+ created_at: event.created_at,
769
+ };
770
+ const placeholders = KERNEL_EVENT_COLUMNS.map(() => '?').join(', ');
771
+ runParams(
772
+ runtime,
773
+ db,
774
+ `INSERT INTO kernel_events (${KERNEL_EVENT_COLUMNS.join(', ')}) VALUES (${placeholders})`,
775
+ KERNEL_EVENT_COLUMNS.map(column => row[column]),
776
+ );
777
+ // Return what we wrote (minted id included) so callers can build the projection
778
+ // outbox entry — don't depend on .run()'s return shape across runtimes.
779
+ return { ...event, ...row };
780
+ }
781
+
782
+ // Read the entity-revision row for an issue (the CAS authority). Only issues store
783
+ // entity_revision; any other entity type has no stored revision, so return null and
784
+ // let the evaluator treat it as a brand-new (revision-0) entity.
785
+ function loadKernelEntityRow(runtime, db, entityType, entityId) {
786
+ if (entityType !== 'issue') return null;
787
+ const rows = allParams(runtime, db, 'SELECT * FROM kernel_issues WHERE id = ?', [entityId]);
788
+ return rows[0] || null;
789
+ }
790
+
791
+ // Read the full event stream for one entity, oldest first (matches
792
+ // idx_kernel_events_entity_created; there is no seq column, so created_at is the
793
+ // ordering key).
794
+ function listKernelEventRows(runtime, db, entityType, entityId) {
795
+ return allParams(
796
+ runtime,
797
+ db,
798
+ 'SELECT * FROM kernel_events WHERE entity_type = ? AND entity_id = ? ORDER BY created_at ASC',
799
+ [entityType, entityId],
800
+ );
801
+ }
802
+
803
+ // Look up the committed event for an idempotency key (the duplicate-replay probe).
804
+ // The broker calls this unconditionally inside a Promise.all even for keyless
805
+ // events, so guard a falsy key up front rather than binding undefined.
806
+ function loadKernelEventByIdempotencyKeyRow(runtime, db, idempotencyKey) {
807
+ if (!idempotencyKey) return null;
808
+ const rows = allParams(
809
+ runtime,
810
+ db,
811
+ 'SELECT * FROM kernel_events WHERE idempotency_key = ?',
812
+ [idempotencyKey],
813
+ );
814
+ return rows[0] || null;
815
+ }
816
+
817
+ // --- Guarded-event commit writes (Wave 3) -------------------------------------
818
+ // commitGuardedAccept (broker) opens BEGIN IMMEDIATE, inserts the event + outbox,
819
+ // and — via the typeof-guarded applyAcceptedIssueMutation hook — calls back into
820
+ // the driver to apply the accepted issue mutation to the authority tables. These
821
+ // writes run on the SAME connection inside the broker's transaction, so an event
822
+ // insert and its issue-row effect commit (or roll back) atomically.
823
+
824
+ // kernel_conflicts has no `reason`/`payload` columns; persist only the stored
825
+ // schema columns (the evaluator's reason is encoded inside payload_json).
826
+ const KERNEL_CONFLICT_COLUMNS = Object.freeze([
827
+ 'id',
828
+ 'entity_type',
829
+ 'entity_id',
830
+ 'expected_revision',
831
+ 'actual_revision',
832
+ 'status',
833
+ 'payload_json',
834
+ 'created_at',
835
+ ]);
836
+
837
+ function insertKernelConflictRow(runtime, db, conflict) {
838
+ const row = {
839
+ id: conflict.id || randomUUID(),
840
+ entity_type: conflict.entity_type,
841
+ entity_id: conflict.entity_id,
842
+ expected_revision: Number(conflict.expected_revision || 0),
843
+ actual_revision: Number(conflict.actual_revision || 0),
844
+ status: conflict.status || 'quarantined',
845
+ payload_json: conflict.payload_json ?? JSON.stringify(conflict.payload ?? {}),
846
+ created_at: conflict.created_at,
847
+ };
848
+ const placeholders = KERNEL_CONFLICT_COLUMNS.map(() => '?').join(', ');
849
+ runParams(
850
+ runtime,
851
+ db,
852
+ `INSERT INTO kernel_conflicts (${KERNEL_CONFLICT_COLUMNS.join(', ')}) VALUES (${placeholders})`,
853
+ KERNEL_CONFLICT_COLUMNS.map(column => row[column]),
854
+ );
855
+ return { ...conflict, id: row.id };
856
+ }
857
+
858
+ // kernel_outbox status/attempts default in the schema, but we write them explicitly
859
+ // so a freshly-enqueued entry is fully specified regardless of runtime defaults.
860
+ const KERNEL_OUTBOX_COLUMNS = Object.freeze([
861
+ 'id',
862
+ 'event_id',
863
+ 'target',
864
+ 'status',
865
+ 'attempts',
866
+ 'next_attempt_at',
867
+ 'created_at',
868
+ ]);
869
+
870
+ function enqueueKernelProjectionRow(runtime, db, entry) {
871
+ const row = {
872
+ id: entry.id || randomUUID(),
873
+ event_id: entry.event_id,
874
+ target: entry.target,
875
+ status: entry.status || 'pending',
876
+ attempts: Number(entry.attempts || 0),
877
+ next_attempt_at: entry.next_attempt_at ?? null,
878
+ created_at: entry.created_at,
879
+ };
880
+ const placeholders = KERNEL_OUTBOX_COLUMNS.map(() => '?').join(', ');
881
+ runParams(
882
+ runtime,
883
+ db,
884
+ `INSERT INTO kernel_outbox (${KERNEL_OUTBOX_COLUMNS.join(', ')}) VALUES (${placeholders})`,
885
+ KERNEL_OUTBOX_COLUMNS.map(column => row[column]),
886
+ );
887
+ return { ...entry, id: row.id };
888
+ }
889
+
890
+ // --- Projection-outbox read/update primitives (Wave 5) ------------------------
891
+ // The outbox consumer (projection-jsonl-writer.runJsonlProjectionConsumer) is the
892
+ // PRECISE spec for these shapes. They are additive read/update writes over
893
+ // kernel_outbox + kernel_dead_letters — they NEVER touch the append/CAS path
894
+ // (insertKernelEvent / enqueueKernelProjection) and never mutate Kernel authority
895
+ // tables. A projection failure is recorded out-of-band so the event log stays
896
+ // the single source of truth.
897
+
898
+ // List the drainable outbox rows for one target. `now` gates backoff: a row that
899
+ // failed and was scheduled forward (next_attempt_at in the future) MUST NOT be
900
+ // re-listed until its backoff elapses, else recordProjectionFailure's exponential
901
+ // backoff is dead and a poison row re-drains every tick. A NULL next_attempt_at
902
+ // (never-retried) is always eligible. Ordered by created_at so the snapshot the
903
+ // consumer takes reflects insertion order deterministically.
904
+ function listProjectionOutboxRows(runtime, db, filter = {}) {
905
+ const clauses = [];
906
+ const params = [];
907
+ if (filter.target !== undefined) {
908
+ clauses.push('target = ?');
909
+ params.push(filter.target);
910
+ }
911
+ if (filter.status !== undefined) {
912
+ clauses.push('status = ?');
913
+ params.push(filter.status);
914
+ }
915
+ if (filter.now !== undefined) {
916
+ clauses.push('(next_attempt_at IS NULL OR next_attempt_at <= ?)');
917
+ params.push(filter.now);
918
+ }
919
+ const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';
920
+ return allParams(
921
+ runtime,
922
+ db,
923
+ `SELECT * FROM kernel_outbox${where} ORDER BY created_at ASC, id ASC`,
924
+ params,
925
+ );
926
+ }
927
+
928
+ // The full projection read-model: every authority issue/comment/dependency row.
929
+ // The consumer renders ONE full snapshot per drain, so this returns the whole
930
+ // board (not a delta). Tables may be empty on a fresh DB; safeAll degrades a
931
+ // partially-migrated table to [].
932
+ function loadProjectionModelRows(runtime, db) {
933
+ return {
934
+ issues: safeAll(runtime, db, 'SELECT * FROM kernel_issues ORDER BY id ASC'),
935
+ comments: safeAll(runtime, db, 'SELECT * FROM kernel_comments ORDER BY issue_id ASC, created_at ASC, id ASC'),
936
+ dependencies: safeAll(runtime, db, 'SELECT * FROM kernel_dependencies ORDER BY issue_id ASC, blocks_issue_id ASC, id ASC'),
937
+ };
938
+ }
939
+
940
+ // Mark the drained outbox rows delivered. Builds one `?` placeholder per id (never
941
+ // interpolate ids) and guards an empty list so we don't emit `IN ()` (a syntax
942
+ // error on both runtimes). Returns {updated:n} — the count the consumer reports.
943
+ function markProjectionDeliveredRows(runtime, db, ids = [], _meta = {}) {
944
+ const list = Array.isArray(ids) ? ids.filter(id => id !== undefined && id !== null) : [];
945
+ if (list.length === 0) return { updated: 0 };
946
+ const placeholders = list.map(() => '?').join(', ');
947
+ runParams(
948
+ runtime,
949
+ db,
950
+ `UPDATE kernel_outbox SET status = 'delivered' WHERE id IN (${placeholders})`,
951
+ list,
952
+ );
953
+ return { updated: list.length };
954
+ }
955
+
956
+ // Record a transient projection failure: bump attempts + schedule the next retry
957
+ // while keeping the row pending. kernel_outbox has NO error column, so the
958
+ // record.error has nowhere to land here (it is surfaced only when the row is
959
+ // finally dead-lettered) — that is intentional, not a dropped field.
960
+ function recordProjectionFailureRows(runtime, db, record = {}) {
961
+ runParams(
962
+ runtime,
963
+ db,
964
+ "UPDATE kernel_outbox SET status = 'pending', attempts = ?, next_attempt_at = ? WHERE id = ?",
965
+ [Number(record.attempts || 0), record.next_attempt_at ?? null, record.id],
966
+ );
967
+ return { id: record.id, attempts: Number(record.attempts || 0) };
968
+ }
969
+
970
+ const KERNEL_DEAD_LETTER_COLUMNS = Object.freeze([
971
+ 'id',
972
+ 'outbox_id',
973
+ 'target',
974
+ 'status',
975
+ 'error',
976
+ 'payload_json',
977
+ 'created_at',
978
+ ]);
979
+
980
+ // Terminal projection failure: insert a dead_letters row AND transition the source
981
+ // outbox row out of 'pending' (→ 'dead') so it is never re-drained. Both writes run
982
+ // on the same connection; the consumer calls this from its catch path, not inside a
983
+ // guarded transaction, so the two writes are best-effort sequential (a projection
984
+ // failure must not block authority). Returns {id} (the new dead-letter id).
985
+ function deadLetterProjectionRows(runtime, db, record = {}) {
986
+ const id = record.id || randomUUID();
987
+ const row = {
988
+ id,
989
+ outbox_id: record.outbox_id ?? null,
990
+ target: record.target,
991
+ status: record.status || 'open',
992
+ error: record.error ?? '',
993
+ payload_json: record.payload_json ?? JSON.stringify(record.payload ?? {}),
994
+ created_at: record.created_at ?? record.now,
995
+ };
996
+ const placeholders = KERNEL_DEAD_LETTER_COLUMNS.map(() => '?').join(', ');
997
+ runParams(
998
+ runtime,
999
+ db,
1000
+ `INSERT INTO kernel_dead_letters (${KERNEL_DEAD_LETTER_COLUMNS.join(', ')}) VALUES (${placeholders})`,
1001
+ KERNEL_DEAD_LETTER_COLUMNS.map(column => row[column]),
1002
+ );
1003
+ if (record.outbox_id) {
1004
+ runParams(
1005
+ runtime,
1006
+ db,
1007
+ "UPDATE kernel_outbox SET status = 'dead' WHERE id = ?",
1008
+ [record.outbox_id],
1009
+ );
1010
+ }
1011
+ return { id };
1012
+ }
1013
+
1014
+ // All blocking edges, so the evaluator can detect a cycle the new dependency.add
1015
+ // edge would close. The broker only calls this for dependency.add events with a
1016
+ // complete scope; an empty/absent table degrades to []. The cycle check needs the
1017
+ // whole graph (not just the scoped edge), so `scope` is currently informational.
1018
+ function listKernelDependencyRows(runtime, db, _scope = {}) {
1019
+ return safeAll(runtime, db, 'SELECT * FROM kernel_dependencies');
1020
+ }
1021
+
1022
+ // Read the single live-lease candidate for an issue: the row in state='active'.
1023
+ // Filter on STATE ONLY, never on expiry — planClaimAcquisition needs the
1024
+ // expired-but-active row to fire its reclaim/supersede branch. Dropping it here
1025
+ // would null the active row and the next insert would collide on the partial
1026
+ // UNIQUE index (idx_kernel_claims_active_lease). The partial index guarantees at
1027
+ // most one such row, so the first match is authoritative.
1028
+ function loadActiveKernelClaimRow(runtime, db, issueId) {
1029
+ const rows = allParams(
1030
+ runtime,
1031
+ db,
1032
+ "SELECT * FROM kernel_claims WHERE issue_id = ? AND state = 'active' ORDER BY claimed_at ASC LIMIT 1",
1033
+ [issueId],
1034
+ );
1035
+ return rows[0] || null;
1036
+ }
1037
+
1038
+ // The 8 columns buildClaimRow (lease-enforcer) produces. The native
1039
+ // partial-UNIQUE(issue_id WHERE state='active') error is intentionally NOT caught
1040
+ // here — the broker's recoverGuardedFailure parses it to convert a cross-owner
1041
+ // lease collision into a claim_conflict quarantine.
1042
+ const KERNEL_CLAIM_COLUMNS = Object.freeze([
1043
+ 'id',
1044
+ 'issue_id',
1045
+ 'actor',
1046
+ 'state',
1047
+ 'session_id',
1048
+ 'worktree_id',
1049
+ 'claimed_at',
1050
+ 'expires_at',
1051
+ ]);
1052
+
1053
+ function insertKernelClaimRow(runtime, db, claim) {
1054
+ const row = {
1055
+ id: claim.id || randomUUID(),
1056
+ issue_id: claim.issue_id,
1057
+ actor: claim.actor,
1058
+ state: claim.state || 'active',
1059
+ session_id: claim.session_id ?? null,
1060
+ worktree_id: claim.worktree_id ?? null,
1061
+ claimed_at: claim.claimed_at,
1062
+ expires_at: claim.expires_at ?? null,
1063
+ };
1064
+ const placeholders = KERNEL_CLAIM_COLUMNS.map(() => '?').join(', ');
1065
+ runParams(
1066
+ runtime,
1067
+ db,
1068
+ `INSERT INTO kernel_claims (${KERNEL_CLAIM_COLUMNS.join(', ')}) VALUES (${placeholders})`,
1069
+ KERNEL_CLAIM_COLUMNS.map(column => row[column]),
1070
+ );
1071
+ return { ...claim, id: row.id };
1072
+ }
1073
+
1074
+ // Transition a claim row's state (e.g. active → reclaimable when superseding an
1075
+ // expired lease). Moving a row out of 'active' frees the partial-UNIQUE slot so a
1076
+ // fresh active lease can be inserted in the same transaction.
1077
+ function updateKernelClaimStateRow(runtime, db, claimId, state) {
1078
+ runParams(
1079
+ runtime,
1080
+ db,
1081
+ 'UPDATE kernel_claims SET state = ? WHERE id = ?',
1082
+ [state, claimId],
1083
+ );
1084
+ return { id: claimId, state };
1085
+ }
1086
+
1087
+ // --- Worktree-linkage primitives (P0 kernel linkage backbone). The kernel_worktrees
1088
+ // table is a plain authority registry (NOT event-sourced): `forge worktree create`
1089
+ // writes a row here so the kernel records issue → worktree → work-folder, and
1090
+ // orientation / `forge worktree list` read it back instead of guessing. The row is
1091
+ // keyed by absolute worktree `path`; re-registering the same path UPDATEs in place so
1092
+ // the write is idempotent (a worktree can be re-created / re-linked without duplicating).
1093
+ const KERNEL_WORKTREE_COLUMNS = Object.freeze([
1094
+ 'id',
1095
+ 'git_common_dir',
1096
+ 'path',
1097
+ 'branch',
1098
+ 'actor',
1099
+ 'issue_id',
1100
+ 'work_folder',
1101
+ 'registered_at',
1102
+ 'state',
1103
+ ]);
1104
+
1105
+ function loadWorktreeRowByPath(runtime, db, worktreePath) {
1106
+ if (!worktreePath) return null;
1107
+ const rows = safeAll(
1108
+ runtime,
1109
+ db,
1110
+ 'SELECT * FROM kernel_worktrees WHERE path = ? ORDER BY registered_at DESC LIMIT 1',
1111
+ [worktreePath],
1112
+ );
1113
+ return rows[0] || null;
1114
+ }
1115
+
1116
+ // The idempotent upsert key. `forge plan` registers MULTIPLE branches from ONE
1117
+ // checkout (same absolute path), so keying by path ALONE made a second plan-first
1118
+ // feature UPDATE-in-place over the first branch's row and dead-end its ship (R1).
1119
+ // Key by (path, branch) so each branch keeps its own row; fall back to path-only
1120
+ // when no branch is supplied (worktree flows use distinct paths, so behavior there
1121
+ // is unchanged).
1122
+ function loadWorktreeRowByPathAndBranch(runtime, db, worktreePath, branch) {
1123
+ if (!worktreePath) return null;
1124
+ if (!branch) return loadWorktreeRowByPath(runtime, db, worktreePath);
1125
+ const rows = safeAll(
1126
+ runtime,
1127
+ db,
1128
+ 'SELECT * FROM kernel_worktrees WHERE path = ? AND branch = ? ORDER BY registered_at DESC LIMIT 1',
1129
+ [worktreePath, branch],
1130
+ );
1131
+ return rows[0] || null;
1132
+ }
1133
+
1134
+ // A git branch is checked out in exactly ONE worktree, so a NEW active registration
1135
+ // for a branch supersedes any prior ACTIVE row carrying that same branch under a
1136
+ // different id (be18881c): a reused/deleted-and-recreated branch must not keep a
1137
+ // stale binding to the OLD issue. Marking those rows state='superseded' lets the
1138
+ // active-only branch resolver skip them regardless of their timestamp.
1139
+ function supersedePriorBranchRegistrations(runtime, db, branch, keepId) {
1140
+ if (!branch) return;
1141
+ runParams(
1142
+ runtime,
1143
+ db,
1144
+ "UPDATE kernel_worktrees SET state = 'superseded' WHERE branch = ? AND state = 'active' AND id != ?",
1145
+ [branch, keepId || ''],
1146
+ );
1147
+ }
1148
+
1149
+ function upsertWorktreeRow(runtime, db, input) {
1150
+ const existing = loadWorktreeRowByPathAndBranch(runtime, db, input.path, input.branch);
1151
+ const row = {
1152
+ id: input.id || existing?.id || randomUUID(),
1153
+ git_common_dir: input.git_common_dir,
1154
+ path: input.path,
1155
+ branch: input.branch,
1156
+ actor: input.actor ?? null,
1157
+ issue_id: input.issue_id ?? null,
1158
+ work_folder: input.work_folder ?? null,
1159
+ registered_at: input.registered_at || new Date().toISOString(),
1160
+ state: input.state || 'active',
1161
+ };
1162
+ if (existing) {
1163
+ runParams(
1164
+ runtime,
1165
+ db,
1166
+ 'UPDATE kernel_worktrees SET git_common_dir = ?, branch = ?, actor = ?, issue_id = ?, work_folder = ?, registered_at = ?, state = ? WHERE id = ?',
1167
+ [row.git_common_dir, row.branch, row.actor, row.issue_id, row.work_folder, row.registered_at, row.state, row.id],
1168
+ );
1169
+ } else {
1170
+ const placeholders = KERNEL_WORKTREE_COLUMNS.map(() => '?').join(', ');
1171
+ runParams(
1172
+ runtime,
1173
+ db,
1174
+ `INSERT INTO kernel_worktrees (${KERNEL_WORKTREE_COLUMNS.join(', ')}) VALUES (${placeholders})`,
1175
+ KERNEL_WORKTREE_COLUMNS.map(column => row[column]),
1176
+ );
1177
+ }
1178
+ if (row.state === 'active') {
1179
+ supersedePriorBranchRegistrations(runtime, db, row.branch, row.id);
1180
+ }
1181
+ return row;
1182
+ }
1183
+
1184
+ function listWorktreeRows(runtime, db, filter = {}) {
1185
+ if (filter && filter.state) {
1186
+ return safeAll(
1187
+ runtime,
1188
+ db,
1189
+ 'SELECT * FROM kernel_worktrees WHERE state = ? ORDER BY registered_at DESC',
1190
+ [filter.state],
1191
+ );
1192
+ }
1193
+ return safeAll(runtime, db, 'SELECT * FROM kernel_worktrees ORDER BY registered_at DESC');
1194
+ }
1195
+
1196
+ // --- Stage-run registry (f61601ab). kernel_stage_runs records the REAL workflow
1197
+ // phase per issue so the dashboard/`show` read the phase instead of guessing it
1198
+ // from status+claim (a claimed-open issue with a merged PR would otherwise still
1199
+ // show "dev"). Like kernel_worktrees this is a plain authority registry written
1200
+ // DIRECTLY (not event-sourced): a stage row is keyed by (issue_id, stage) and the
1201
+ // write is idempotent per that pair — re-starting a stage UPDATEs in place instead
1202
+ // of duplicating. `start` opens an active row (started_at, completed_at NULL);
1203
+ // `complete` stamps completed_at + status='done' on that same row.
1204
+ const KERNEL_STAGE_RUN_COLUMNS = Object.freeze([
1205
+ 'id',
1206
+ 'issue_id',
1207
+ 'stage',
1208
+ 'substage',
1209
+ 'status',
1210
+ 'started_at',
1211
+ 'completed_at',
1212
+ 'evidence_id',
1213
+ ]);
1214
+
1215
+ function loadStageRunRow(runtime, db, issueId, stage) {
1216
+ if (!issueId || !stage) return null;
1217
+ const rows = safeAll(
1218
+ runtime,
1219
+ db,
1220
+ 'SELECT * FROM kernel_stage_runs WHERE issue_id = ? AND stage = ? ORDER BY started_at DESC LIMIT 1',
1221
+ [issueId, stage],
1222
+ );
1223
+ return rows[0] || null;
1224
+ }
1225
+
1226
+ // Idempotent per (issue_id, stage). action 'start' opens/keeps an active row;
1227
+ // action 'complete' stamps completed_at + status='done' (creating the row first
1228
+ // if the stage was never explicitly started, so a bare `complete` still records
1229
+ // that the stage ran).
1230
+ function recordStageRunRow(runtime, db, input) {
1231
+ const stage = input.stage;
1232
+ if (!input.issue_id || !stage) {
1233
+ throw new Error('recordStageRun requires issue_id and stage');
1234
+ }
1235
+ const action = input.action || 'start';
1236
+ if (action !== 'start' && action !== 'complete') {
1237
+ throw new Error(`recordStageRun: unknown action "${action}" (expected start|complete)`);
1238
+ }
1239
+ const now = input.now || new Date().toISOString();
1240
+ const existing = loadStageRunRow(runtime, db, input.issue_id, stage);
1241
+
1242
+ if (action === 'start') {
1243
+ if (existing) {
1244
+ // Re-start is idempotent: keep the id + original started_at, ensure the row
1245
+ // is active again (supports a rework loop re-opening a completed stage).
1246
+ const row = {
1247
+ ...existing,
1248
+ substage: input.substage ?? existing.substage ?? null,
1249
+ status: 'active',
1250
+ completed_at: null,
1251
+ evidence_id: input.evidence_id ?? existing.evidence_id ?? null,
1252
+ };
1253
+ runParams(
1254
+ runtime,
1255
+ db,
1256
+ 'UPDATE kernel_stage_runs SET substage = ?, status = ?, completed_at = ?, evidence_id = ? WHERE id = ?',
1257
+ [row.substage, row.status, row.completed_at, row.evidence_id, row.id],
1258
+ );
1259
+ return row;
1260
+ }
1261
+ const row = {
1262
+ id: input.id || randomUUID(),
1263
+ issue_id: input.issue_id,
1264
+ stage,
1265
+ substage: input.substage ?? null,
1266
+ status: 'active',
1267
+ started_at: input.started_at || now,
1268
+ completed_at: null,
1269
+ evidence_id: input.evidence_id ?? null,
1270
+ };
1271
+ const placeholders = KERNEL_STAGE_RUN_COLUMNS.map(() => '?').join(', ');
1272
+ runParams(
1273
+ runtime,
1274
+ db,
1275
+ `INSERT INTO kernel_stage_runs (${KERNEL_STAGE_RUN_COLUMNS.join(', ')}) VALUES (${placeholders})`,
1276
+ KERNEL_STAGE_RUN_COLUMNS.map(column => row[column]),
1277
+ );
1278
+ return row;
1279
+ }
1280
+
1281
+ // action === 'complete'
1282
+ if (existing) {
1283
+ const row = {
1284
+ ...existing,
1285
+ substage: input.substage ?? existing.substage ?? null,
1286
+ status: 'done',
1287
+ completed_at: now,
1288
+ evidence_id: input.evidence_id ?? existing.evidence_id ?? null,
1289
+ };
1290
+ runParams(
1291
+ runtime,
1292
+ db,
1293
+ 'UPDATE kernel_stage_runs SET substage = ?, status = ?, completed_at = ?, evidence_id = ? WHERE id = ?',
1294
+ [row.substage, row.status, row.completed_at, row.evidence_id, row.id],
1295
+ );
1296
+ return row;
1297
+ }
1298
+ const row = {
1299
+ id: input.id || randomUUID(),
1300
+ issue_id: input.issue_id,
1301
+ stage,
1302
+ substage: input.substage ?? null,
1303
+ status: 'done',
1304
+ started_at: input.started_at || now,
1305
+ completed_at: now,
1306
+ evidence_id: input.evidence_id ?? null,
1307
+ };
1308
+ const placeholders = KERNEL_STAGE_RUN_COLUMNS.map(() => '?').join(', ');
1309
+ runParams(
1310
+ runtime,
1311
+ db,
1312
+ `INSERT INTO kernel_stage_runs (${KERNEL_STAGE_RUN_COLUMNS.join(', ')}) VALUES (${placeholders})`,
1313
+ KERNEL_STAGE_RUN_COLUMNS.map(column => row[column]),
1314
+ );
1315
+ return row;
1316
+ }
1317
+
1318
+ // Atomic stage transition: complete the `from` stage and start the `to` stage as
1319
+ // ONE all-or-nothing write. Auto-recording from a `stage: <from> -> <to>` comment
1320
+ // (5a5ba3a6) used to issue these as two separate recordStageRun calls at the caller;
1321
+ // if the second threw, the first had already persisted — leaving a half-transition
1322
+ // (from marked done, to never started) so `current_stage` was wrong. Wrapping both
1323
+ // writes in a single BEGIN IMMEDIATE transaction makes a mid-transition failure roll
1324
+ // back cleanly. Best-effort/non-blocking is the CALLER's contract; this method still
1325
+ // throws on failure so the caller can observe it (and roll back has happened).
1326
+ function recordStageTransitionRow(runtime, db, input) {
1327
+ const issueId = input && input.issue_id;
1328
+ const from = input && input.from;
1329
+ const to = input && input.to;
1330
+ if (!issueId || !from || !to) {
1331
+ throw new Error('recordStageTransition requires issue_id, from, and to');
1332
+ }
1333
+ const now = input.now || new Date().toISOString();
1334
+ execSql(runtime, db, 'BEGIN IMMEDIATE;');
1335
+ try {
1336
+ const completed = recordStageRunRow(runtime, db, {
1337
+ issue_id: issueId, stage: from, action: 'complete', now,
1338
+ });
1339
+ const started = recordStageRunRow(runtime, db, {
1340
+ issue_id: issueId, stage: to, action: 'start', now,
1341
+ });
1342
+ execSql(runtime, db, 'COMMIT;');
1343
+ return { from: completed, to: started };
1344
+ } catch (error) {
1345
+ try {
1346
+ execSql(runtime, db, 'ROLLBACK;');
1347
+ } catch {
1348
+ // A rollback failure must not mask the original transition error.
1349
+ }
1350
+ throw error;
1351
+ }
1352
+ }
1353
+
1354
+ function listStageRunRows(runtime, db, issueId) {
1355
+ if (!issueId) return [];
1356
+ // Deterministic order: started_at first, then rowid (the implicit INSERT sequence)
1357
+ // as a STABLE tie-break. Fast callers can mint two runs in the same millisecond, so
1358
+ // ordering by started_at alone leaves ties undefined (differs local vs CI vs OS).
1359
+ // rowid gives intuitive insertion order for a history list; the random-UUID `id`
1360
+ // would not reflect insertion order, so it is unsuitable as the tie-break.
1361
+ return safeAll(
1362
+ runtime,
1363
+ db,
1364
+ 'SELECT * FROM kernel_stage_runs WHERE issue_id = ? ORDER BY started_at ASC, rowid ASC',
1365
+ [issueId],
1366
+ );
1367
+ }
1368
+
1369
+ // Current stage = latest ACTIVE run (completed_at IS NULL) by started_at; when none
1370
+ // is active, the latest COMPLETED run by completed_at. Returns null when the issue
1371
+ // has no stage runs (caller falls back to the status+claim heuristic).
1372
+ function loadCurrentStageRunRow(runtime, db, issueId) {
1373
+ const rows = listStageRunRows(runtime, db, issueId);
1374
+ if (rows.length === 0) return null;
1375
+ const active = rows.filter(row => !row.completed_at);
1376
+ if (active.length > 0) {
1377
+ return active.reduce((latest, row) => (String(row.started_at) >= String(latest.started_at) ? row : latest));
1378
+ }
1379
+ return rows.reduce((latest, row) => (
1380
+ String(row.completed_at || row.started_at) >= String(latest.completed_at || latest.started_at) ? row : latest
1381
+ ));
1382
+ }
1383
+
1384
+ // Columns the issue upsert may set from an accepted event payload. id/title are
1385
+ // required for a create; the rest are optional and only overwritten when present.
1386
+ const ISSUE_MUTABLE_COLUMNS = Object.freeze([
1387
+ 'title',
1388
+ 'body',
1389
+ 'type',
1390
+ 'status',
1391
+ 'priority',
1392
+ 'priority_rank',
1393
+ 'parent_id',
1394
+ 'sprint_id',
1395
+ 'release_id',
1396
+ 'stage_state',
1397
+ 'labels',
1398
+ 'acceptance_criteria',
1399
+ 'estimate',
1400
+ // KAP-10 (design/notes) + KAP-11 (assignee): persisted on create AND update via
1401
+ // the same assignment loop. assignee is the persistent owner, distinct from the
1402
+ // transient kernel_claims lease.
1403
+ 'design',
1404
+ 'notes',
1405
+ 'assignee',
1406
+ // Author, close timestamp + raw close reason, and a verbatim metadata JSON blob.
1407
+ // The importer sets these explicitly on an issue event payload; a native CLI close
1408
+ // also auto-fills closed_at/close_reason from the close event (9197b0c8) — explicit
1409
+ // payload values always win, preserving import fidelity.
1410
+ 'created_by',
1411
+ 'closed_at',
1412
+ 'close_reason',
1413
+ 'metadata',
1414
+ ]);
1415
+
1416
+ // close drives the issue to a terminal status; an explicit payload.status (rework
1417
+ // transitions) still wins so the broker can model any accepted lifecycle move.
1418
+ function resolveMutationStatus(eventType, payload) {
1419
+ if (typeof payload.status === 'string' && payload.status) return payload.status;
1420
+ if (eventType === 'issue.close') return 'done';
1421
+ return null;
1422
+ }
1423
+
1424
+ // KAP-8: after a close COMMITS the issue to a terminal status, compute the issues
1425
+ // that become newly READY because this issue is now done. This is a LOCALIZED
1426
+ // post-write read on the SAME connection/transaction — the issue row is already
1427
+ // `done`, so loadBoardReadiness sees the post-close state. We restrict the result
1428
+ // to DIRECT dependents of the closed issue (kernel_dependencies rows where
1429
+ // blocks_issue_id === closedId) whose readiness now flips to ready (the closed
1430
+ // blocker is terminal and dropped, and they carry no OTHER live blocker). Sorted
1431
+ // for a deterministic response.
1432
+ function computeNewlyUnblocked(runtime, db, closedIssueId, context = {}) {
1433
+ const dependents = safeAll(
1434
+ runtime,
1435
+ db,
1436
+ 'SELECT DISTINCT issue_id FROM kernel_dependencies WHERE blocks_issue_id = ?',
1437
+ [closedIssueId],
1438
+ ).map(row => row.issue_id).filter(Boolean);
1439
+ if (dependents.length === 0) return [];
1440
+ const { index } = loadBoardReadiness(runtime, db, context);
1441
+ return dependents
1442
+ .filter(id => Boolean(index.readinessById[id]?.ready))
1443
+ .sort((a, b) => String(a).localeCompare(String(b)));
1444
+ }
1445
+
1446
+ // Upsert the issue row for an accepted issue event and bump entity_revision. The
1447
+ // evaluator already enforced CAS (expected_revision === stored), so the new
1448
+ // revision is monotonic: stored + 1 for an update, 0 for a fresh create.
1449
+ function applyAcceptedIssueEvent(runtime, db, event, context = {}) {
1450
+ const payload = event.payload || (event.payload_json ? JSON.parse(event.payload_json) : {});
1451
+ const issueId = event.entity_id;
1452
+ const now = event.created_at;
1453
+ const existing = loadKernelEntityRow(runtime, db, 'issue', issueId);
1454
+ const status = resolveMutationStatus(event.event_type, payload);
1455
+
1456
+ // 9197b0c8: a native close must persist its OWN close metadata. The importer
1457
+ // supplies closed_at/close_reason explicitly, but a CLI `close --reason` only
1458
+ // carries the event-payload `reason` — both COLUMNS stayed NULL and every real
1459
+ // close failed gate.issue_verify's read-back. Stamp the columns from the close
1460
+ // event (explicit payload values, e.g. import fidelity, still win).
1461
+ if (event.event_type === 'issue.close') {
1462
+ if (payload.closed_at === undefined || payload.closed_at === null) {
1463
+ payload.closed_at = now;
1464
+ }
1465
+ if (
1466
+ (payload.close_reason === undefined || payload.close_reason === null)
1467
+ && typeof payload.reason === 'string' && payload.reason
1468
+ ) {
1469
+ payload.close_reason = payload.reason;
1470
+ }
1471
+ }
1472
+
1473
+ if (!existing) {
1474
+ // Fresh create: seed required NOT NULL columns, then overwrite with any
1475
+ // supplied payload values via the shared column map below. priority_rank is
1476
+ // DERIVED from the (possibly defaulted) priority LABEL so a no-`--priority`
1477
+ // create still sorts by its P2 default — seeding rank 0 would otherwise rank the
1478
+ // common default-priority issue ABOVE an explicit P1 in `list` (priority order
1479
+ // inverted). The CLI/broker already supplies priority_rank when --priority is
1480
+ // given, so this fallback only fires for the defaulted/raw-event path.
1481
+ const priorityLabel = payload.priority ?? 'P2';
1482
+ runParams(
1483
+ runtime,
1484
+ db,
1485
+ `INSERT INTO kernel_issues (id, title, type, status, priority, priority_rank, created_at, updated_at, entity_revision)
1486
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`,
1487
+ [
1488
+ issueId,
1489
+ payload.title ?? issueId,
1490
+ payload.type ?? 'task',
1491
+ status ?? payload.status ?? 'open',
1492
+ priorityLabel,
1493
+ Number(payload.priority_rank ?? rankForPriorityLabel(priorityLabel)),
1494
+ now,
1495
+ now,
1496
+ ],
1497
+ );
1498
+ }
1499
+
1500
+ const assignments = [];
1501
+ const values = [];
1502
+ for (const column of ISSUE_MUTABLE_COLUMNS) {
1503
+ const value = column === 'status' ? status : payload[column];
1504
+ if (value === undefined || value === null) continue;
1505
+ assignments.push(`${column} = ?`);
1506
+ // KAP-4: labels arrive as a string[] (broker parseLabelFlag). SQLite cannot
1507
+ // bind an array param, so persist as JSON-array TEXT — the canonical form
1508
+ // parseLabels reads back on the read side. Every other column binds as-is.
1509
+ values.push(column === 'labels' ? JSON.stringify(value) : value);
1510
+ }
1511
+ assignments.push('updated_at = ?');
1512
+ values.push(now);
1513
+ // Monotonic CAS bump: increment the stored revision on every accepted write
1514
+ // (a create stays at 0 because the INSERT seeded 0 and this UPDATE runs once).
1515
+ const expectedRevision = Number(existing ? existing.entity_revision || 0 : 0);
1516
+ const nextRevision = existing ? expectedRevision + 1 : 0;
1517
+ assignments.push('entity_revision = ?');
1518
+ values.push(nextRevision);
1519
+ if (existing) {
1520
+ // Optimistic CAS at the row write for revision-bumping mutations (update/close).
1521
+ // The evaluator pre-reads the entity OUTSIDE this transaction, so two writers
1522
+ // that both pre-read rev=N both pass the evaluator; BEGIN IMMEDIATE then
1523
+ // serializes them and the second would otherwise apply on top of N+1 — a silent
1524
+ // lost update. Gate the WHERE on the event's expected_revision: if the row's
1525
+ // actual revision has moved (0 rows changed), throw a tagged conflict the broker
1526
+ // converts into a stale_revision quarantine. A create INSERTs a fresh row (the PK
1527
+ // guards it) so it takes the un-gated path below.
1528
+ const result = runParams(
1529
+ runtime,
1530
+ db,
1531
+ `UPDATE kernel_issues SET ${assignments.join(', ')} WHERE id = ? AND entity_revision = ?`,
1532
+ [...values, issueId, Number(event.expected_revision || 0)],
1533
+ );
1534
+ // Both runtimes' run() return a changed-row count (bun:sqlite .changes;
1535
+ // node:sqlite StatementSync.run() → { changes, lastInsertRowid }). 0 changes
1536
+ // means the CAS predicate (entity_revision = expected) matched no row.
1537
+ if (Number(result?.changes || 0) === 0) {
1538
+ const error = new Error('kernel issue revision conflict');
1539
+ // Driver-supplied TYPED conflict signal (issues 89bf8930 / d4ce47bb): the
1540
+ // broker branches on this code, never on error text. kernelRevisionConflict
1541
+ // is retained as the structural marker classifyConflictSignal also honors.
1542
+ error.conflictSignal = CONFLICT_SIGNAL.CAS_STALE;
1543
+ error.kernelRevisionConflict = true;
1544
+ error.entityId = issueId;
1545
+ error.expectedRevision = Number(event.expected_revision || 0);
1546
+ error.actualRevision = expectedRevision;
1547
+ throw error;
1548
+ }
1549
+ return finalizeIssueMutation(runtime, db, event, issueId, nextRevision, context);
1550
+ }
1551
+ runParams(
1552
+ runtime,
1553
+ db,
1554
+ `UPDATE kernel_issues SET ${assignments.join(', ')} WHERE id = ?`,
1555
+ [...values, issueId],
1556
+ );
1557
+ return finalizeIssueMutation(runtime, db, event, issueId, nextRevision, context);
1558
+ }
1559
+
1560
+ // Build the issue-mutation summary, attaching KAP-8 newly_unblocked for a close.
1561
+ // The issue row is already at its terminal status here (the UPDATE above committed
1562
+ // on this connection), so computeNewlyUnblocked sees the post-close readiness.
1563
+ function finalizeIssueMutation(runtime, db, event, issueId, revision, context) {
1564
+ const summary = { id: issueId, revision };
1565
+ if (event.event_type === 'issue.close') {
1566
+ summary.newly_unblocked = computeNewlyUnblocked(runtime, db, issueId, context);
1567
+ }
1568
+ return summary;
1569
+ }
1570
+
1571
+ // Append a comment row for an accepted issue.comment event.
1572
+ function applyAcceptedCommentEvent(runtime, db, event) {
1573
+ const payload = event.payload || (event.payload_json ? JSON.parse(event.payload_json) : {});
1574
+ const commentId = payload.comment_id || randomUUID();
1575
+ runParams(
1576
+ runtime,
1577
+ db,
1578
+ `INSERT INTO kernel_comments (id, issue_id, body, actor, visibility, created_at)
1579
+ VALUES (?, ?, ?, ?, ?, ?)`,
1580
+ [
1581
+ commentId,
1582
+ payload.issue_id ?? event.entity_id,
1583
+ payload.body ?? '',
1584
+ event.actor ?? payload.actor ?? 'forge',
1585
+ payload.visibility ?? 'local',
1586
+ event.created_at,
1587
+ ],
1588
+ );
1589
+ // A comment never bumps the issue revision; report the host issue's current one.
1590
+ const issue = loadKernelEntityRow(runtime, db, 'issue', payload.issue_id ?? event.entity_id);
1591
+ return { id: payload.issue_id ?? event.entity_id, revision: Number(issue?.entity_revision || 0), comment_id: commentId };
1592
+ }
1593
+
1594
+ // Insert the dependency edge for an accepted dependency.add event. The event's
1595
+ // entity_id IS the dependency row id (the broker scopes the event on the
1596
+ // 'dependency' entity stream), so the row is uniquely keyed without minting a new
1597
+ // id. This is the ONLY place dependency rows are written — the cycle guard already
1598
+ // fired in the evaluator before this accepted event reached the commit.
1599
+ function applyAcceptedDependencyAddEvent(runtime, db, event) {
1600
+ const payload = event.payload || (event.payload_json ? JSON.parse(event.payload_json) : {});
1601
+ const dependencyId = event.entity_id;
1602
+ runParams(
1603
+ runtime,
1604
+ db,
1605
+ `INSERT INTO kernel_dependencies (id, issue_id, blocks_issue_id, dependency_type, created_at)
1606
+ VALUES (?, ?, ?, ?, ?)`,
1607
+ [
1608
+ dependencyId,
1609
+ payload.issue_id,
1610
+ payload.blocks_issue_id,
1611
+ payload.dependency_type || 'blocks',
1612
+ event.created_at,
1613
+ ],
1614
+ );
1615
+ return { id: dependencyId, revision: 0, dependency_id: dependencyId };
1616
+ }
1617
+
1618
+ // Delete the dependency edge for an accepted dependency.remove event, keyed by the
1619
+ // (issue_id, blocks_issue_id) pair the payload names — the dependent's id is not
1620
+ // the dependency row id, so delete by the edge endpoints, not entity_id.
1621
+ function applyAcceptedDependencyRemoveEvent(runtime, db, event) {
1622
+ const payload = event.payload || (event.payload_json ? JSON.parse(event.payload_json) : {});
1623
+ runParams(
1624
+ runtime,
1625
+ db,
1626
+ 'DELETE FROM kernel_dependencies WHERE issue_id = ? AND blocks_issue_id = ?',
1627
+ [payload.issue_id, payload.blocks_issue_id],
1628
+ );
1629
+ return { id: event.entity_id, revision: 0, dependency_id: event.entity_id };
1630
+ }
1631
+
1632
+ // Clear the active lease for an accepted claim.release event. Conservatively
1633
+ // releases the issue's active lease (the required ownership model is same-actor
1634
+ // "release clears it"; cross-owner authorization is deliberately out of scope).
1635
+ // claim.create is NOT handled here — its lease row is inserted by the broker's
1636
+ // insertKernelClaim inside commitGuardedAccept; re-inserting it here would be a
1637
+ // double-INSERT that trips the partial-UNIQUE index.
1638
+ function applyAcceptedClaimReleaseEvent(runtime, db, event) {
1639
+ const payload = event.payload || (event.payload_json ? JSON.parse(event.payload_json) : {});
1640
+ runParams(
1641
+ runtime,
1642
+ db,
1643
+ "UPDATE kernel_claims SET state = 'released' WHERE issue_id = ? AND state = 'active'",
1644
+ [payload.issue_id],
1645
+ );
1646
+ return { id: event.entity_id, revision: 0, claim_id: event.entity_id };
1647
+ }
1648
+
1649
+ // Apply an accepted event's authority-table effect. Returns the mutation summary
1650
+ // ({id, revision, comment_id?/dependency_id?/claim_id?}) the broker threads back
1651
+ // into the issue-command response, or null for events with no synchronous
1652
+ // side effect here (claim.create's lease is written by the broker's
1653
+ // insertKernelClaim inside the transaction). The entity_type guard is critical:
1654
+ // dependency/claim events must NEVER fall into the issue-upsert branch, which
1655
+ // would corrupt kernel_issues with a bogus row keyed by the dep/claim id.
1656
+ function applyAcceptedMutation(runtime, db, event, context = {}) {
1657
+ if (event.entity_type === 'dependency') {
1658
+ if (event.event_type === 'dependency.remove') {
1659
+ return applyAcceptedDependencyRemoveEvent(runtime, db, event);
1660
+ }
1661
+ return applyAcceptedDependencyAddEvent(runtime, db, event);
1662
+ }
1663
+ if (event.entity_type === 'claim') {
1664
+ if (event.event_type === 'claim.release') {
1665
+ return applyAcceptedClaimReleaseEvent(runtime, db, event);
1666
+ }
1667
+ // claim.create: the lease row is written by the broker's insertKernelClaim
1668
+ // inside commitGuardedAccept; no authority-table effect to apply here.
1669
+ return null;
1670
+ }
1671
+ if (event.entity_type === 'issue' && event.event_type === 'issue.comment') {
1672
+ return applyAcceptedCommentEvent(runtime, db, event);
1673
+ }
1674
+ if (event.entity_type === 'issue') {
1675
+ // context carries now/actor so KAP-8's post-close readiness recompute uses the
1676
+ // same clock/actor the rest of the guarded path did.
1677
+ return applyAcceptedIssueEvent(runtime, db, event, context);
1678
+ }
1679
+ return null;
1680
+ }
1681
+
1682
+ // --- Faithful import write path (beads → kernel) ------------------------------
1683
+ // Direct authority-table writes that PRESERVE an imported issue's ORIGINAL
1684
+ // created_at/updated_at, terminal status (done/cancelled), priority(+rank), labels,
1685
+ // acceptance/content and beads-fidelity columns — BYPASSING applyAcceptedIssueEvent's
1686
+ // now-stamping create/CAS path. This is the ONLY path that writes an issue's original
1687
+ // timestamps; the normal create/update flow is unchanged. Consumed exclusively by the
1688
+ // broker's importIssues entry point (the `forge migrate` write path). Each call is
1689
+ // idempotent (ON CONFLICT(id) DO NOTHING — an existing id is skipped, never duplicated
1690
+ // or thrown) and transactional (one BEGIN IMMEDIATE per call; all-or-nothing).
1691
+
1692
+ // The full kernel_issues column set the importer writes. Every NOT NULL column is
1693
+ // seeded with a default in buildImportIssueRow, so a sparse record (only id/title)
1694
+ // still inserts a valid row, while a full-fidelity record round-trips verbatim.
1695
+ const IMPORT_ISSUE_COLUMNS = Object.freeze([
1696
+ 'id', 'title', 'body', 'type', 'status', 'priority', 'priority_rank',
1697
+ 'created_at', 'updated_at', 'entity_revision',
1698
+ 'parent_id', 'sprint_id', 'release_id', 'stage_state', 'labels',
1699
+ 'acceptance_criteria', 'estimate', 'design', 'notes', 'assignee',
1700
+ 'created_by', 'closed_at', 'close_reason', 'metadata',
1701
+ ]);
1702
+
1703
+ const IMPORT_COMMENT_COLUMNS = Object.freeze(['id', 'issue_id', 'body', 'actor', 'visibility', 'created_at']);
1704
+ const IMPORT_DEPENDENCY_COLUMNS = Object.freeze(['id', 'issue_id', 'blocks_issue_id', 'dependency_type', 'created_at']);
1705
+ // The kernel_events column set the importer writes for legacy beads activity events +
1706
+ // interactions (records.activityEvents). Every NOT NULL column is seeded with a default in
1707
+ // buildImportEventRow so a sparse record still inserts a valid row.
1708
+ const IMPORT_EVENT_COLUMNS = Object.freeze([
1709
+ 'id', 'entity_type', 'entity_id', 'event_type', 'idempotency_key',
1710
+ 'expected_revision', 'actor', 'origin', 'payload_json', 'created_at',
1711
+ ]);
1712
+
1713
+ // The mapper carries an issue's terminal close metadata on a SEPARATE
1714
+ // `beads.issue.closed` event (kernel.events), not on the issue record — mirror the
1715
+ // adapter's getCloseMetadataByIssue so closed_at/close_reason land on the issue row.
1716
+ function buildImportCloseMetadata(events = []) {
1717
+ const byIssue = new Map();
1718
+ for (const event of Array.isArray(events) ? events : []) {
1719
+ if (!event || event.event_type !== 'beads.issue.closed') continue;
1720
+ let payload;
1721
+ try {
1722
+ payload = event.payload_json ? JSON.parse(event.payload_json) : (event.payload || {});
1723
+ } catch {
1724
+ payload = {};
1725
+ }
1726
+ byIssue.set(event.entity_id, {
1727
+ closed_at: payload.closed_at ?? event.created_at ?? null,
1728
+ close_reason: payload.close_reason ?? null,
1729
+ });
1730
+ }
1731
+ return byIssue;
1732
+ }
1733
+
1734
+ // Build the full insert row for one imported issue record. NOT NULL columns default so a
1735
+ // sparse record stays valid; created_at/updated_at fall back to the original created_at
1736
+ // (then `now`) rather than always-now, so the imported issue's history is preserved.
1737
+ // labels are stored verbatim (the mapper already JSON-encodes them) — an accidental array
1738
+ // is re-encoded defensively. Close metadata prefers a record-level column, else the
1739
+ // close-event sidecar.
1740
+ function buildImportIssueRow(record, closeMeta, now) {
1741
+ const priority = record.priority ?? 'P2';
1742
+ const createdAt = record.created_at ?? now;
1743
+ const close = closeMeta || {};
1744
+ const labels = Array.isArray(record.labels) ? JSON.stringify(record.labels) : (record.labels ?? null);
1745
+ return {
1746
+ id: record.id,
1747
+ title: record.title ?? record.id,
1748
+ body: record.body ?? null,
1749
+ type: record.type ?? 'task',
1750
+ status: record.status ?? 'open',
1751
+ priority,
1752
+ priority_rank: Number(record.priority_rank ?? rankForPriorityLabel(priority)),
1753
+ created_at: createdAt,
1754
+ updated_at: record.updated_at ?? createdAt,
1755
+ entity_revision: Number(record.entity_revision ?? 0),
1756
+ parent_id: record.parent_id ?? null,
1757
+ sprint_id: record.sprint_id ?? null,
1758
+ release_id: record.release_id ?? null,
1759
+ stage_state: record.stage_state ?? null,
1760
+ labels,
1761
+ acceptance_criteria: record.acceptance_criteria ?? null,
1762
+ estimate: record.estimate ?? null,
1763
+ design: record.design ?? null,
1764
+ notes: record.notes ?? null,
1765
+ assignee: record.assignee ?? null,
1766
+ created_by: record.created_by ?? null,
1767
+ closed_at: record.closed_at ?? close.closed_at ?? null,
1768
+ close_reason: record.close_reason ?? close.close_reason ?? null,
1769
+ metadata: record.metadata ?? null,
1770
+ };
1771
+ }
1772
+
1773
+ // Build the full insert row for one imported activity event (records.activityEvents: legacy
1774
+ // beads events.jsonl + interactions.jsonl mapped to kernel_events). NOT NULL columns default so
1775
+ // a sparse record stays valid; the mapper already supplies a deterministic id/idempotency_key so
1776
+ // the insert is idempotent under ON CONFLICT(id).
1777
+ function buildImportEventRow(record, now) {
1778
+ return {
1779
+ id: record.id,
1780
+ entity_type: record.entity_type ?? 'issue',
1781
+ entity_id: record.entity_id ?? '',
1782
+ event_type: record.event_type ?? 'beads.event',
1783
+ idempotency_key: record.idempotency_key ?? record.id,
1784
+ expected_revision: Number(record.expected_revision ?? 0),
1785
+ actor: record.actor ?? 'beads',
1786
+ origin: record.origin ?? 'beads_import',
1787
+ payload_json: record.payload_json ?? JSON.stringify(record.payload ?? {}),
1788
+ created_at: record.created_at ?? now,
1789
+ };
1790
+ }
1791
+
1792
+ // Insert a kernel records bundle ({ issues, comments, dependencies, events, activityEvents })
1793
+ // into the authority tables inside ONE transaction. Order is issues → comments → dependencies →
1794
+ // activity events so every child FK resolves; children whose endpoint id is absent (a dangling
1795
+ // edge) are filtered out rather than aborting the whole batch on the live FK. activityEvents
1796
+ // (kernel_events has no entity FK) always insert. Returns per-table {inserted, skipped} counts
1797
+ // (skipped = an id that already existed or a filtered child).
1798
+ function importIssueRecords(runtime, db, records = {}, options = {}) {
1799
+ const now = options.now || new Date().toISOString();
1800
+ const issues = Array.isArray(records.issues) ? records.issues : [];
1801
+ const comments = Array.isArray(records.comments) ? records.comments : [];
1802
+ const dependencies = Array.isArray(records.dependencies) ? records.dependencies : [];
1803
+ const activityEvents = Array.isArray(records.activityEvents) ? records.activityEvents : [];
1804
+ const closeByIssue = buildImportCloseMetadata(records.events);
1805
+ const summary = {
1806
+ issues: { inserted: 0, skipped: 0 },
1807
+ comments: { inserted: 0, skipped: 0 },
1808
+ dependencies: { inserted: 0, skipped: 0 },
1809
+ events: { inserted: 0, skipped: 0 },
1810
+ };
1811
+ const wasInserted = result => Number(result?.changes || 0) > 0;
1812
+
1813
+ execSql(runtime, db, 'BEGIN IMMEDIATE;');
1814
+ try {
1815
+ const issueSql = `INSERT INTO kernel_issues (${IMPORT_ISSUE_COLUMNS.join(', ')})`
1816
+ + ` VALUES (${IMPORT_ISSUE_COLUMNS.map(() => '?').join(', ')}) ON CONFLICT(id) DO NOTHING`;
1817
+ for (const record of issues) {
1818
+ if (!record || record.id == null) { summary.issues.skipped += 1; continue; }
1819
+ const row = buildImportIssueRow(record, closeByIssue.get(record.id), now);
1820
+ const result = runParams(runtime, db, issueSql, IMPORT_ISSUE_COLUMNS.map(column => row[column]));
1821
+ summary.issues[wasInserted(result) ? 'inserted' : 'skipped'] += 1;
1822
+ }
1823
+
1824
+ // FK-safe child filtering: an id present after the issue inserts (imported OR
1825
+ // pre-existing) is a valid endpoint; anything else would trip the live FK.
1826
+ const existingIds = new Set(allParams(runtime, db, 'SELECT id FROM kernel_issues').map(issue => issue.id));
1827
+
1828
+ const commentSql = `INSERT INTO kernel_comments (${IMPORT_COMMENT_COLUMNS.join(', ')})`
1829
+ + ` VALUES (${IMPORT_COMMENT_COLUMNS.map(() => '?').join(', ')}) ON CONFLICT(id) DO NOTHING`;
1830
+ for (const comment of comments) {
1831
+ if (!comment || comment.id == null || !existingIds.has(comment.issue_id)) { summary.comments.skipped += 1; continue; }
1832
+ const result = runParams(runtime, db, commentSql, [
1833
+ comment.id,
1834
+ comment.issue_id,
1835
+ comment.body ?? '',
1836
+ comment.actor ?? 'beads',
1837
+ comment.visibility ?? 'local',
1838
+ comment.created_at ?? now,
1839
+ ]);
1840
+ summary.comments[wasInserted(result) ? 'inserted' : 'skipped'] += 1;
1841
+ }
1842
+
1843
+ const dependencySql = `INSERT INTO kernel_dependencies (${IMPORT_DEPENDENCY_COLUMNS.join(', ')})`
1844
+ + ` VALUES (${IMPORT_DEPENDENCY_COLUMNS.map(() => '?').join(', ')}) ON CONFLICT(id) DO NOTHING`;
1845
+ for (const dependency of dependencies) {
1846
+ if (!dependency || dependency.id == null
1847
+ || !existingIds.has(dependency.issue_id) || !existingIds.has(dependency.blocks_issue_id)) {
1848
+ summary.dependencies.skipped += 1;
1849
+ continue;
1850
+ }
1851
+ const result = runParams(runtime, db, dependencySql, [
1852
+ dependency.id,
1853
+ dependency.issue_id,
1854
+ dependency.blocks_issue_id,
1855
+ dependency.dependency_type ?? 'blocks',
1856
+ dependency.created_at ?? now,
1857
+ ]);
1858
+ summary.dependencies[wasInserted(result) ? 'inserted' : 'skipped'] += 1;
1859
+ }
1860
+
1861
+ // Legacy activity log → kernel_events. No entity FK, so every record inserts; the
1862
+ // deterministic id (ON CONFLICT DO NOTHING) makes re-migration idempotent.
1863
+ const eventSql = `INSERT INTO kernel_events (${IMPORT_EVENT_COLUMNS.join(', ')})`
1864
+ + ` VALUES (${IMPORT_EVENT_COLUMNS.map(() => '?').join(', ')}) ON CONFLICT(id) DO NOTHING`;
1865
+ for (const event of activityEvents) {
1866
+ if (!event || event.id == null) { summary.events.skipped += 1; continue; }
1867
+ const row = buildImportEventRow(event, now);
1868
+ const result = runParams(runtime, db, eventSql, IMPORT_EVENT_COLUMNS.map(column => row[column]));
1869
+ summary.events[wasInserted(result) ? 'inserted' : 'skipped'] += 1;
1870
+ }
1871
+
1872
+ execSql(runtime, db, 'COMMIT;');
1873
+ } catch (error) {
1874
+ try {
1875
+ execSql(runtime, db, 'ROLLBACK;');
1876
+ } catch {
1877
+ // A rollback failure must not mask the original import error.
1878
+ }
1879
+ throw error;
1880
+ }
1881
+ return summary;
1882
+ }
1883
+
1884
+ // --- Project-memory read-model primitives -------------------------------------
1885
+ // kernel_memories is a Forge read model written DIRECTLY (NOT through the guarded-event
1886
+ // path), so these are plain synchronous SQL helpers. project-memory.js owns the memory
1887
+ // entry shape; here we (de)serialize the JSON columns and upsert by key. The driver
1888
+ // methods are synchronous so the (synchronous) project-memory facade can persist without
1889
+ // awaiting the async broker.initialize().
1890
+ const KERNEL_MEMORY_COLUMNS = Object.freeze([
1891
+ 'key',
1892
+ 'value_json',
1893
+ 'source_agent',
1894
+ 'scope',
1895
+ 'confidence',
1896
+ 'tags_json',
1897
+ 'supersedes_json',
1898
+ 'beads_refs_json',
1899
+ 'created_at',
1900
+ 'updated_at',
1901
+ ]);
1902
+
1903
+ function parseMemoryJsonColumn(raw, fallback) {
1904
+ if (raw === null || raw === undefined || raw === '') return fallback;
1905
+ try {
1906
+ return JSON.parse(raw);
1907
+ } catch {
1908
+ return fallback;
1909
+ }
1910
+ }
1911
+
1912
+ // Map a stored row back to the memory entry shape. Optional fields are omitted when
1913
+ // unset (matches the legacy entry shape); tags and timestamp are always present. The
1914
+ // entry's logical `timestamp` is the mutable "as-of" time (updated_at), so re-writing a
1915
+ // key surfaces the latest write — matching the legacy single-timestamp behavior, where
1916
+ // every write refreshed the stored timestamp. created_at stays as immutable first-seen
1917
+ // provenance and is intentionally not part of the entry shape.
1918
+ function memoryRowToEntry(row) {
1919
+ if (!row) return null;
1920
+ const entry = {
1921
+ key: row.key,
1922
+ value: parseMemoryJsonColumn(row.value_json, row.value_json),
1923
+ sourceAgent: row.source_agent,
1924
+ tags: parseMemoryJsonColumn(row.tags_json, []),
1925
+ timestamp: row.updated_at,
1926
+ };
1927
+ if (row.scope !== null && row.scope !== undefined) entry.scope = row.scope;
1928
+ if (row.confidence !== null && row.confidence !== undefined) entry.confidence = Number(row.confidence);
1929
+ const supersedes = parseMemoryJsonColumn(row.supersedes_json, undefined);
1930
+ if (Array.isArray(supersedes)) entry.supersedes = supersedes;
1931
+ const beadsRefs = parseMemoryJsonColumn(row.beads_refs_json, undefined);
1932
+ if (Array.isArray(beadsRefs)) entry.beadsRefs = beadsRefs;
1933
+ return entry;
1934
+ }
1935
+
1936
+ function memoryEntryToRow(entry, now) {
1937
+ const tags = Array.isArray(entry.tags) ? entry.tags : [];
1938
+ return {
1939
+ key: entry.key,
1940
+ value_json: JSON.stringify(entry.value ?? null),
1941
+ source_agent: entry.sourceAgent ?? entry['source-agent'] ?? '',
1942
+ scope: entry.scope ?? null,
1943
+ confidence: entry.confidence ?? null,
1944
+ tags_json: JSON.stringify(tags),
1945
+ supersedes_json: Array.isArray(entry.supersedes) ? JSON.stringify(entry.supersedes) : null,
1946
+ beads_refs_json: Array.isArray(entry.beadsRefs) ? JSON.stringify(entry.beadsRefs) : null,
1947
+ // created_at is first-seen provenance (kept across upserts); updated_at is the
1948
+ // entry's logical timestamp (the "as-of" the read model surfaces). Both default to
1949
+ // the wall clock when the caller omits a timestamp (e.g. a direct driver write).
1950
+ created_at: entry.timestamp || now,
1951
+ updated_at: entry.timestamp || now,
1952
+ };
1953
+ }
1954
+
1955
+ // Upsert by key: insert a fresh row, or refresh every value column on a key collision
1956
+ // while keeping the original created_at (only updated_at advances).
1957
+ function upsertMemoryRow(runtime, db, entry) {
1958
+ const now = new Date().toISOString();
1959
+ const row = memoryEntryToRow(entry, now);
1960
+ const placeholders = KERNEL_MEMORY_COLUMNS.map(() => '?').join(', ');
1961
+ runParams(
1962
+ runtime,
1963
+ db,
1964
+ `INSERT INTO kernel_memories (${KERNEL_MEMORY_COLUMNS.join(', ')}) VALUES (${placeholders})
1965
+ ON CONFLICT(key) DO UPDATE SET
1966
+ value_json = excluded.value_json,
1967
+ source_agent = excluded.source_agent,
1968
+ scope = excluded.scope,
1969
+ confidence = excluded.confidence,
1970
+ tags_json = excluded.tags_json,
1971
+ supersedes_json = excluded.supersedes_json,
1972
+ beads_refs_json = excluded.beads_refs_json,
1973
+ updated_at = excluded.updated_at`,
1974
+ KERNEL_MEMORY_COLUMNS.map(column => row[column]),
1975
+ );
1976
+ return memoryRowToEntry(row);
1977
+ }
1978
+
1979
+ function loadMemoryRow(runtime, db, key) {
1980
+ const rows = allParams(runtime, db, 'SELECT * FROM kernel_memories WHERE key = ?', [key]);
1981
+ return memoryRowToEntry(rows[0] || null);
1982
+ }
1983
+
1984
+ function listMemoryRows(runtime, db) {
1985
+ return allParams(runtime, db, 'SELECT * FROM kernel_memories ORDER BY key ASC').map(memoryRowToEntry);
1986
+ }
1987
+
1988
+ // Token-AND LIKE search across key + value_json. Each whitespace-separated token must
1989
+ // appear (in either column); an empty query lists everything. Parameterized, so the
1990
+ // tokens never interpolate into SQL.
1991
+ function searchMemoryRows(runtime, db, query) {
1992
+ const tokens = String(query ?? '').trim().split(/\s+/).filter(Boolean);
1993
+ if (tokens.length === 0) {
1994
+ return listMemoryRows(runtime, db);
1995
+ }
1996
+ const clauses = tokens.map(() => '(key LIKE ? OR value_json LIKE ?)').join(' AND ');
1997
+ const params = tokens.flatMap(token => {
1998
+ const like = `%${token}%`;
1999
+ return [like, like];
2000
+ });
2001
+ return allParams(
2002
+ runtime,
2003
+ db,
2004
+ `SELECT * FROM kernel_memories WHERE ${clauses} ORDER BY key ASC`,
2005
+ params,
2006
+ ).map(memoryRowToEntry);
2007
+ }
2008
+
2009
+ // Optional source_agent allow-list → a parameterized `WHERE source_agent IN (...)` clause.
2010
+ // Lets the default `recall` view show only human `remember` notes without loading and
2011
+ // filtering the whole table in JS. Returns { clause, params }; empty when no filter.
2012
+ function memoryAgentFilter(agents) {
2013
+ if (!Array.isArray(agents) || agents.length === 0) {
2014
+ return { clause: '', params: [] };
2015
+ }
2016
+ const placeholders = agents.map(() => '?').join(', ');
2017
+ return { clause: ` WHERE source_agent IN (${placeholders})`, params: [...agents] };
2018
+ }
2019
+
2020
+ // The newest `limit` entries by logical (as-of) timestamp — the default read model for
2021
+ // `recall` with no query. rowid breaks ties so same-timestamp rows are still deterministic.
2022
+ // An optional `agents` allow-list scopes the view (e.g. to human `remember` notes).
2023
+ function recentMemoryRows(runtime, db, limit, agents) {
2024
+ const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
2025
+ const { clause, params } = memoryAgentFilter(agents);
2026
+ return allParams(
2027
+ runtime,
2028
+ db,
2029
+ `SELECT * FROM kernel_memories${clause} ORDER BY updated_at DESC, rowid DESC LIMIT ?`,
2030
+ [...params, capped],
2031
+ ).map(memoryRowToEntry);
2032
+ }
2033
+
2034
+ // Total number of stored memories (optionally scoped by `agents`) — paired with
2035
+ // recentMemoryRows so `recall` can report "showing N of TOTAL" instead of silently truncating.
2036
+ function countMemoryRows(runtime, db, agents) {
2037
+ const { clause, params } = memoryAgentFilter(agents);
2038
+ const rows = allParams(runtime, db, `SELECT count(*) AS count FROM kernel_memories${clause}`, params);
2039
+ return Number((rows[0] || {}).count) || 0;
2040
+ }
2041
+
2042
+ // Turn a free-form query into an FTS5 MATCH expression: extract alphanumeric barewords,
2043
+ // quote each as a phrase (so an FTS operator token can never break the syntax), and AND
2044
+ // them together. Order-independent token-AND matching — "auth bug" matches a note holding
2045
+ // both tokens in any order. Returns '' when the query has no usable tokens.
2046
+ function buildMemoryFtsMatch(query) {
2047
+ const tokens = String(query ?? '').match(/[\p{L}\p{N}]+/gu);
2048
+ if (!tokens || tokens.length === 0) return '';
2049
+ return tokens.map(token => `"${token}"`).join(' AND ');
2050
+ }
2051
+
2052
+ // BM25 top-N recall over the kernel_memories_fts index (migration 008). Joins the FTS
2053
+ // rowid back to the memory row and orders by bm25 (lower = better match). An empty/tokenless
2054
+ // query falls back to recent entries so `recall` never returns a bare full dump.
2055
+ function searchMemoryRowsRanked(runtime, db, query, limit) {
2056
+ const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
2057
+ const match = buildMemoryFtsMatch(query);
2058
+ if (!match) {
2059
+ return recentMemoryRows(runtime, db, capped);
2060
+ }
2061
+ return allParams(
2062
+ runtime,
2063
+ db,
2064
+ `SELECT m.* FROM kernel_memories m
2065
+ JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
2066
+ WHERE kernel_memories_fts MATCH ?
2067
+ ORDER BY bm25(kernel_memories_fts)
2068
+ LIMIT ?`,
2069
+ [match, capped],
2070
+ ).map(memoryRowToEntry);
2071
+ }
2072
+
2073
+ function closeDatabase(db) {
2074
+ if (db && typeof db.close === 'function') {
2075
+ db.close();
2076
+ }
2077
+ }
2078
+
2079
+ function createDriver(runtime, configuredDatabasePath) {
2080
+ let db;
2081
+ let openedDatabasePath;
2082
+ let memorySchemaEnsured = false;
2083
+
2084
+ // kernel_memories is created by migration 005 through broker.initialize(), but the
2085
+ // synchronous project-memory facade writes WITHOUT first running migrations. Lazily
2086
+ // ensure the table (idempotent CREATE IF NOT EXISTS, rendered from the same migration)
2087
+ // plus a busy_timeout for the second connection the issue backend may hold open.
2088
+ function ensureMemorySchema(database) {
2089
+ if (memorySchemaEnsured) return;
2090
+ execSql(runtime, database, 'PRAGMA busy_timeout=5000;');
2091
+ for (const statement of buildMemoryProjectionMigration().apply) {
2092
+ execSql(runtime, database, statement);
2093
+ }
2094
+ // FTS5 recall index (migration 008): create the virtual table + sync triggers
2095
+ // idempotently so a synchronous memory write stays indexed without a prior
2096
+ // broker.initialize(). When the index is NEWLY created, rebuild once to backfill any
2097
+ // rows written before it existed (a DB upgraded from before this feature, or rows the
2098
+ // insights engine wrote straight to kernel_memories) — the sync triggers keep it
2099
+ // current thereafter, so steady-state process starts skip the reindex.
2100
+ //
2101
+ // Staleness is detected by TABLE EXISTENCE (sqlite_master), never by count(*): on an
2102
+ // external-content FTS5 table `count(*)` returns the CONTENT row count, not the
2103
+ // indexed-doc count, so it can never reveal an un-backfilled index.
2104
+ const ftsDdl = memoryFtsDdl();
2105
+ const ftsExisted = Number(queryOne(
2106
+ runtime,
2107
+ database,
2108
+ "SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'kernel_memories_fts'",
2109
+ ).count) > 0;
2110
+ execSql(runtime, database, ftsDdl.create);
2111
+ for (const trigger of ftsDdl.triggers) {
2112
+ execSql(runtime, database, trigger);
2113
+ }
2114
+ if (!ftsExisted) {
2115
+ execSql(runtime, database, ftsDdl.rebuild);
2116
+ }
2117
+ memorySchemaEnsured = true;
2118
+ }
2119
+
2120
+ function resolveDatabasePath(config) {
2121
+ const brokerDatabasePath = config && config.databasePath;
2122
+ if (configuredDatabasePath && brokerDatabasePath && configuredDatabasePath !== brokerDatabasePath) {
2123
+ throw new Error([
2124
+ 'Kernel SQLite driver databasePath mismatch:',
2125
+ `driver is configured for ${configuredDatabasePath}`,
2126
+ `but broker config uses ${brokerDatabasePath}`,
2127
+ ].join(' '));
2128
+ }
2129
+ const databasePath = brokerDatabasePath || configuredDatabasePath;
2130
+ if (!databasePath) {
2131
+ throw new Error('Kernel SQLite driver requires a databasePath or broker config databasePath');
2132
+ }
2133
+ return databasePath;
2134
+ }
2135
+
2136
+ function getDatabase(config) {
2137
+ const databasePath = resolveDatabasePath(config);
2138
+ if (!db) {
2139
+ db = createDatabase(runtime, databasePath);
2140
+ openedDatabasePath = databasePath;
2141
+ } else if (openedDatabasePath !== databasePath) {
2142
+ throw new Error(`Kernel SQLite driver is already open for ${openedDatabasePath}`);
2143
+ }
2144
+ return db;
2145
+ }
2146
+
2147
+ return {
2148
+ runtime: {
2149
+ id: runtime.id,
2150
+ databaseClassName: runtime.databaseClassName,
2151
+ nativeCompileDependency: runtime.nativeCompileDependency,
2152
+ experimental: runtime.experimental,
2153
+ },
2154
+ databasePath: configuredDatabasePath,
2155
+ async exec(statement, config) {
2156
+ execSql(runtime, getDatabase(config), statement);
2157
+ },
2158
+ async queryAll(statement, config) {
2159
+ return queryAll(runtime, getDatabase(config), statement);
2160
+ },
2161
+ async issueOperation(operation, args = [], context = {}, config = {}) {
2162
+ const database = getDatabase(config);
2163
+ const READ_OPERATIONS = new Set(['ready', 'list', 'show', 'search', 'stats', 'blocked', 'stale', 'orphans', 'lint', 'children', 'owns', 'claims']);
2164
+ if (READ_OPERATIONS.has(operation)) {
2165
+ return runIssueReadOperation(runtime, database, operation, args, context);
2166
+ }
2167
+ // Mutations (create/update/close/comment/dep.add/dep.remove/claim/release) are
2168
+ // implemented through the broker's guarded-event path in a later wave.
2169
+ throw new Error(`Kernel SQLite driver issueOperation: mutation operation '${operation}' is not implemented yet (reads only)`);
2170
+ },
2171
+ // Git-style short-id support (kernel 9556660b): the candidate ids (+ titles)
2172
+ // whose id starts with `prefix`. Parameterized LIKE with escaped wildcards;
2173
+ // ordered by id ascending so an EXACT match (the shortest id sharing the
2174
+ // prefix) always sorts first and is never pushed out by the limit. Consumed
2175
+ // by the broker's issue-id prefix resolver, never by the contract directly.
2176
+ async findIssueIdsByPrefix(prefix, limit = 6, _context = {}, config = {}) {
2177
+ const escaped = String(prefix).replace(/[\\%_]/g, match => `\\${match}`);
2178
+ return allParams(
2179
+ runtime, getDatabase(config),
2180
+ "SELECT id, title FROM kernel_issues WHERE id LIKE ? ESCAPE '\\' ORDER BY id ASC LIMIT ?",
2181
+ [`${escaped}%`, limit],
2182
+ );
2183
+ },
2184
+ // --- Event-store primitives (Wave 2) — composed by broker.runGuardedEvent.
2185
+ // `context` is part of the broker contract but unused by these direct SQL
2186
+ // reads/writes (prefixed `_` for eslint no-unused-vars).
2187
+ async insertKernelEvent(event, _context = {}, config = {}) {
2188
+ return insertKernelEventRow(runtime, getDatabase(config), event);
2189
+ },
2190
+ async loadKernelEntity(entityType, entityId, _context = {}, config = {}) {
2191
+ return loadKernelEntityRow(runtime, getDatabase(config), entityType, entityId);
2192
+ },
2193
+ async listKernelEvents(entityType, entityId, _context = {}, config = {}) {
2194
+ return listKernelEventRows(runtime, getDatabase(config), entityType, entityId);
2195
+ },
2196
+ async loadKernelEventByIdempotencyKey(idempotencyKey, _context = {}, config = {}) {
2197
+ return loadKernelEventByIdempotencyKeyRow(runtime, getDatabase(config), idempotencyKey);
2198
+ },
2199
+ async insertKernelConflict(conflict, _context = {}, config = {}) {
2200
+ return insertKernelConflictRow(runtime, getDatabase(config), conflict);
2201
+ },
2202
+ async enqueueKernelProjection(entry, _context = {}, config = {}) {
2203
+ return enqueueKernelProjectionRow(runtime, getDatabase(config), entry);
2204
+ },
2205
+ // --- Projection-outbox read/update surface (Wave 5) — composed by the
2206
+ // broker's projection-outbox methods, consumed by runJsonlProjectionConsumer.
2207
+ // These never touch the append/CAS path; `context` is part of the broker
2208
+ // contract but unused by these direct reads/writes (prefixed `_`).
2209
+ async listProjectionOutbox(filter = {}, _context = {}, config = {}) {
2210
+ return listProjectionOutboxRows(runtime, getDatabase(config), filter);
2211
+ },
2212
+ async loadProjectionModel(_context = {}, config = {}) {
2213
+ return loadProjectionModelRows(runtime, getDatabase(config));
2214
+ },
2215
+ async markProjectionDelivered(ids = [], meta = {}, _context = {}, config = {}) {
2216
+ return markProjectionDeliveredRows(runtime, getDatabase(config), ids, meta);
2217
+ },
2218
+ async recordProjectionFailure(record, _context = {}, config = {}) {
2219
+ return recordProjectionFailureRows(runtime, getDatabase(config), record);
2220
+ },
2221
+ async deadLetterProjection(record, _context = {}, config = {}) {
2222
+ return deadLetterProjectionRows(runtime, getDatabase(config), record);
2223
+ },
2224
+ async listKernelDependencies(scope, _context = {}, config = {}) {
2225
+ return listKernelDependencyRows(runtime, getDatabase(config), scope);
2226
+ },
2227
+ // Claim-lease primitives (Wave 4) — composed by commitGuardedAccept /
2228
+ // resolveClaimAcquisition. loadActiveKernelClaim feeds planClaimAcquisition;
2229
+ // insertKernelClaim / updateKernelClaimState are the lease writes. The DB
2230
+ // partial-UNIQUE index (idx_kernel_claims_active_lease) enforces the
2231
+ // single-active-claim-per-issue invariant under concurrent writers.
2232
+ async loadActiveKernelClaim(issueId, _context = {}, config = {}) {
2233
+ return loadActiveKernelClaimRow(runtime, getDatabase(config), issueId);
2234
+ },
2235
+ async insertKernelClaim(claim, _context = {}, config = {}) {
2236
+ return insertKernelClaimRow(runtime, getDatabase(config), claim);
2237
+ },
2238
+ async updateKernelClaimState(claimId, state, _context = {}, config = {}) {
2239
+ return updateKernelClaimStateRow(runtime, getDatabase(config), claimId, state);
2240
+ },
2241
+ // commitGuardedAccept invokes this (typeof-guarded) INSIDE its BEGIN IMMEDIATE
2242
+ // transaction to apply an accepted issue event to the authority tables. The
2243
+ // returned summary ({id, revision, comment_id?}) flows back through
2244
+ // runGuardedEvent's result so runIssueOperation can shape the mutation response.
2245
+ async applyAcceptedIssueMutation(event, context = {}, config = {}) {
2246
+ return applyAcceptedMutation(runtime, getDatabase(config), event, context);
2247
+ },
2248
+ // Faithful-import write path: insert a kernel records bundle ({ issues, comments,
2249
+ // dependencies, events, activityEvents }) DIRECTLY into the authority tables,
2250
+ // preserving each issue's original created_at/updated_at + terminal status (bypassing
2251
+ // the now-stamping create/CAS path) and landing the legacy beads activity log in
2252
+ // kernel_events. Idempotent + transactional. `context` is part of the driver contract
2253
+ // but unused by this direct write (prefixed `_`).
2254
+ async importIssues(records = {}, options = {}, _context = {}, config = {}) {
2255
+ return importIssueRecords(runtime, getDatabase(config), records, options);
2256
+ },
2257
+ // --- Project-memory read model (written directly, not via the guarded path).
2258
+ // Synchronous by design: the project-memory facade is synchronous, and these
2259
+ // lazily ensure the kernel_memories table so a write never needs a prior
2260
+ // (async) broker.initialize().
2261
+ // --- Worktree-linkage registry (written directly, not via the guarded event
2262
+ // path). Synchronous like the memory facade so `forge worktree create` and the
2263
+ // synchronous orientation read can use them without a prior broker.initialize().
2264
+ // registerWorktree requires the 007 columns (callers use a migrated driver);
2265
+ // getWorktreeLinkage/listWorktrees tolerate a missing/empty table (safeAll) and
2266
+ // return null/[] so orientation falls back to the folder heuristic.
2267
+ registerWorktree(input, config = {}) {
2268
+ return upsertWorktreeRow(runtime, getDatabase(config), input);
2269
+ },
2270
+ getWorktreeLinkage(filter = {}, config = {}) {
2271
+ return loadWorktreeRowByPath(runtime, getDatabase(config), filter.path);
2272
+ },
2273
+ listWorktrees(filter = {}, config = {}) {
2274
+ return listWorktreeRows(runtime, getDatabase(config), filter);
2275
+ },
2276
+ // --- Stage-run registry (f61601ab). Direct writes like the worktree registry
2277
+ // (bypass the guarded event path), synchronous so a CLI verb / orientation read
2278
+ // can use them without a prior async broker.initialize(). Idempotent per
2279
+ // (issue_id, stage). getCurrentStage powers the real workflow-phase read.
2280
+ recordStageRun(input, config = {}) {
2281
+ return recordStageRunRow(runtime, getDatabase(config), input);
2282
+ },
2283
+ // Atomic complete(from)+start(to) in ONE transaction: a mid-transition failure
2284
+ // rolls back both writes so `current_stage` never reflects a half-transition.
2285
+ recordStageTransition(input, config = {}) {
2286
+ return recordStageTransitionRow(runtime, getDatabase(config), input);
2287
+ },
2288
+ listStageRuns(filter = {}, config = {}) {
2289
+ return listStageRunRows(runtime, getDatabase(config), filter.issue_id);
2290
+ },
2291
+ getCurrentStage(filter = {}, config = {}) {
2292
+ return loadCurrentStageRunRow(runtime, getDatabase(config), filter.issue_id);
2293
+ },
2294
+ recordMemory(entry, config = {}) {
2295
+ const database = getDatabase(config);
2296
+ ensureMemorySchema(database);
2297
+ return upsertMemoryRow(runtime, database, entry);
2298
+ },
2299
+ loadMemory(key, config = {}) {
2300
+ const database = getDatabase(config);
2301
+ ensureMemorySchema(database);
2302
+ return loadMemoryRow(runtime, database, key);
2303
+ },
2304
+ searchMemories(query, config = {}) {
2305
+ const database = getDatabase(config);
2306
+ ensureMemorySchema(database);
2307
+ return searchMemoryRows(runtime, database, query);
2308
+ },
2309
+ // BM25 top-N recall over the FTS5 index (token-AND). An empty query falls back to
2310
+ // the newest `limit` entries so recall never returns a bare full dump.
2311
+ searchMemoriesRanked(query, limit, config = {}) {
2312
+ const database = getDatabase(config);
2313
+ ensureMemorySchema(database);
2314
+ return searchMemoryRowsRanked(runtime, database, query, limit);
2315
+ },
2316
+ // The newest `limit` entries (default recall with no query). `options.agents` scopes
2317
+ // the read to a source_agent allow-list (e.g. human `remember` notes only).
2318
+ recentMemories(limit, options = {}, config = {}) {
2319
+ const database = getDatabase(config);
2320
+ ensureMemorySchema(database);
2321
+ return recentMemoryRows(runtime, database, limit, options.agents);
2322
+ },
2323
+ // Total stored memories (optionally scoped by `options.agents`) — lets recall report
2324
+ // "showing N of TOTAL".
2325
+ countMemories(options = {}, config = {}) {
2326
+ const database = getDatabase(config);
2327
+ ensureMemorySchema(database);
2328
+ return countMemoryRows(runtime, database, options.agents);
2329
+ },
2330
+ listMemories(config = {}) {
2331
+ const database = getDatabase(config);
2332
+ ensureMemorySchema(database);
2333
+ return listMemoryRows(runtime, database);
2334
+ },
2335
+ close() {
2336
+ closeDatabase(db);
2337
+ db = null;
2338
+ openedDatabasePath = null;
2339
+ memorySchemaEnsured = false;
2340
+ },
2341
+ };
2342
+ }
2343
+
2344
+ function assertCapability(runtime, capability, detail) {
2345
+ if (!detail.ok) {
2346
+ throw new Error(`Builtin SQLite runtime ${runtime.id} failed ${capability} validation: ${detail.reason}`);
2347
+ }
2348
+ return true;
2349
+ }
2350
+
2351
+ function validateWal(runtime, db) {
2352
+ const row = queryOne(runtime, db, 'PRAGMA journal_mode=WAL;');
2353
+ const mode = String(row.journal_mode || '').toLowerCase();
2354
+ return { ok: mode === 'wal', reason: `journal_mode=${mode || 'unknown'}` };
2355
+ }
2356
+
2357
+ function validateBusyTimeout(runtime, db) {
2358
+ const row = queryOne(runtime, db, 'PRAGMA busy_timeout=5000;');
2359
+ const timeout = Number(row.timeout);
2360
+ return { ok: timeout === 5000, reason: `timeout=${Number.isNaN(timeout) ? 'unknown' : timeout}` };
2361
+ }
2362
+
2363
+ function createProbeTableName(prefix) {
2364
+ probeCounter += 1;
2365
+ return `${prefix}_${process.pid}_${probeCounter}`;
2366
+ }
2367
+
2368
+ function validateTransactions(runtime, db) {
2369
+ const tableName = createProbeTableName('forge_transaction_probe');
2370
+ let committed = false;
2371
+ try {
2372
+ execSql(runtime, db, [
2373
+ 'BEGIN IMMEDIATE;',
2374
+ `CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY, value TEXT NOT NULL);`,
2375
+ `INSERT INTO ${tableName} (value) VALUES ('ok');`,
2376
+ 'COMMIT;',
2377
+ ].join('\n'));
2378
+ committed = true;
2379
+ const row = queryOne(runtime, db, `SELECT value FROM ${tableName} WHERE id = 1;`);
2380
+ return { ok: row.value === 'ok', reason: `value=${row.value || 'missing'}` };
2381
+ } catch (error) {
2382
+ if (!committed) {
2383
+ try {
2384
+ execSql(runtime, db, 'ROLLBACK;');
2385
+ } catch {
2386
+ // Ignore rollback errors from runtimes that already closed the failed transaction.
2387
+ }
2388
+ }
2389
+ return { ok: false, reason: error.message || String(error) };
2390
+ } finally {
2391
+ try {
2392
+ execSql(runtime, db, `DROP TABLE IF EXISTS ${tableName};`);
2393
+ } catch {
2394
+ // Probe cleanup must not hide the original capability result.
2395
+ }
2396
+ }
2397
+ }
2398
+
2399
+ function validateFts5(runtime, db) {
2400
+ const tableName = createProbeTableName('forge_fts_probe');
2401
+ try {
2402
+ execSql(runtime, db, `CREATE VIRTUAL TABLE ${tableName} USING fts5(content);`);
2403
+ execSql(runtime, db, `INSERT INTO ${tableName} (content) VALUES ('kernel sqlite driver');`);
2404
+ const row = queryOne(runtime, db, `SELECT count(*) AS count FROM ${tableName} WHERE ${tableName} MATCH 'sqlite';`);
2405
+ return { ok: Number(row.count) === 1, reason: `count=${row.count || 0}` };
2406
+ } catch (error) {
2407
+ return { ok: false, reason: error.message || String(error) };
2408
+ } finally {
2409
+ try {
2410
+ execSql(runtime, db, `DROP TABLE IF EXISTS ${tableName};`);
2411
+ } catch {
2412
+ // Probe cleanup must not hide the original capability result.
2413
+ }
2414
+ }
2415
+ }
2416
+
2417
+ function validateCheckpoint(runtime, db) {
2418
+ try {
2419
+ const row = queryOne(runtime, db, 'PRAGMA wal_checkpoint(TRUNCATE);');
2420
+ return { ok: Number(row.busy) === 0, reason: `busy=${row.busy}` };
2421
+ } catch (error) {
2422
+ return { ok: false, reason: error.message || String(error) };
2423
+ }
2424
+ }
2425
+
2426
+ async function createBackup(runtime, db, backupPath) {
2427
+ ensureFileBackedDatabaseDirectory(backupPath);
2428
+ if (fs.existsSync(backupPath)) {
2429
+ fs.rmSync(backupPath, { force: true });
2430
+ }
2431
+
2432
+ if (runtime.id === 'node:sqlite') {
2433
+ if (typeof runtime.module.backup === 'function') {
2434
+ await runtime.module.backup(db, backupPath);
2435
+ return;
2436
+ }
2437
+ if (typeof db.backup === 'function') {
2438
+ await db.backup(backupPath);
2439
+ return;
2440
+ }
2441
+ throw new Error('node:sqlite backup API is unavailable');
2442
+ }
2443
+
2444
+ if (runtime.id === 'bun:sqlite') {
2445
+ if (typeof db.serialize !== 'function') {
2446
+ throw new Error('bun:sqlite Database.serialize() is unavailable');
2447
+ }
2448
+ fs.writeFileSync(backupPath, db.serialize());
2449
+ return;
2450
+ }
2451
+
2452
+ throw new Error(`Unsupported builtin SQLite runtime: ${runtime.id}`);
2453
+ }
2454
+
2455
+ async function validateBackup(runtime, db, backupPath) {
2456
+ const tableName = createProbeTableName('forge_backup_probe');
2457
+ try {
2458
+ execSql(runtime, db, `CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY, value TEXT NOT NULL);`);
2459
+ execSql(runtime, db, `INSERT INTO ${tableName} (value) VALUES ('ok');`);
2460
+ await createBackup(runtime, db, backupPath);
2461
+ const backupDb = createDatabase(runtime, backupPath);
2462
+ try {
2463
+ const row = queryOne(runtime, backupDb, `SELECT value FROM ${tableName} WHERE id = 1;`);
2464
+ return {
2465
+ ok: row.value === 'ok' && fs.existsSync(backupPath),
2466
+ reason: `value=${row.value || 'missing'}`,
2467
+ };
2468
+ } finally {
2469
+ closeDatabase(backupDb);
2470
+ }
2471
+ } catch (error) {
2472
+ return { ok: false, reason: error.message || String(error) };
2473
+ } finally {
2474
+ try {
2475
+ execSql(runtime, db, `DROP TABLE IF EXISTS ${tableName};`);
2476
+ } catch {
2477
+ // Probe cleanup must not hide the original capability result.
2478
+ }
2479
+ }
2480
+ }
2481
+
2482
+ async function validateBuiltinSQLiteRuntimeDriver(options = {}, deps = {}) {
2483
+ const runtime = options.runtime || selectBuiltinSQLiteRuntime(deps);
2484
+ let tempDir = options.tempDir;
2485
+ let ownsTempDir = false;
2486
+ if (!tempDir && !options.databasePath && !options.backupPath) {
2487
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-kernel-sqlite-'));
2488
+ ownsTempDir = true;
2489
+ }
2490
+ const databasePath = options.databasePath
2491
+ || (tempDir ? path.join(tempDir, 'kernel.sqlite') : `${options.backupPath}.source.sqlite`);
2492
+ const backupPath = options.backupPath
2493
+ || (tempDir ? path.join(tempDir, 'kernel.backup.sqlite') : `${databasePath}.backup.sqlite`);
2494
+ let db;
2495
+
2496
+ try {
2497
+ db = createDatabase(runtime, databasePath);
2498
+ const capabilities = {
2499
+ wal: assertCapability(runtime, 'WAL', validateWal(runtime, db)),
2500
+ busyTimeout: assertCapability(runtime, 'busy_timeout', validateBusyTimeout(runtime, db)),
2501
+ transactions: assertCapability(runtime, 'transaction', validateTransactions(runtime, db)),
2502
+ fts5: assertCapability(runtime, 'FTS5', validateFts5(runtime, db)),
2503
+ checkpoint: assertCapability(runtime, 'checkpoint', validateCheckpoint(runtime, db)),
2504
+ backup: assertCapability(runtime, 'backup', await validateBackup(runtime, db, backupPath)),
2505
+ nativeCompileDependency: runtime.nativeCompileDependency,
2506
+ };
2507
+
2508
+ return {
2509
+ runtime: {
2510
+ id: runtime.id,
2511
+ databaseClassName: runtime.databaseClassName,
2512
+ nativeCompileDependency: runtime.nativeCompileDependency,
2513
+ experimental: runtime.experimental,
2514
+ },
2515
+ databasePath,
2516
+ backupPath,
2517
+ capabilities,
2518
+ };
2519
+ } finally {
2520
+ closeDatabase(db);
2521
+ if (ownsTempDir) {
2522
+ fs.rmSync(tempDir, { recursive: true, force: true });
2523
+ }
2524
+ }
2525
+ }
2526
+
2527
+ function createBuiltinSQLiteDriver(options = {}, deps = {}) {
2528
+ const runtime = options.runtime || selectBuiltinSQLiteRuntime(deps);
2529
+ return createDriver(runtime, options.databasePath);
2530
+ }
2531
+
2532
+ module.exports = {
2533
+ BUILTIN_SQLITE_RUNTIME_ORDER,
2534
+ CONFLICT_SIGNAL,
2535
+ classifyConflictSignal,
2536
+ createBuiltinSQLiteDriver,
2537
+ requireSqliteRuntimeModule,
2538
+ selectBuiltinSQLiteRuntime,
2539
+ validateBuiltinSQLiteRuntimeDriver,
2540
+ };