forge-workflow 0.0.9 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (479) hide show
  1. package/.claude/rules/{greptile-review-process.md → review-process.md} +56 -41
  2. package/.claude/scripts/{greptile-resolve.sh → review-resolve.sh} +13 -3
  3. package/.cursor/rules/permissions-guidance.mdc +2 -2
  4. package/.forge/hooks/check-tdd.js +3 -0
  5. package/.forge/hooks/forge-native-hook.js +245 -0
  6. package/.forge/protected-paths.yaml +157 -0
  7. package/AGENTS.md +151 -61
  8. package/CHANGELOG.md +681 -0
  9. package/CLAUDE.md +9 -106
  10. package/QUICKSTART.md +171 -0
  11. package/README.md +271 -363
  12. package/bin/forge-cmd.js +120 -9
  13. package/bin/forge-preflight.js +26 -5
  14. package/bin/forge.js +466 -489
  15. package/docs/INDEX.md +93 -0
  16. package/docs/PROJECT_DESIGN.md +685 -0
  17. package/docs/architecture/index.md +66 -0
  18. package/docs/architecture/notes/README.md +35 -0
  19. package/docs/architecture/subsystems/README.md +46 -0
  20. package/docs/{TOOLCHAIN.md → forge/TOOLCHAIN.md} +56 -47
  21. package/docs/forge/VALIDATION.md +82 -0
  22. package/docs/{AGENT_INSTALL_PROMPT.md → guides/AGENT_INSTALL_PROMPT.md} +3 -3
  23. package/docs/guides/BEADS_GITHUB_SYNC.md +32 -0
  24. package/docs/{ENHANCED_ONBOARDING.md → guides/ENHANCED_ONBOARDING.md} +16 -12
  25. package/docs/guides/GREPTILE_SETUP.md +46 -0
  26. package/docs/guides/MANUAL_REVIEW_GUIDE.md +58 -0
  27. package/docs/guides/MIGRATION.md +56 -0
  28. package/docs/guides/SETUP.md +118 -0
  29. package/docs/guides/SUPPORT.md +185 -0
  30. package/docs/guides/WORKFLOW_TEMPLATES.md +74 -0
  31. package/docs/guides/memory-backends.md +183 -0
  32. package/docs/reference/ADAPTERS.md +128 -0
  33. package/docs/reference/AGENT_SKILL_PARITY.md +175 -0
  34. package/docs/reference/COMMANDS.md +205 -0
  35. package/docs/reference/DECISION_DRIFT_GUARDS.md +97 -0
  36. package/docs/{EXAMPLES.md → reference/EXAMPLES.md} +7 -5
  37. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +135 -0
  38. package/docs/reference/HERMES_INTEGRATION.md +118 -0
  39. package/docs/reference/INSIGHTS_RECAP.md +63 -0
  40. package/docs/reference/INSTALL.md +164 -0
  41. package/docs/reference/KERNEL_TAXONOMY_VALIDATION.md +161 -0
  42. package/docs/reference/PROTECTED_PATH_MANIFEST.md +25 -0
  43. package/docs/reference/RELEASE.md +68 -0
  44. package/docs/reference/RESEARCH_TEMPLATE.md +292 -0
  45. package/docs/{ROADMAP.md → reference/ROADMAP.md} +12 -9
  46. package/docs/reference/SKILLS.md +35 -0
  47. package/docs/reference/STATUS_BOARD.md +80 -0
  48. package/docs/reference/TEMPLATES.md +106 -0
  49. package/docs/reference/TOOLCHAIN.md +658 -0
  50. package/docs/reference/VALIDATION.md +82 -0
  51. package/docs/reference/agent-permissions.md +169 -0
  52. package/docs/reference/beads-to-kernel-migration-ux.md +61 -0
  53. package/docs/reference/control-plane-guarantees.md +125 -0
  54. package/docs/reference/dependency-chain.md +331 -0
  55. package/docs/reference/forge-kernel-issue-command-contract.md +161 -0
  56. package/docs/reference/forge-kernel-schema.md +72 -0
  57. package/docs/reference/kernel-conflict-evaluators.md +27 -0
  58. package/docs/reference/patch-md-format.md +77 -0
  59. package/docs/reference/protected-state-surfaces.md +59 -0
  60. package/docs/reference/shepherd.md +115 -0
  61. package/docs/reference/superpowers-analysis.md +320 -0
  62. package/docs/reference/superpowers-integration-options.md +404 -0
  63. package/docs/reference/test-environment.md +519 -0
  64. package/docs/reference/upgrade-safety.md +59 -0
  65. package/lefthook.yml +18 -0
  66. package/lib/adapter-cli.js +307 -0
  67. package/lib/adapters/beads-issue-adapter.js +127 -0
  68. package/lib/adapters/beads-kernel-compat.js +1042 -0
  69. package/lib/adapters/greptile-review-adapter.js +141 -0
  70. package/lib/adapters/kernel-issue-adapter.js +101 -0
  71. package/lib/adapters/pr-state-adapter.js +484 -0
  72. package/lib/adoption-profiles.js +126 -0
  73. package/lib/agents/README.md +2 -6
  74. package/lib/agents/claude.plugin.json +3 -8
  75. package/lib/agents/codex.plugin.json +9 -1
  76. package/lib/agents/cursor.plugin.json +2 -6
  77. package/lib/agents/hermes.plugin.json +22 -0
  78. package/lib/agents-config.js +39 -1236
  79. package/lib/audit-evidence.js +282 -0
  80. package/lib/beads-setup.js +225 -28
  81. package/lib/beads-sync-scaffold.js +36 -107
  82. package/lib/codex-skills.js +51 -1
  83. package/lib/commands/_issue.js +744 -70
  84. package/lib/commands/_manifest.js +91 -0
  85. package/lib/commands/_registry.js +85 -34
  86. package/lib/commands/_resolve-command-opts.js +261 -0
  87. package/lib/commands/_serve-security.js +270 -0
  88. package/lib/commands/adapter.js +12 -0
  89. package/lib/commands/add.js +118 -0
  90. package/lib/commands/audit.js +70 -0
  91. package/lib/commands/blocked.js +5 -0
  92. package/lib/commands/board.js +64 -0
  93. package/lib/commands/claim.js +21 -2
  94. package/lib/commands/claims.js +7 -0
  95. package/lib/commands/clean.js +485 -75
  96. package/lib/commands/close.js +2 -2
  97. package/lib/commands/comment.js +5 -0
  98. package/lib/commands/control.js +148 -0
  99. package/lib/commands/create.js +2 -2
  100. package/lib/commands/dev.js +185 -7
  101. package/lib/commands/doc-gate.js +336 -0
  102. package/lib/commands/doctor.js +156 -0
  103. package/lib/commands/explain.js +15 -0
  104. package/lib/commands/export.js +237 -0
  105. package/lib/commands/gate.js +192 -0
  106. package/lib/commands/hooks.js +242 -0
  107. package/lib/commands/inbox.js +118 -0
  108. package/lib/commands/init.js +598 -0
  109. package/lib/commands/insights.js +79 -0
  110. package/lib/commands/issue.js +12 -1
  111. package/lib/commands/issues.js +66 -0
  112. package/lib/commands/lint.js +5 -0
  113. package/lib/commands/list.js +2 -2
  114. package/lib/commands/merge.js +312 -0
  115. package/lib/commands/migrate.js +523 -0
  116. package/lib/commands/new.js +12 -0
  117. package/lib/commands/options.js +241 -0
  118. package/lib/commands/orient.js +13 -0
  119. package/lib/commands/orphans.js +5 -0
  120. package/lib/commands/patch.js +67 -0
  121. package/lib/commands/plan.js +436 -24
  122. package/lib/commands/preflight.js +211 -0
  123. package/lib/commands/prime.js +13 -0
  124. package/lib/commands/push.js +69 -2
  125. package/lib/commands/ready.js +2 -2
  126. package/lib/commands/recall.js +116 -0
  127. package/lib/commands/recap.js +61 -0
  128. package/lib/commands/recommend.js +22 -2
  129. package/lib/commands/release.js +91 -0
  130. package/lib/commands/remember.js +74 -0
  131. package/lib/commands/role.js +99 -0
  132. package/lib/commands/serve.js +581 -0
  133. package/lib/commands/setup.js +851 -979
  134. package/lib/commands/shepherd.js +436 -0
  135. package/lib/commands/ship.js +23 -1
  136. package/lib/commands/show.js +2 -2
  137. package/lib/commands/stage.js +192 -0
  138. package/lib/commands/stale.js +5 -0
  139. package/lib/commands/status.js +329 -11
  140. package/lib/commands/sync.js +34 -46
  141. package/lib/commands/team.js +15 -2
  142. package/lib/commands/test.js +58 -7
  143. package/lib/commands/update.js +2 -2
  144. package/lib/commands/upgrade.js +47 -0
  145. package/lib/commands/validate.js +56 -25
  146. package/lib/commands/worktree.js +308 -128
  147. package/lib/config-writer.js +202 -0
  148. package/lib/control-plane.js +236 -0
  149. package/lib/core/runtime-graph.js +946 -0
  150. package/lib/dep-guard/keyword-ripple.js +184 -0
  151. package/lib/deprecated-sync-cleanup.js +362 -0
  152. package/lib/detect-agent.js +2 -28
  153. package/lib/detect-worktree.js +42 -17
  154. package/lib/doc-gate/declaration.js +177 -0
  155. package/lib/doc-gate/detect.js +289 -0
  156. package/lib/doc-gate/gate.js +375 -0
  157. package/lib/doc-gate/okf-config.js +128 -0
  158. package/lib/doc-gate/okf.js +429 -0
  159. package/lib/docs-command.js +1161 -6
  160. package/lib/forge-issues.js +697 -0
  161. package/lib/forge-lock.js +262 -0
  162. package/lib/gate-events.js +193 -0
  163. package/lib/global-flags.js +74 -0
  164. package/lib/greptile-match.js +7 -63
  165. package/lib/harness-capability-matrix.js +380 -0
  166. package/lib/hook-global-installer.js +347 -0
  167. package/lib/hook-renderer.js +451 -0
  168. package/lib/inbox.js +391 -0
  169. package/lib/insights.js +397 -0
  170. package/lib/issue-adapter.js +156 -0
  171. package/lib/issue-backend.js +145 -0
  172. package/lib/issue-render.js +220 -0
  173. package/lib/issue-sync/authority.js +100 -0
  174. package/lib/issue-sync/github-pull.js +184 -0
  175. package/lib/issue-sync/import-primitives.js +98 -0
  176. package/lib/issue-sync/legacy-link-bridge.js +436 -0
  177. package/lib/issue-sync/link-store.js +292 -0
  178. package/lib/issue-sync/project-github.js +123 -0
  179. package/lib/issue-sync/reconcile.js +195 -0
  180. package/lib/issue-sync/schema.js +126 -0
  181. package/lib/kernel/backing-issue.js +305 -0
  182. package/lib/kernel/broker.js +1218 -0
  183. package/lib/kernel/cli-broker-factory.js +130 -0
  184. package/lib/kernel/conflict-signal.js +82 -0
  185. package/lib/kernel/evaluators.js +195 -0
  186. package/lib/kernel/fs-class.js +495 -0
  187. package/lib/kernel/issue-command-contract.js +559 -0
  188. package/lib/kernel/issue-id-resolver.js +186 -0
  189. package/lib/kernel/lease-enforcer.js +158 -0
  190. package/lib/kernel/migrations.js +333 -0
  191. package/lib/kernel/planning-buckets-schema.js +109 -0
  192. package/lib/kernel/projection-jsonl-writer.js +450 -0
  193. package/lib/kernel/readiness-model.js +329 -0
  194. package/lib/kernel/schema.js +356 -0
  195. package/lib/kernel/sqlite-driver.js +2504 -0
  196. package/lib/kernel/taxonomy-validator.js +394 -0
  197. package/lib/lefthook-check.js +8 -4
  198. package/lib/lefthook-wiring.js +413 -0
  199. package/lib/mcp-config-renderer.js +288 -0
  200. package/lib/memory/graphiti-mcp.js +106 -0
  201. package/lib/memory/router.js +387 -0
  202. package/lib/memory/typed-api.js +102 -0
  203. package/lib/memory-digest.js +195 -0
  204. package/lib/merge-rules.js +395 -0
  205. package/lib/migrate-dry-run.js +466 -0
  206. package/lib/orientation.js +863 -0
  207. package/lib/package-manager-remediation.js +103 -0
  208. package/lib/package-root.js +381 -0
  209. package/lib/patch-intent.js +890 -0
  210. package/lib/plugin-catalog.js +3 -4
  211. package/lib/plugin-manager.js +0 -5
  212. package/lib/pr-bundle.js +186 -0
  213. package/lib/pr-monitor/differ.js +195 -0
  214. package/lib/pr-monitor/events.js +0 -0
  215. package/lib/pr-monitor/gather.js +124 -0
  216. package/lib/pr-monitor/journal.js +299 -0
  217. package/lib/pr-monitor/monitor.js +146 -0
  218. package/lib/pr-monitor/render-sticky.js +157 -0
  219. package/lib/pr-monitor/watch-lifecycle.js +95 -0
  220. package/lib/pr-monitor/watch.js +247 -0
  221. package/lib/pr-pull.js +1273 -0
  222. package/lib/pr-shepherd.js +494 -0
  223. package/lib/pr-state-validator.js +59 -0
  224. package/lib/preflight/gates.js +237 -0
  225. package/lib/preflight/runner.js +116 -0
  226. package/lib/project-discovery.js +0 -53
  227. package/lib/project-memory.js +166 -0
  228. package/lib/protected-path-manifest.js +281 -0
  229. package/lib/protected-state-surfaces.js +387 -0
  230. package/lib/release-readiness.js +2089 -0
  231. package/lib/reset.js +59 -45
  232. package/lib/review-adapter.js +68 -0
  233. package/lib/rules-sync.js +260 -0
  234. package/lib/runtime-health.js +332 -23
  235. package/lib/safety-config-renderer.js +268 -0
  236. package/lib/setup-action-log.js +1 -7
  237. package/lib/setup.js +27 -65
  238. package/lib/shell-utils.js +76 -6
  239. package/lib/skills-sync.js +330 -0
  240. package/lib/smart-status/conflicts.js +205 -0
  241. package/lib/smart-status/scoring.js +191 -0
  242. package/lib/status/beads-snapshot.js +145 -0
  243. package/lib/status/presenter.js +216 -0
  244. package/lib/status/snapshot.js +186 -0
  245. package/lib/sync-backend.js +202 -0
  246. package/lib/untrusted-content.js +52 -0
  247. package/lib/upgrade-safety.js +199 -0
  248. package/lib/workflow/enforce-stage.js +298 -47
  249. package/lib/workflow/stage-transition.js +115 -0
  250. package/lib/workflow/stages.js +30 -6
  251. package/lib/workflow/state-manager.js +159 -14
  252. package/lib/workflow/state.js +23 -1
  253. package/lib/workflow-profiles.js +17 -5
  254. package/package.json +46 -36
  255. package/rules/documentation.md +19 -0
  256. package/rules/kernel-tracking.md +26 -0
  257. package/rules/security.md +22 -0
  258. package/rules/tdd.md +20 -0
  259. package/rules/workflow.md +27 -0
  260. package/scripts/auto-backing-issue.js +47 -0
  261. package/scripts/beads-context.sh +165 -22
  262. package/scripts/beads-migrate-to-dolt.sh +7 -0
  263. package/scripts/beads-upgrade-smoke.sh +284 -0
  264. package/scripts/behavioral-judge.sh +115 -11
  265. package/scripts/benchmark.js +349 -63
  266. package/scripts/bootstrap-windows-tools.sh +78 -0
  267. package/scripts/branch-protection.js +2 -3
  268. package/scripts/check-agents.js +34 -137
  269. package/scripts/commitlint.js +3 -1
  270. package/scripts/conflict-detect.sh +3 -0
  271. package/scripts/dep-guard-analyze.js +52 -17
  272. package/scripts/dep-guard-keyword-ripple.js +29 -0
  273. package/scripts/dep-guard-render-review.js +86 -0
  274. package/scripts/dep-guard.sh +64 -232
  275. package/scripts/file-index.sh +3 -0
  276. package/scripts/forge-team/lib/claim.sh +34 -18
  277. package/scripts/forge-team/lib/dashboard.sh +61 -86
  278. package/scripts/forge-team/lib/epic.sh +99 -263
  279. package/scripts/forge-team/lib/hooks.sh +26 -28
  280. package/scripts/forge-team/lib/identity.sh +4 -4
  281. package/scripts/forge-team/lib/sync-github.sh +144 -47
  282. package/scripts/forge-team/lib/verify.sh +93 -83
  283. package/scripts/forge-team/lib/workload.sh +41 -65
  284. package/scripts/forge-team/tests/claim.test.sh +25 -19
  285. package/scripts/forge-team/tests/dashboard.test.sh +31 -46
  286. package/scripts/forge-team/tests/epic.test.sh +52 -71
  287. package/scripts/forge-team/tests/hooks.test.sh +38 -50
  288. package/scripts/forge-team/tests/identity.test.sh +3 -3
  289. package/scripts/forge-team/tests/integration.test.sh +44 -66
  290. package/scripts/forge-team/tests/sync-github.test.sh +183 -79
  291. package/scripts/forge-team/tests/verify.test.sh +37 -46
  292. package/scripts/forge-team/tests/workflow-integration.test.sh +4 -4
  293. package/scripts/forge-team/tests/workload.test.sh +32 -66
  294. package/scripts/gen-command-manifest.js +153 -0
  295. package/scripts/gen-embedded-assets.mjs +129 -0
  296. package/scripts/install.ps1 +139 -0
  297. package/scripts/install.sh +268 -0
  298. package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
  299. package/scripts/lib/release-asset.mjs +84 -0
  300. package/scripts/parity-check.mjs +145 -0
  301. package/scripts/parity-check.test.mjs +58 -0
  302. package/scripts/pin-agentic-workflow-images.js +112 -0
  303. package/scripts/pr-coordinator.sh +3 -0
  304. package/scripts/preflight-sonar.eslint.config.mjs +44 -0
  305. package/scripts/preflight.sh +108 -0
  306. package/scripts/protected-state-check.js +104 -0
  307. package/scripts/smart-status-score.js +31 -0
  308. package/scripts/smart-status-sessions.js +51 -0
  309. package/scripts/smart-status.sh +117 -369
  310. package/scripts/spikes/config-race-bench.js +111 -0
  311. package/scripts/spikes/harness-capability-matrix.js +13 -0
  312. package/scripts/spikes/patch-anchor-stability-bench.js +125 -0
  313. package/scripts/spikes/protected-path-manifest.js +20 -0
  314. package/scripts/spikes/skill-auto-invoke-parity.js +292 -0
  315. package/scripts/sync-agent-skills.js +62 -0
  316. package/scripts/sync-agentic-workflow.js +48 -0
  317. package/scripts/sync-utils.sh +3 -0
  318. package/scripts/test-ci-shard.js +251 -0
  319. package/scripts/test-dashboard.js +188 -52
  320. package/scripts/test-full-suite.js +186 -0
  321. package/scripts/test-profile.js +278 -0
  322. package/scripts/test.js +302 -28
  323. package/scripts/validate.js +143 -0
  324. package/scripts/validate.sh +18 -1
  325. package/skills/claim-safety/SKILL.md +102 -0
  326. package/skills/claim-safety/evals/evals.json +46 -0
  327. package/{.github/prompts/dev.prompt.md → skills/dev/SKILL.md} +46 -52
  328. package/skills/dev/evals/evals.json +50 -0
  329. package/skills/hermes-forge/SKILL.md +185 -0
  330. package/skills/hermes-forge/evals/evals.json +46 -0
  331. package/skills/issue-basics/SKILL.md +111 -0
  332. package/skills/issue-basics/evals/evals.json +46 -0
  333. package/skills/kernel/SKILL.md +166 -0
  334. package/skills/kernel/evals/evals.json +50 -0
  335. package/skills/memory/SKILL.md +102 -0
  336. package/skills/parallel-deep-research/SKILL.md +14 -11
  337. package/skills/parallel-deep-research/evals/evals.json +11 -27
  338. package/{.github/prompts/plan.prompt.md → skills/plan/SKILL.md} +134 -159
  339. package/skills/plan/evals/evals.json +42 -0
  340. package/skills/research/SKILL.md +195 -0
  341. package/skills/research/evals/evals.json +42 -0
  342. package/{.github/prompts/review.prompt.md → skills/review/SKILL.md} +98 -62
  343. package/skills/review/evals/evals.json +42 -0
  344. package/skills/rollback/SKILL.md +110 -0
  345. package/skills/rollback/evals/evals.json +46 -0
  346. package/skills/rollback/references/methods.md +204 -0
  347. package/{.cursor/commands/rollback.md → skills/rollback/references/workflow-integration.md} +10 -284
  348. package/skills/shepherd/SKILL.md +66 -0
  349. package/skills/shepherd/evals/evals.json +42 -0
  350. package/skills/ship/SKILL.md +251 -0
  351. package/skills/ship/evals/evals.json +42 -0
  352. package/skills/smith/SKILL.md +142 -0
  353. package/skills/smith/evals/evals.json +46 -0
  354. package/skills/smith/references/autonomy-and-gates.md +94 -0
  355. package/{.github/prompts/sonarcloud.prompt.md → skills/sonarcloud/SKILL.md} +14 -3
  356. package/skills/sonarcloud/evals/evals.json +46 -0
  357. package/skills/sonarcloud-analysis/SKILL.md +18 -13
  358. package/skills/sonarcloud-analysis/evals/evals.json +11 -15
  359. package/skills/status/SKILL.md +102 -0
  360. package/skills/status/evals/evals.json +50 -0
  361. package/skills/triage-ready/SKILL.md +121 -0
  362. package/skills/triage-ready/evals/evals.json +42 -0
  363. package/{.github/prompts/validate.prompt.md → skills/validate/SKILL.md} +52 -29
  364. package/skills/validate/evals/evals.json +42 -0
  365. package/skills/verify/SKILL.md +299 -0
  366. package/skills/verify/evals/evals.json +50 -0
  367. package/.claude/commands/dev.md +0 -345
  368. package/.claude/commands/plan.md +0 -566
  369. package/.claude/commands/premerge.md +0 -186
  370. package/.claude/commands/research.md +0 -42
  371. package/.claude/commands/review.md +0 -451
  372. package/.claude/commands/rollback.md +0 -721
  373. package/.claude/commands/ship.md +0 -213
  374. package/.claude/commands/sonarcloud.md +0 -152
  375. package/.claude/commands/status.md +0 -90
  376. package/.claude/commands/validate.md +0 -288
  377. package/.claude/commands/verify.md +0 -269
  378. package/.claude/rules/workflow.md +0 -121
  379. package/.cline/workflows/dev.md +0 -342
  380. package/.cline/workflows/plan.md +0 -563
  381. package/.cline/workflows/premerge.md +0 -183
  382. package/.cline/workflows/research.md +0 -39
  383. package/.cline/workflows/review.md +0 -448
  384. package/.cline/workflows/rollback.md +0 -718
  385. package/.cline/workflows/ship.md +0 -210
  386. package/.cline/workflows/sonarcloud.md +0 -146
  387. package/.cline/workflows/status.md +0 -87
  388. package/.cline/workflows/validate.md +0 -285
  389. package/.cline/workflows/verify.md +0 -266
  390. package/.codex/config.toml +0 -11
  391. package/.codex/skills/dev/SKILL.md +0 -345
  392. package/.codex/skills/plan/SKILL.md +0 -566
  393. package/.codex/skills/premerge/SKILL.md +0 -186
  394. package/.codex/skills/research/SKILL.md +0 -42
  395. package/.codex/skills/review/SKILL.md +0 -451
  396. package/.codex/skills/rollback/SKILL.md +0 -721
  397. package/.codex/skills/ship/SKILL.md +0 -213
  398. package/.codex/skills/sonarcloud/SKILL.md +0 -149
  399. package/.codex/skills/status/SKILL.md +0 -90
  400. package/.codex/skills/validate/SKILL.md +0 -288
  401. package/.codex/skills/verify/SKILL.md +0 -269
  402. package/.cursor/commands/dev.md +0 -342
  403. package/.cursor/commands/plan.md +0 -563
  404. package/.cursor/commands/premerge.md +0 -183
  405. package/.cursor/commands/research.md +0 -39
  406. package/.cursor/commands/review.md +0 -448
  407. package/.cursor/commands/ship.md +0 -210
  408. package/.cursor/commands/sonarcloud.md +0 -146
  409. package/.cursor/commands/status.md +0 -87
  410. package/.cursor/commands/validate.md +0 -285
  411. package/.cursor/commands/verify.md +0 -266
  412. package/.cursorrules +0 -149
  413. package/.github/prompts/premerge.prompt.md +0 -188
  414. package/.github/prompts/research.prompt.md +0 -44
  415. package/.github/prompts/rollback.prompt.md +0 -723
  416. package/.github/prompts/ship.prompt.md +0 -215
  417. package/.github/prompts/status.prompt.md +0 -92
  418. package/.github/prompts/verify.prompt.md +0 -271
  419. package/.github/workflows/beads-to-github.yml +0 -56
  420. package/.github/workflows/github-to-beads.yml +0 -97
  421. package/.kilocode/workflows/dev.md +0 -346
  422. package/.kilocode/workflows/plan.md +0 -567
  423. package/.kilocode/workflows/premerge.md +0 -187
  424. package/.kilocode/workflows/research.md +0 -43
  425. package/.kilocode/workflows/review.md +0 -452
  426. package/.kilocode/workflows/rollback.md +0 -722
  427. package/.kilocode/workflows/ship.md +0 -214
  428. package/.kilocode/workflows/sonarcloud.md +0 -150
  429. package/.kilocode/workflows/status.md +0 -91
  430. package/.kilocode/workflows/validate.md +0 -289
  431. package/.kilocode/workflows/verify.md +0 -270
  432. package/.opencode/commands/dev.md +0 -345
  433. package/.opencode/commands/plan.md +0 -566
  434. package/.opencode/commands/premerge.md +0 -186
  435. package/.opencode/commands/research.md +0 -42
  436. package/.opencode/commands/review.md +0 -451
  437. package/.opencode/commands/rollback.md +0 -721
  438. package/.opencode/commands/ship.md +0 -213
  439. package/.opencode/commands/sonarcloud.md +0 -149
  440. package/.opencode/commands/status.md +0 -90
  441. package/.opencode/commands/validate.md +0 -288
  442. package/.opencode/commands/verify.md +0 -269
  443. package/.roo/commands/dev.md +0 -346
  444. package/.roo/commands/plan.md +0 -567
  445. package/.roo/commands/premerge.md +0 -187
  446. package/.roo/commands/research.md +0 -43
  447. package/.roo/commands/review.md +0 -452
  448. package/.roo/commands/rollback.md +0 -722
  449. package/.roo/commands/ship.md +0 -214
  450. package/.roo/commands/sonarcloud.md +0 -150
  451. package/.roo/commands/status.md +0 -91
  452. package/.roo/commands/validate.md +0 -289
  453. package/.roo/commands/verify.md +0 -270
  454. package/docs/BEADS_GITHUB_SYNC.md +0 -255
  455. package/docs/GREPTILE_SETUP.md +0 -400
  456. package/docs/MANUAL_REVIEW_GUIDE.md +0 -106
  457. package/docs/SETUP.md +0 -663
  458. package/docs/VALIDATION.md +0 -363
  459. package/lib/agents/cline.plugin.json +0 -29
  460. package/lib/agents/copilot.plugin.json +0 -24
  461. package/lib/agents/kilocode.plugin.json +0 -22
  462. package/lib/agents/opencode.plugin.json +0 -23
  463. package/lib/agents/roo.plugin.json +0 -30
  464. package/lib/beads-health-check.js +0 -143
  465. package/lib/commands/commands-reset.js +0 -147
  466. package/opencode.json +0 -67
  467. package/scripts/beads-context.test.js +0 -567
  468. package/scripts/github-beads-sync/comment.mjs +0 -64
  469. package/scripts/github-beads-sync/config.mjs +0 -148
  470. package/scripts/github-beads-sync/github-api.mjs +0 -131
  471. package/scripts/github-beads-sync/index.mjs +0 -332
  472. package/scripts/github-beads-sync/label-mapper.mjs +0 -54
  473. package/scripts/github-beads-sync/mapping.mjs +0 -78
  474. package/scripts/github-beads-sync/reverse-sync-cli.mjs +0 -31
  475. package/scripts/github-beads-sync/reverse-sync.mjs +0 -138
  476. package/scripts/github-beads-sync/run-bd.mjs +0 -161
  477. package/scripts/github-beads-sync/sanitize.mjs +0 -121
  478. package/scripts/github-beads-sync.config.json +0 -26
  479. package/scripts/sync-commands.js +0 -600
@@ -0,0 +1,2504 @@
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
+ function upsertWorktreeRow(runtime, db, input) {
1117
+ const existing = loadWorktreeRowByPath(runtime, db, input.path);
1118
+ const row = {
1119
+ id: input.id || existing?.id || randomUUID(),
1120
+ git_common_dir: input.git_common_dir,
1121
+ path: input.path,
1122
+ branch: input.branch,
1123
+ actor: input.actor ?? null,
1124
+ issue_id: input.issue_id ?? null,
1125
+ work_folder: input.work_folder ?? null,
1126
+ registered_at: input.registered_at || new Date().toISOString(),
1127
+ state: input.state || 'active',
1128
+ };
1129
+ if (existing) {
1130
+ runParams(
1131
+ runtime,
1132
+ db,
1133
+ 'UPDATE kernel_worktrees SET git_common_dir = ?, branch = ?, actor = ?, issue_id = ?, work_folder = ?, registered_at = ?, state = ? WHERE id = ?',
1134
+ [row.git_common_dir, row.branch, row.actor, row.issue_id, row.work_folder, row.registered_at, row.state, row.id],
1135
+ );
1136
+ } else {
1137
+ const placeholders = KERNEL_WORKTREE_COLUMNS.map(() => '?').join(', ');
1138
+ runParams(
1139
+ runtime,
1140
+ db,
1141
+ `INSERT INTO kernel_worktrees (${KERNEL_WORKTREE_COLUMNS.join(', ')}) VALUES (${placeholders})`,
1142
+ KERNEL_WORKTREE_COLUMNS.map(column => row[column]),
1143
+ );
1144
+ }
1145
+ return row;
1146
+ }
1147
+
1148
+ function listWorktreeRows(runtime, db, filter = {}) {
1149
+ if (filter && filter.state) {
1150
+ return safeAll(
1151
+ runtime,
1152
+ db,
1153
+ 'SELECT * FROM kernel_worktrees WHERE state = ? ORDER BY registered_at DESC',
1154
+ [filter.state],
1155
+ );
1156
+ }
1157
+ return safeAll(runtime, db, 'SELECT * FROM kernel_worktrees ORDER BY registered_at DESC');
1158
+ }
1159
+
1160
+ // --- Stage-run registry (f61601ab). kernel_stage_runs records the REAL workflow
1161
+ // phase per issue so the dashboard/`show` read the phase instead of guessing it
1162
+ // from status+claim (a claimed-open issue with a merged PR would otherwise still
1163
+ // show "dev"). Like kernel_worktrees this is a plain authority registry written
1164
+ // DIRECTLY (not event-sourced): a stage row is keyed by (issue_id, stage) and the
1165
+ // write is idempotent per that pair — re-starting a stage UPDATEs in place instead
1166
+ // of duplicating. `start` opens an active row (started_at, completed_at NULL);
1167
+ // `complete` stamps completed_at + status='done' on that same row.
1168
+ const KERNEL_STAGE_RUN_COLUMNS = Object.freeze([
1169
+ 'id',
1170
+ 'issue_id',
1171
+ 'stage',
1172
+ 'substage',
1173
+ 'status',
1174
+ 'started_at',
1175
+ 'completed_at',
1176
+ 'evidence_id',
1177
+ ]);
1178
+
1179
+ function loadStageRunRow(runtime, db, issueId, stage) {
1180
+ if (!issueId || !stage) return null;
1181
+ const rows = safeAll(
1182
+ runtime,
1183
+ db,
1184
+ 'SELECT * FROM kernel_stage_runs WHERE issue_id = ? AND stage = ? ORDER BY started_at DESC LIMIT 1',
1185
+ [issueId, stage],
1186
+ );
1187
+ return rows[0] || null;
1188
+ }
1189
+
1190
+ // Idempotent per (issue_id, stage). action 'start' opens/keeps an active row;
1191
+ // action 'complete' stamps completed_at + status='done' (creating the row first
1192
+ // if the stage was never explicitly started, so a bare `complete` still records
1193
+ // that the stage ran).
1194
+ function recordStageRunRow(runtime, db, input) {
1195
+ const stage = input.stage;
1196
+ if (!input.issue_id || !stage) {
1197
+ throw new Error('recordStageRun requires issue_id and stage');
1198
+ }
1199
+ const action = input.action || 'start';
1200
+ if (action !== 'start' && action !== 'complete') {
1201
+ throw new Error(`recordStageRun: unknown action "${action}" (expected start|complete)`);
1202
+ }
1203
+ const now = input.now || new Date().toISOString();
1204
+ const existing = loadStageRunRow(runtime, db, input.issue_id, stage);
1205
+
1206
+ if (action === 'start') {
1207
+ if (existing) {
1208
+ // Re-start is idempotent: keep the id + original started_at, ensure the row
1209
+ // is active again (supports a rework loop re-opening a completed stage).
1210
+ const row = {
1211
+ ...existing,
1212
+ substage: input.substage ?? existing.substage ?? null,
1213
+ status: 'active',
1214
+ completed_at: null,
1215
+ evidence_id: input.evidence_id ?? existing.evidence_id ?? null,
1216
+ };
1217
+ runParams(
1218
+ runtime,
1219
+ db,
1220
+ 'UPDATE kernel_stage_runs SET substage = ?, status = ?, completed_at = ?, evidence_id = ? WHERE id = ?',
1221
+ [row.substage, row.status, row.completed_at, row.evidence_id, row.id],
1222
+ );
1223
+ return row;
1224
+ }
1225
+ const row = {
1226
+ id: input.id || randomUUID(),
1227
+ issue_id: input.issue_id,
1228
+ stage,
1229
+ substage: input.substage ?? null,
1230
+ status: 'active',
1231
+ started_at: input.started_at || now,
1232
+ completed_at: null,
1233
+ evidence_id: input.evidence_id ?? null,
1234
+ };
1235
+ const placeholders = KERNEL_STAGE_RUN_COLUMNS.map(() => '?').join(', ');
1236
+ runParams(
1237
+ runtime,
1238
+ db,
1239
+ `INSERT INTO kernel_stage_runs (${KERNEL_STAGE_RUN_COLUMNS.join(', ')}) VALUES (${placeholders})`,
1240
+ KERNEL_STAGE_RUN_COLUMNS.map(column => row[column]),
1241
+ );
1242
+ return row;
1243
+ }
1244
+
1245
+ // action === 'complete'
1246
+ if (existing) {
1247
+ const row = {
1248
+ ...existing,
1249
+ substage: input.substage ?? existing.substage ?? null,
1250
+ status: 'done',
1251
+ completed_at: now,
1252
+ evidence_id: input.evidence_id ?? existing.evidence_id ?? null,
1253
+ };
1254
+ runParams(
1255
+ runtime,
1256
+ db,
1257
+ 'UPDATE kernel_stage_runs SET substage = ?, status = ?, completed_at = ?, evidence_id = ? WHERE id = ?',
1258
+ [row.substage, row.status, row.completed_at, row.evidence_id, row.id],
1259
+ );
1260
+ return row;
1261
+ }
1262
+ const row = {
1263
+ id: input.id || randomUUID(),
1264
+ issue_id: input.issue_id,
1265
+ stage,
1266
+ substage: input.substage ?? null,
1267
+ status: 'done',
1268
+ started_at: input.started_at || now,
1269
+ completed_at: now,
1270
+ evidence_id: input.evidence_id ?? null,
1271
+ };
1272
+ const placeholders = KERNEL_STAGE_RUN_COLUMNS.map(() => '?').join(', ');
1273
+ runParams(
1274
+ runtime,
1275
+ db,
1276
+ `INSERT INTO kernel_stage_runs (${KERNEL_STAGE_RUN_COLUMNS.join(', ')}) VALUES (${placeholders})`,
1277
+ KERNEL_STAGE_RUN_COLUMNS.map(column => row[column]),
1278
+ );
1279
+ return row;
1280
+ }
1281
+
1282
+ // Atomic stage transition: complete the `from` stage and start the `to` stage as
1283
+ // ONE all-or-nothing write. Auto-recording from a `stage: <from> -> <to>` comment
1284
+ // (5a5ba3a6) used to issue these as two separate recordStageRun calls at the caller;
1285
+ // if the second threw, the first had already persisted — leaving a half-transition
1286
+ // (from marked done, to never started) so `current_stage` was wrong. Wrapping both
1287
+ // writes in a single BEGIN IMMEDIATE transaction makes a mid-transition failure roll
1288
+ // back cleanly. Best-effort/non-blocking is the CALLER's contract; this method still
1289
+ // throws on failure so the caller can observe it (and roll back has happened).
1290
+ function recordStageTransitionRow(runtime, db, input) {
1291
+ const issueId = input && input.issue_id;
1292
+ const from = input && input.from;
1293
+ const to = input && input.to;
1294
+ if (!issueId || !from || !to) {
1295
+ throw new Error('recordStageTransition requires issue_id, from, and to');
1296
+ }
1297
+ const now = input.now || new Date().toISOString();
1298
+ execSql(runtime, db, 'BEGIN IMMEDIATE;');
1299
+ try {
1300
+ const completed = recordStageRunRow(runtime, db, {
1301
+ issue_id: issueId, stage: from, action: 'complete', now,
1302
+ });
1303
+ const started = recordStageRunRow(runtime, db, {
1304
+ issue_id: issueId, stage: to, action: 'start', now,
1305
+ });
1306
+ execSql(runtime, db, 'COMMIT;');
1307
+ return { from: completed, to: started };
1308
+ } catch (error) {
1309
+ try {
1310
+ execSql(runtime, db, 'ROLLBACK;');
1311
+ } catch {
1312
+ // A rollback failure must not mask the original transition error.
1313
+ }
1314
+ throw error;
1315
+ }
1316
+ }
1317
+
1318
+ function listStageRunRows(runtime, db, issueId) {
1319
+ if (!issueId) return [];
1320
+ // Deterministic order: started_at first, then rowid (the implicit INSERT sequence)
1321
+ // as a STABLE tie-break. Fast callers can mint two runs in the same millisecond, so
1322
+ // ordering by started_at alone leaves ties undefined (differs local vs CI vs OS).
1323
+ // rowid gives intuitive insertion order for a history list; the random-UUID `id`
1324
+ // would not reflect insertion order, so it is unsuitable as the tie-break.
1325
+ return safeAll(
1326
+ runtime,
1327
+ db,
1328
+ 'SELECT * FROM kernel_stage_runs WHERE issue_id = ? ORDER BY started_at ASC, rowid ASC',
1329
+ [issueId],
1330
+ );
1331
+ }
1332
+
1333
+ // Current stage = latest ACTIVE run (completed_at IS NULL) by started_at; when none
1334
+ // is active, the latest COMPLETED run by completed_at. Returns null when the issue
1335
+ // has no stage runs (caller falls back to the status+claim heuristic).
1336
+ function loadCurrentStageRunRow(runtime, db, issueId) {
1337
+ const rows = listStageRunRows(runtime, db, issueId);
1338
+ if (rows.length === 0) return null;
1339
+ const active = rows.filter(row => !row.completed_at);
1340
+ if (active.length > 0) {
1341
+ return active.reduce((latest, row) => (String(row.started_at) >= String(latest.started_at) ? row : latest));
1342
+ }
1343
+ return rows.reduce((latest, row) => (
1344
+ String(row.completed_at || row.started_at) >= String(latest.completed_at || latest.started_at) ? row : latest
1345
+ ));
1346
+ }
1347
+
1348
+ // Columns the issue upsert may set from an accepted event payload. id/title are
1349
+ // required for a create; the rest are optional and only overwritten when present.
1350
+ const ISSUE_MUTABLE_COLUMNS = Object.freeze([
1351
+ 'title',
1352
+ 'body',
1353
+ 'type',
1354
+ 'status',
1355
+ 'priority',
1356
+ 'priority_rank',
1357
+ 'parent_id',
1358
+ 'sprint_id',
1359
+ 'release_id',
1360
+ 'stage_state',
1361
+ 'labels',
1362
+ 'acceptance_criteria',
1363
+ 'estimate',
1364
+ // KAP-10 (design/notes) + KAP-11 (assignee): persisted on create AND update via
1365
+ // the same assignment loop. assignee is the persistent owner, distinct from the
1366
+ // transient kernel_claims lease.
1367
+ 'design',
1368
+ 'notes',
1369
+ 'assignee',
1370
+ // Author, close timestamp + raw close reason, and a verbatim metadata JSON blob.
1371
+ // The importer sets these explicitly on an issue event payload; a native CLI close
1372
+ // also auto-fills closed_at/close_reason from the close event (9197b0c8) — explicit
1373
+ // payload values always win, preserving import fidelity.
1374
+ 'created_by',
1375
+ 'closed_at',
1376
+ 'close_reason',
1377
+ 'metadata',
1378
+ ]);
1379
+
1380
+ // close drives the issue to a terminal status; an explicit payload.status (rework
1381
+ // transitions) still wins so the broker can model any accepted lifecycle move.
1382
+ function resolveMutationStatus(eventType, payload) {
1383
+ if (typeof payload.status === 'string' && payload.status) return payload.status;
1384
+ if (eventType === 'issue.close') return 'done';
1385
+ return null;
1386
+ }
1387
+
1388
+ // KAP-8: after a close COMMITS the issue to a terminal status, compute the issues
1389
+ // that become newly READY because this issue is now done. This is a LOCALIZED
1390
+ // post-write read on the SAME connection/transaction — the issue row is already
1391
+ // `done`, so loadBoardReadiness sees the post-close state. We restrict the result
1392
+ // to DIRECT dependents of the closed issue (kernel_dependencies rows where
1393
+ // blocks_issue_id === closedId) whose readiness now flips to ready (the closed
1394
+ // blocker is terminal and dropped, and they carry no OTHER live blocker). Sorted
1395
+ // for a deterministic response.
1396
+ function computeNewlyUnblocked(runtime, db, closedIssueId, context = {}) {
1397
+ const dependents = safeAll(
1398
+ runtime,
1399
+ db,
1400
+ 'SELECT DISTINCT issue_id FROM kernel_dependencies WHERE blocks_issue_id = ?',
1401
+ [closedIssueId],
1402
+ ).map(row => row.issue_id).filter(Boolean);
1403
+ if (dependents.length === 0) return [];
1404
+ const { index } = loadBoardReadiness(runtime, db, context);
1405
+ return dependents
1406
+ .filter(id => Boolean(index.readinessById[id]?.ready))
1407
+ .sort((a, b) => String(a).localeCompare(String(b)));
1408
+ }
1409
+
1410
+ // Upsert the issue row for an accepted issue event and bump entity_revision. The
1411
+ // evaluator already enforced CAS (expected_revision === stored), so the new
1412
+ // revision is monotonic: stored + 1 for an update, 0 for a fresh create.
1413
+ function applyAcceptedIssueEvent(runtime, db, event, context = {}) {
1414
+ const payload = event.payload || (event.payload_json ? JSON.parse(event.payload_json) : {});
1415
+ const issueId = event.entity_id;
1416
+ const now = event.created_at;
1417
+ const existing = loadKernelEntityRow(runtime, db, 'issue', issueId);
1418
+ const status = resolveMutationStatus(event.event_type, payload);
1419
+
1420
+ // 9197b0c8: a native close must persist its OWN close metadata. The importer
1421
+ // supplies closed_at/close_reason explicitly, but a CLI `close --reason` only
1422
+ // carries the event-payload `reason` — both COLUMNS stayed NULL and every real
1423
+ // close failed gate.issue_verify's read-back. Stamp the columns from the close
1424
+ // event (explicit payload values, e.g. import fidelity, still win).
1425
+ if (event.event_type === 'issue.close') {
1426
+ if (payload.closed_at === undefined || payload.closed_at === null) {
1427
+ payload.closed_at = now;
1428
+ }
1429
+ if (
1430
+ (payload.close_reason === undefined || payload.close_reason === null)
1431
+ && typeof payload.reason === 'string' && payload.reason
1432
+ ) {
1433
+ payload.close_reason = payload.reason;
1434
+ }
1435
+ }
1436
+
1437
+ if (!existing) {
1438
+ // Fresh create: seed required NOT NULL columns, then overwrite with any
1439
+ // supplied payload values via the shared column map below. priority_rank is
1440
+ // DERIVED from the (possibly defaulted) priority LABEL so a no-`--priority`
1441
+ // create still sorts by its P2 default — seeding rank 0 would otherwise rank the
1442
+ // common default-priority issue ABOVE an explicit P1 in `list` (priority order
1443
+ // inverted). The CLI/broker already supplies priority_rank when --priority is
1444
+ // given, so this fallback only fires for the defaulted/raw-event path.
1445
+ const priorityLabel = payload.priority ?? 'P2';
1446
+ runParams(
1447
+ runtime,
1448
+ db,
1449
+ `INSERT INTO kernel_issues (id, title, type, status, priority, priority_rank, created_at, updated_at, entity_revision)
1450
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`,
1451
+ [
1452
+ issueId,
1453
+ payload.title ?? issueId,
1454
+ payload.type ?? 'task',
1455
+ status ?? payload.status ?? 'open',
1456
+ priorityLabel,
1457
+ Number(payload.priority_rank ?? rankForPriorityLabel(priorityLabel)),
1458
+ now,
1459
+ now,
1460
+ ],
1461
+ );
1462
+ }
1463
+
1464
+ const assignments = [];
1465
+ const values = [];
1466
+ for (const column of ISSUE_MUTABLE_COLUMNS) {
1467
+ const value = column === 'status' ? status : payload[column];
1468
+ if (value === undefined || value === null) continue;
1469
+ assignments.push(`${column} = ?`);
1470
+ // KAP-4: labels arrive as a string[] (broker parseLabelFlag). SQLite cannot
1471
+ // bind an array param, so persist as JSON-array TEXT — the canonical form
1472
+ // parseLabels reads back on the read side. Every other column binds as-is.
1473
+ values.push(column === 'labels' ? JSON.stringify(value) : value);
1474
+ }
1475
+ assignments.push('updated_at = ?');
1476
+ values.push(now);
1477
+ // Monotonic CAS bump: increment the stored revision on every accepted write
1478
+ // (a create stays at 0 because the INSERT seeded 0 and this UPDATE runs once).
1479
+ const expectedRevision = Number(existing ? existing.entity_revision || 0 : 0);
1480
+ const nextRevision = existing ? expectedRevision + 1 : 0;
1481
+ assignments.push('entity_revision = ?');
1482
+ values.push(nextRevision);
1483
+ if (existing) {
1484
+ // Optimistic CAS at the row write for revision-bumping mutations (update/close).
1485
+ // The evaluator pre-reads the entity OUTSIDE this transaction, so two writers
1486
+ // that both pre-read rev=N both pass the evaluator; BEGIN IMMEDIATE then
1487
+ // serializes them and the second would otherwise apply on top of N+1 — a silent
1488
+ // lost update. Gate the WHERE on the event's expected_revision: if the row's
1489
+ // actual revision has moved (0 rows changed), throw a tagged conflict the broker
1490
+ // converts into a stale_revision quarantine. A create INSERTs a fresh row (the PK
1491
+ // guards it) so it takes the un-gated path below.
1492
+ const result = runParams(
1493
+ runtime,
1494
+ db,
1495
+ `UPDATE kernel_issues SET ${assignments.join(', ')} WHERE id = ? AND entity_revision = ?`,
1496
+ [...values, issueId, Number(event.expected_revision || 0)],
1497
+ );
1498
+ // Both runtimes' run() return a changed-row count (bun:sqlite .changes;
1499
+ // node:sqlite StatementSync.run() → { changes, lastInsertRowid }). 0 changes
1500
+ // means the CAS predicate (entity_revision = expected) matched no row.
1501
+ if (Number(result?.changes || 0) === 0) {
1502
+ const error = new Error('kernel issue revision conflict');
1503
+ // Driver-supplied TYPED conflict signal (issues 89bf8930 / d4ce47bb): the
1504
+ // broker branches on this code, never on error text. kernelRevisionConflict
1505
+ // is retained as the structural marker classifyConflictSignal also honors.
1506
+ error.conflictSignal = CONFLICT_SIGNAL.CAS_STALE;
1507
+ error.kernelRevisionConflict = true;
1508
+ error.entityId = issueId;
1509
+ error.expectedRevision = Number(event.expected_revision || 0);
1510
+ error.actualRevision = expectedRevision;
1511
+ throw error;
1512
+ }
1513
+ return finalizeIssueMutation(runtime, db, event, issueId, nextRevision, context);
1514
+ }
1515
+ runParams(
1516
+ runtime,
1517
+ db,
1518
+ `UPDATE kernel_issues SET ${assignments.join(', ')} WHERE id = ?`,
1519
+ [...values, issueId],
1520
+ );
1521
+ return finalizeIssueMutation(runtime, db, event, issueId, nextRevision, context);
1522
+ }
1523
+
1524
+ // Build the issue-mutation summary, attaching KAP-8 newly_unblocked for a close.
1525
+ // The issue row is already at its terminal status here (the UPDATE above committed
1526
+ // on this connection), so computeNewlyUnblocked sees the post-close readiness.
1527
+ function finalizeIssueMutation(runtime, db, event, issueId, revision, context) {
1528
+ const summary = { id: issueId, revision };
1529
+ if (event.event_type === 'issue.close') {
1530
+ summary.newly_unblocked = computeNewlyUnblocked(runtime, db, issueId, context);
1531
+ }
1532
+ return summary;
1533
+ }
1534
+
1535
+ // Append a comment row for an accepted issue.comment event.
1536
+ function applyAcceptedCommentEvent(runtime, db, event) {
1537
+ const payload = event.payload || (event.payload_json ? JSON.parse(event.payload_json) : {});
1538
+ const commentId = payload.comment_id || randomUUID();
1539
+ runParams(
1540
+ runtime,
1541
+ db,
1542
+ `INSERT INTO kernel_comments (id, issue_id, body, actor, visibility, created_at)
1543
+ VALUES (?, ?, ?, ?, ?, ?)`,
1544
+ [
1545
+ commentId,
1546
+ payload.issue_id ?? event.entity_id,
1547
+ payload.body ?? '',
1548
+ event.actor ?? payload.actor ?? 'forge',
1549
+ payload.visibility ?? 'local',
1550
+ event.created_at,
1551
+ ],
1552
+ );
1553
+ // A comment never bumps the issue revision; report the host issue's current one.
1554
+ const issue = loadKernelEntityRow(runtime, db, 'issue', payload.issue_id ?? event.entity_id);
1555
+ return { id: payload.issue_id ?? event.entity_id, revision: Number(issue?.entity_revision || 0), comment_id: commentId };
1556
+ }
1557
+
1558
+ // Insert the dependency edge for an accepted dependency.add event. The event's
1559
+ // entity_id IS the dependency row id (the broker scopes the event on the
1560
+ // 'dependency' entity stream), so the row is uniquely keyed without minting a new
1561
+ // id. This is the ONLY place dependency rows are written — the cycle guard already
1562
+ // fired in the evaluator before this accepted event reached the commit.
1563
+ function applyAcceptedDependencyAddEvent(runtime, db, event) {
1564
+ const payload = event.payload || (event.payload_json ? JSON.parse(event.payload_json) : {});
1565
+ const dependencyId = event.entity_id;
1566
+ runParams(
1567
+ runtime,
1568
+ db,
1569
+ `INSERT INTO kernel_dependencies (id, issue_id, blocks_issue_id, dependency_type, created_at)
1570
+ VALUES (?, ?, ?, ?, ?)`,
1571
+ [
1572
+ dependencyId,
1573
+ payload.issue_id,
1574
+ payload.blocks_issue_id,
1575
+ payload.dependency_type || 'blocks',
1576
+ event.created_at,
1577
+ ],
1578
+ );
1579
+ return { id: dependencyId, revision: 0, dependency_id: dependencyId };
1580
+ }
1581
+
1582
+ // Delete the dependency edge for an accepted dependency.remove event, keyed by the
1583
+ // (issue_id, blocks_issue_id) pair the payload names — the dependent's id is not
1584
+ // the dependency row id, so delete by the edge endpoints, not entity_id.
1585
+ function applyAcceptedDependencyRemoveEvent(runtime, db, event) {
1586
+ const payload = event.payload || (event.payload_json ? JSON.parse(event.payload_json) : {});
1587
+ runParams(
1588
+ runtime,
1589
+ db,
1590
+ 'DELETE FROM kernel_dependencies WHERE issue_id = ? AND blocks_issue_id = ?',
1591
+ [payload.issue_id, payload.blocks_issue_id],
1592
+ );
1593
+ return { id: event.entity_id, revision: 0, dependency_id: event.entity_id };
1594
+ }
1595
+
1596
+ // Clear the active lease for an accepted claim.release event. Conservatively
1597
+ // releases the issue's active lease (the required ownership model is same-actor
1598
+ // "release clears it"; cross-owner authorization is deliberately out of scope).
1599
+ // claim.create is NOT handled here — its lease row is inserted by the broker's
1600
+ // insertKernelClaim inside commitGuardedAccept; re-inserting it here would be a
1601
+ // double-INSERT that trips the partial-UNIQUE index.
1602
+ function applyAcceptedClaimReleaseEvent(runtime, db, event) {
1603
+ const payload = event.payload || (event.payload_json ? JSON.parse(event.payload_json) : {});
1604
+ runParams(
1605
+ runtime,
1606
+ db,
1607
+ "UPDATE kernel_claims SET state = 'released' WHERE issue_id = ? AND state = 'active'",
1608
+ [payload.issue_id],
1609
+ );
1610
+ return { id: event.entity_id, revision: 0, claim_id: event.entity_id };
1611
+ }
1612
+
1613
+ // Apply an accepted event's authority-table effect. Returns the mutation summary
1614
+ // ({id, revision, comment_id?/dependency_id?/claim_id?}) the broker threads back
1615
+ // into the issue-command response, or null for events with no synchronous
1616
+ // side effect here (claim.create's lease is written by the broker's
1617
+ // insertKernelClaim inside the transaction). The entity_type guard is critical:
1618
+ // dependency/claim events must NEVER fall into the issue-upsert branch, which
1619
+ // would corrupt kernel_issues with a bogus row keyed by the dep/claim id.
1620
+ function applyAcceptedMutation(runtime, db, event, context = {}) {
1621
+ if (event.entity_type === 'dependency') {
1622
+ if (event.event_type === 'dependency.remove') {
1623
+ return applyAcceptedDependencyRemoveEvent(runtime, db, event);
1624
+ }
1625
+ return applyAcceptedDependencyAddEvent(runtime, db, event);
1626
+ }
1627
+ if (event.entity_type === 'claim') {
1628
+ if (event.event_type === 'claim.release') {
1629
+ return applyAcceptedClaimReleaseEvent(runtime, db, event);
1630
+ }
1631
+ // claim.create: the lease row is written by the broker's insertKernelClaim
1632
+ // inside commitGuardedAccept; no authority-table effect to apply here.
1633
+ return null;
1634
+ }
1635
+ if (event.entity_type === 'issue' && event.event_type === 'issue.comment') {
1636
+ return applyAcceptedCommentEvent(runtime, db, event);
1637
+ }
1638
+ if (event.entity_type === 'issue') {
1639
+ // context carries now/actor so KAP-8's post-close readiness recompute uses the
1640
+ // same clock/actor the rest of the guarded path did.
1641
+ return applyAcceptedIssueEvent(runtime, db, event, context);
1642
+ }
1643
+ return null;
1644
+ }
1645
+
1646
+ // --- Faithful import write path (beads → kernel) ------------------------------
1647
+ // Direct authority-table writes that PRESERVE an imported issue's ORIGINAL
1648
+ // created_at/updated_at, terminal status (done/cancelled), priority(+rank), labels,
1649
+ // acceptance/content and beads-fidelity columns — BYPASSING applyAcceptedIssueEvent's
1650
+ // now-stamping create/CAS path. This is the ONLY path that writes an issue's original
1651
+ // timestamps; the normal create/update flow is unchanged. Consumed exclusively by the
1652
+ // broker's importIssues entry point (the `forge migrate` write path). Each call is
1653
+ // idempotent (ON CONFLICT(id) DO NOTHING — an existing id is skipped, never duplicated
1654
+ // or thrown) and transactional (one BEGIN IMMEDIATE per call; all-or-nothing).
1655
+
1656
+ // The full kernel_issues column set the importer writes. Every NOT NULL column is
1657
+ // seeded with a default in buildImportIssueRow, so a sparse record (only id/title)
1658
+ // still inserts a valid row, while a full-fidelity record round-trips verbatim.
1659
+ const IMPORT_ISSUE_COLUMNS = Object.freeze([
1660
+ 'id', 'title', 'body', 'type', 'status', 'priority', 'priority_rank',
1661
+ 'created_at', 'updated_at', 'entity_revision',
1662
+ 'parent_id', 'sprint_id', 'release_id', 'stage_state', 'labels',
1663
+ 'acceptance_criteria', 'estimate', 'design', 'notes', 'assignee',
1664
+ 'created_by', 'closed_at', 'close_reason', 'metadata',
1665
+ ]);
1666
+
1667
+ const IMPORT_COMMENT_COLUMNS = Object.freeze(['id', 'issue_id', 'body', 'actor', 'visibility', 'created_at']);
1668
+ const IMPORT_DEPENDENCY_COLUMNS = Object.freeze(['id', 'issue_id', 'blocks_issue_id', 'dependency_type', 'created_at']);
1669
+ // The kernel_events column set the importer writes for legacy beads activity events +
1670
+ // interactions (records.activityEvents). Every NOT NULL column is seeded with a default in
1671
+ // buildImportEventRow so a sparse record still inserts a valid row.
1672
+ const IMPORT_EVENT_COLUMNS = Object.freeze([
1673
+ 'id', 'entity_type', 'entity_id', 'event_type', 'idempotency_key',
1674
+ 'expected_revision', 'actor', 'origin', 'payload_json', 'created_at',
1675
+ ]);
1676
+
1677
+ // The mapper carries an issue's terminal close metadata on a SEPARATE
1678
+ // `beads.issue.closed` event (kernel.events), not on the issue record — mirror the
1679
+ // adapter's getCloseMetadataByIssue so closed_at/close_reason land on the issue row.
1680
+ function buildImportCloseMetadata(events = []) {
1681
+ const byIssue = new Map();
1682
+ for (const event of Array.isArray(events) ? events : []) {
1683
+ if (!event || event.event_type !== 'beads.issue.closed') continue;
1684
+ let payload;
1685
+ try {
1686
+ payload = event.payload_json ? JSON.parse(event.payload_json) : (event.payload || {});
1687
+ } catch {
1688
+ payload = {};
1689
+ }
1690
+ byIssue.set(event.entity_id, {
1691
+ closed_at: payload.closed_at ?? event.created_at ?? null,
1692
+ close_reason: payload.close_reason ?? null,
1693
+ });
1694
+ }
1695
+ return byIssue;
1696
+ }
1697
+
1698
+ // Build the full insert row for one imported issue record. NOT NULL columns default so a
1699
+ // sparse record stays valid; created_at/updated_at fall back to the original created_at
1700
+ // (then `now`) rather than always-now, so the imported issue's history is preserved.
1701
+ // labels are stored verbatim (the mapper already JSON-encodes them) — an accidental array
1702
+ // is re-encoded defensively. Close metadata prefers a record-level column, else the
1703
+ // close-event sidecar.
1704
+ function buildImportIssueRow(record, closeMeta, now) {
1705
+ const priority = record.priority ?? 'P2';
1706
+ const createdAt = record.created_at ?? now;
1707
+ const close = closeMeta || {};
1708
+ const labels = Array.isArray(record.labels) ? JSON.stringify(record.labels) : (record.labels ?? null);
1709
+ return {
1710
+ id: record.id,
1711
+ title: record.title ?? record.id,
1712
+ body: record.body ?? null,
1713
+ type: record.type ?? 'task',
1714
+ status: record.status ?? 'open',
1715
+ priority,
1716
+ priority_rank: Number(record.priority_rank ?? rankForPriorityLabel(priority)),
1717
+ created_at: createdAt,
1718
+ updated_at: record.updated_at ?? createdAt,
1719
+ entity_revision: Number(record.entity_revision ?? 0),
1720
+ parent_id: record.parent_id ?? null,
1721
+ sprint_id: record.sprint_id ?? null,
1722
+ release_id: record.release_id ?? null,
1723
+ stage_state: record.stage_state ?? null,
1724
+ labels,
1725
+ acceptance_criteria: record.acceptance_criteria ?? null,
1726
+ estimate: record.estimate ?? null,
1727
+ design: record.design ?? null,
1728
+ notes: record.notes ?? null,
1729
+ assignee: record.assignee ?? null,
1730
+ created_by: record.created_by ?? null,
1731
+ closed_at: record.closed_at ?? close.closed_at ?? null,
1732
+ close_reason: record.close_reason ?? close.close_reason ?? null,
1733
+ metadata: record.metadata ?? null,
1734
+ };
1735
+ }
1736
+
1737
+ // Build the full insert row for one imported activity event (records.activityEvents: legacy
1738
+ // beads events.jsonl + interactions.jsonl mapped to kernel_events). NOT NULL columns default so
1739
+ // a sparse record stays valid; the mapper already supplies a deterministic id/idempotency_key so
1740
+ // the insert is idempotent under ON CONFLICT(id).
1741
+ function buildImportEventRow(record, now) {
1742
+ return {
1743
+ id: record.id,
1744
+ entity_type: record.entity_type ?? 'issue',
1745
+ entity_id: record.entity_id ?? '',
1746
+ event_type: record.event_type ?? 'beads.event',
1747
+ idempotency_key: record.idempotency_key ?? record.id,
1748
+ expected_revision: Number(record.expected_revision ?? 0),
1749
+ actor: record.actor ?? 'beads',
1750
+ origin: record.origin ?? 'beads_import',
1751
+ payload_json: record.payload_json ?? JSON.stringify(record.payload ?? {}),
1752
+ created_at: record.created_at ?? now,
1753
+ };
1754
+ }
1755
+
1756
+ // Insert a kernel records bundle ({ issues, comments, dependencies, events, activityEvents })
1757
+ // into the authority tables inside ONE transaction. Order is issues → comments → dependencies →
1758
+ // activity events so every child FK resolves; children whose endpoint id is absent (a dangling
1759
+ // edge) are filtered out rather than aborting the whole batch on the live FK. activityEvents
1760
+ // (kernel_events has no entity FK) always insert. Returns per-table {inserted, skipped} counts
1761
+ // (skipped = an id that already existed or a filtered child).
1762
+ function importIssueRecords(runtime, db, records = {}, options = {}) {
1763
+ const now = options.now || new Date().toISOString();
1764
+ const issues = Array.isArray(records.issues) ? records.issues : [];
1765
+ const comments = Array.isArray(records.comments) ? records.comments : [];
1766
+ const dependencies = Array.isArray(records.dependencies) ? records.dependencies : [];
1767
+ const activityEvents = Array.isArray(records.activityEvents) ? records.activityEvents : [];
1768
+ const closeByIssue = buildImportCloseMetadata(records.events);
1769
+ const summary = {
1770
+ issues: { inserted: 0, skipped: 0 },
1771
+ comments: { inserted: 0, skipped: 0 },
1772
+ dependencies: { inserted: 0, skipped: 0 },
1773
+ events: { inserted: 0, skipped: 0 },
1774
+ };
1775
+ const wasInserted = result => Number(result?.changes || 0) > 0;
1776
+
1777
+ execSql(runtime, db, 'BEGIN IMMEDIATE;');
1778
+ try {
1779
+ const issueSql = `INSERT INTO kernel_issues (${IMPORT_ISSUE_COLUMNS.join(', ')})`
1780
+ + ` VALUES (${IMPORT_ISSUE_COLUMNS.map(() => '?').join(', ')}) ON CONFLICT(id) DO NOTHING`;
1781
+ for (const record of issues) {
1782
+ if (!record || record.id == null) { summary.issues.skipped += 1; continue; }
1783
+ const row = buildImportIssueRow(record, closeByIssue.get(record.id), now);
1784
+ const result = runParams(runtime, db, issueSql, IMPORT_ISSUE_COLUMNS.map(column => row[column]));
1785
+ summary.issues[wasInserted(result) ? 'inserted' : 'skipped'] += 1;
1786
+ }
1787
+
1788
+ // FK-safe child filtering: an id present after the issue inserts (imported OR
1789
+ // pre-existing) is a valid endpoint; anything else would trip the live FK.
1790
+ const existingIds = new Set(allParams(runtime, db, 'SELECT id FROM kernel_issues').map(issue => issue.id));
1791
+
1792
+ const commentSql = `INSERT INTO kernel_comments (${IMPORT_COMMENT_COLUMNS.join(', ')})`
1793
+ + ` VALUES (${IMPORT_COMMENT_COLUMNS.map(() => '?').join(', ')}) ON CONFLICT(id) DO NOTHING`;
1794
+ for (const comment of comments) {
1795
+ if (!comment || comment.id == null || !existingIds.has(comment.issue_id)) { summary.comments.skipped += 1; continue; }
1796
+ const result = runParams(runtime, db, commentSql, [
1797
+ comment.id,
1798
+ comment.issue_id,
1799
+ comment.body ?? '',
1800
+ comment.actor ?? 'beads',
1801
+ comment.visibility ?? 'local',
1802
+ comment.created_at ?? now,
1803
+ ]);
1804
+ summary.comments[wasInserted(result) ? 'inserted' : 'skipped'] += 1;
1805
+ }
1806
+
1807
+ const dependencySql = `INSERT INTO kernel_dependencies (${IMPORT_DEPENDENCY_COLUMNS.join(', ')})`
1808
+ + ` VALUES (${IMPORT_DEPENDENCY_COLUMNS.map(() => '?').join(', ')}) ON CONFLICT(id) DO NOTHING`;
1809
+ for (const dependency of dependencies) {
1810
+ if (!dependency || dependency.id == null
1811
+ || !existingIds.has(dependency.issue_id) || !existingIds.has(dependency.blocks_issue_id)) {
1812
+ summary.dependencies.skipped += 1;
1813
+ continue;
1814
+ }
1815
+ const result = runParams(runtime, db, dependencySql, [
1816
+ dependency.id,
1817
+ dependency.issue_id,
1818
+ dependency.blocks_issue_id,
1819
+ dependency.dependency_type ?? 'blocks',
1820
+ dependency.created_at ?? now,
1821
+ ]);
1822
+ summary.dependencies[wasInserted(result) ? 'inserted' : 'skipped'] += 1;
1823
+ }
1824
+
1825
+ // Legacy activity log → kernel_events. No entity FK, so every record inserts; the
1826
+ // deterministic id (ON CONFLICT DO NOTHING) makes re-migration idempotent.
1827
+ const eventSql = `INSERT INTO kernel_events (${IMPORT_EVENT_COLUMNS.join(', ')})`
1828
+ + ` VALUES (${IMPORT_EVENT_COLUMNS.map(() => '?').join(', ')}) ON CONFLICT(id) DO NOTHING`;
1829
+ for (const event of activityEvents) {
1830
+ if (!event || event.id == null) { summary.events.skipped += 1; continue; }
1831
+ const row = buildImportEventRow(event, now);
1832
+ const result = runParams(runtime, db, eventSql, IMPORT_EVENT_COLUMNS.map(column => row[column]));
1833
+ summary.events[wasInserted(result) ? 'inserted' : 'skipped'] += 1;
1834
+ }
1835
+
1836
+ execSql(runtime, db, 'COMMIT;');
1837
+ } catch (error) {
1838
+ try {
1839
+ execSql(runtime, db, 'ROLLBACK;');
1840
+ } catch {
1841
+ // A rollback failure must not mask the original import error.
1842
+ }
1843
+ throw error;
1844
+ }
1845
+ return summary;
1846
+ }
1847
+
1848
+ // --- Project-memory read-model primitives -------------------------------------
1849
+ // kernel_memories is a Forge read model written DIRECTLY (NOT through the guarded-event
1850
+ // path), so these are plain synchronous SQL helpers. project-memory.js owns the memory
1851
+ // entry shape; here we (de)serialize the JSON columns and upsert by key. The driver
1852
+ // methods are synchronous so the (synchronous) project-memory facade can persist without
1853
+ // awaiting the async broker.initialize().
1854
+ const KERNEL_MEMORY_COLUMNS = Object.freeze([
1855
+ 'key',
1856
+ 'value_json',
1857
+ 'source_agent',
1858
+ 'scope',
1859
+ 'confidence',
1860
+ 'tags_json',
1861
+ 'supersedes_json',
1862
+ 'beads_refs_json',
1863
+ 'created_at',
1864
+ 'updated_at',
1865
+ ]);
1866
+
1867
+ function parseMemoryJsonColumn(raw, fallback) {
1868
+ if (raw === null || raw === undefined || raw === '') return fallback;
1869
+ try {
1870
+ return JSON.parse(raw);
1871
+ } catch {
1872
+ return fallback;
1873
+ }
1874
+ }
1875
+
1876
+ // Map a stored row back to the memory entry shape. Optional fields are omitted when
1877
+ // unset (matches the legacy entry shape); tags and timestamp are always present. The
1878
+ // entry's logical `timestamp` is the mutable "as-of" time (updated_at), so re-writing a
1879
+ // key surfaces the latest write — matching the legacy single-timestamp behavior, where
1880
+ // every write refreshed the stored timestamp. created_at stays as immutable first-seen
1881
+ // provenance and is intentionally not part of the entry shape.
1882
+ function memoryRowToEntry(row) {
1883
+ if (!row) return null;
1884
+ const entry = {
1885
+ key: row.key,
1886
+ value: parseMemoryJsonColumn(row.value_json, row.value_json),
1887
+ sourceAgent: row.source_agent,
1888
+ tags: parseMemoryJsonColumn(row.tags_json, []),
1889
+ timestamp: row.updated_at,
1890
+ };
1891
+ if (row.scope !== null && row.scope !== undefined) entry.scope = row.scope;
1892
+ if (row.confidence !== null && row.confidence !== undefined) entry.confidence = Number(row.confidence);
1893
+ const supersedes = parseMemoryJsonColumn(row.supersedes_json, undefined);
1894
+ if (Array.isArray(supersedes)) entry.supersedes = supersedes;
1895
+ const beadsRefs = parseMemoryJsonColumn(row.beads_refs_json, undefined);
1896
+ if (Array.isArray(beadsRefs)) entry.beadsRefs = beadsRefs;
1897
+ return entry;
1898
+ }
1899
+
1900
+ function memoryEntryToRow(entry, now) {
1901
+ const tags = Array.isArray(entry.tags) ? entry.tags : [];
1902
+ return {
1903
+ key: entry.key,
1904
+ value_json: JSON.stringify(entry.value ?? null),
1905
+ source_agent: entry.sourceAgent ?? entry['source-agent'] ?? '',
1906
+ scope: entry.scope ?? null,
1907
+ confidence: entry.confidence ?? null,
1908
+ tags_json: JSON.stringify(tags),
1909
+ supersedes_json: Array.isArray(entry.supersedes) ? JSON.stringify(entry.supersedes) : null,
1910
+ beads_refs_json: Array.isArray(entry.beadsRefs) ? JSON.stringify(entry.beadsRefs) : null,
1911
+ // created_at is first-seen provenance (kept across upserts); updated_at is the
1912
+ // entry's logical timestamp (the "as-of" the read model surfaces). Both default to
1913
+ // the wall clock when the caller omits a timestamp (e.g. a direct driver write).
1914
+ created_at: entry.timestamp || now,
1915
+ updated_at: entry.timestamp || now,
1916
+ };
1917
+ }
1918
+
1919
+ // Upsert by key: insert a fresh row, or refresh every value column on a key collision
1920
+ // while keeping the original created_at (only updated_at advances).
1921
+ function upsertMemoryRow(runtime, db, entry) {
1922
+ const now = new Date().toISOString();
1923
+ const row = memoryEntryToRow(entry, now);
1924
+ const placeholders = KERNEL_MEMORY_COLUMNS.map(() => '?').join(', ');
1925
+ runParams(
1926
+ runtime,
1927
+ db,
1928
+ `INSERT INTO kernel_memories (${KERNEL_MEMORY_COLUMNS.join(', ')}) VALUES (${placeholders})
1929
+ ON CONFLICT(key) DO UPDATE SET
1930
+ value_json = excluded.value_json,
1931
+ source_agent = excluded.source_agent,
1932
+ scope = excluded.scope,
1933
+ confidence = excluded.confidence,
1934
+ tags_json = excluded.tags_json,
1935
+ supersedes_json = excluded.supersedes_json,
1936
+ beads_refs_json = excluded.beads_refs_json,
1937
+ updated_at = excluded.updated_at`,
1938
+ KERNEL_MEMORY_COLUMNS.map(column => row[column]),
1939
+ );
1940
+ return memoryRowToEntry(row);
1941
+ }
1942
+
1943
+ function loadMemoryRow(runtime, db, key) {
1944
+ const rows = allParams(runtime, db, 'SELECT * FROM kernel_memories WHERE key = ?', [key]);
1945
+ return memoryRowToEntry(rows[0] || null);
1946
+ }
1947
+
1948
+ function listMemoryRows(runtime, db) {
1949
+ return allParams(runtime, db, 'SELECT * FROM kernel_memories ORDER BY key ASC').map(memoryRowToEntry);
1950
+ }
1951
+
1952
+ // Token-AND LIKE search across key + value_json. Each whitespace-separated token must
1953
+ // appear (in either column); an empty query lists everything. Parameterized, so the
1954
+ // tokens never interpolate into SQL.
1955
+ function searchMemoryRows(runtime, db, query) {
1956
+ const tokens = String(query ?? '').trim().split(/\s+/).filter(Boolean);
1957
+ if (tokens.length === 0) {
1958
+ return listMemoryRows(runtime, db);
1959
+ }
1960
+ const clauses = tokens.map(() => '(key LIKE ? OR value_json LIKE ?)').join(' AND ');
1961
+ const params = tokens.flatMap(token => {
1962
+ const like = `%${token}%`;
1963
+ return [like, like];
1964
+ });
1965
+ return allParams(
1966
+ runtime,
1967
+ db,
1968
+ `SELECT * FROM kernel_memories WHERE ${clauses} ORDER BY key ASC`,
1969
+ params,
1970
+ ).map(memoryRowToEntry);
1971
+ }
1972
+
1973
+ // Optional source_agent allow-list → a parameterized `WHERE source_agent IN (...)` clause.
1974
+ // Lets the default `recall` view show only human `remember` notes without loading and
1975
+ // filtering the whole table in JS. Returns { clause, params }; empty when no filter.
1976
+ function memoryAgentFilter(agents) {
1977
+ if (!Array.isArray(agents) || agents.length === 0) {
1978
+ return { clause: '', params: [] };
1979
+ }
1980
+ const placeholders = agents.map(() => '?').join(', ');
1981
+ return { clause: ` WHERE source_agent IN (${placeholders})`, params: [...agents] };
1982
+ }
1983
+
1984
+ // The newest `limit` entries by logical (as-of) timestamp — the default read model for
1985
+ // `recall` with no query. rowid breaks ties so same-timestamp rows are still deterministic.
1986
+ // An optional `agents` allow-list scopes the view (e.g. to human `remember` notes).
1987
+ function recentMemoryRows(runtime, db, limit, agents) {
1988
+ const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
1989
+ const { clause, params } = memoryAgentFilter(agents);
1990
+ return allParams(
1991
+ runtime,
1992
+ db,
1993
+ `SELECT * FROM kernel_memories${clause} ORDER BY updated_at DESC, rowid DESC LIMIT ?`,
1994
+ [...params, capped],
1995
+ ).map(memoryRowToEntry);
1996
+ }
1997
+
1998
+ // Total number of stored memories (optionally scoped by `agents`) — paired with
1999
+ // recentMemoryRows so `recall` can report "showing N of TOTAL" instead of silently truncating.
2000
+ function countMemoryRows(runtime, db, agents) {
2001
+ const { clause, params } = memoryAgentFilter(agents);
2002
+ const rows = allParams(runtime, db, `SELECT count(*) AS count FROM kernel_memories${clause}`, params);
2003
+ return Number((rows[0] || {}).count) || 0;
2004
+ }
2005
+
2006
+ // Turn a free-form query into an FTS5 MATCH expression: extract alphanumeric barewords,
2007
+ // quote each as a phrase (so an FTS operator token can never break the syntax), and AND
2008
+ // them together. Order-independent token-AND matching — "auth bug" matches a note holding
2009
+ // both tokens in any order. Returns '' when the query has no usable tokens.
2010
+ function buildMemoryFtsMatch(query) {
2011
+ const tokens = String(query ?? '').match(/[\p{L}\p{N}]+/gu);
2012
+ if (!tokens || tokens.length === 0) return '';
2013
+ return tokens.map(token => `"${token}"`).join(' AND ');
2014
+ }
2015
+
2016
+ // BM25 top-N recall over the kernel_memories_fts index (migration 008). Joins the FTS
2017
+ // rowid back to the memory row and orders by bm25 (lower = better match). An empty/tokenless
2018
+ // query falls back to recent entries so `recall` never returns a bare full dump.
2019
+ function searchMemoryRowsRanked(runtime, db, query, limit) {
2020
+ const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
2021
+ const match = buildMemoryFtsMatch(query);
2022
+ if (!match) {
2023
+ return recentMemoryRows(runtime, db, capped);
2024
+ }
2025
+ return allParams(
2026
+ runtime,
2027
+ db,
2028
+ `SELECT m.* FROM kernel_memories m
2029
+ JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
2030
+ WHERE kernel_memories_fts MATCH ?
2031
+ ORDER BY bm25(kernel_memories_fts)
2032
+ LIMIT ?`,
2033
+ [match, capped],
2034
+ ).map(memoryRowToEntry);
2035
+ }
2036
+
2037
+ function closeDatabase(db) {
2038
+ if (db && typeof db.close === 'function') {
2039
+ db.close();
2040
+ }
2041
+ }
2042
+
2043
+ function createDriver(runtime, configuredDatabasePath) {
2044
+ let db;
2045
+ let openedDatabasePath;
2046
+ let memorySchemaEnsured = false;
2047
+
2048
+ // kernel_memories is created by migration 005 through broker.initialize(), but the
2049
+ // synchronous project-memory facade writes WITHOUT first running migrations. Lazily
2050
+ // ensure the table (idempotent CREATE IF NOT EXISTS, rendered from the same migration)
2051
+ // plus a busy_timeout for the second connection the issue backend may hold open.
2052
+ function ensureMemorySchema(database) {
2053
+ if (memorySchemaEnsured) return;
2054
+ execSql(runtime, database, 'PRAGMA busy_timeout=5000;');
2055
+ for (const statement of buildMemoryProjectionMigration().apply) {
2056
+ execSql(runtime, database, statement);
2057
+ }
2058
+ // FTS5 recall index (migration 008): create the virtual table + sync triggers
2059
+ // idempotently so a synchronous memory write stays indexed without a prior
2060
+ // broker.initialize(). When the index is NEWLY created, rebuild once to backfill any
2061
+ // rows written before it existed (a DB upgraded from before this feature, or rows the
2062
+ // insights engine wrote straight to kernel_memories) — the sync triggers keep it
2063
+ // current thereafter, so steady-state process starts skip the reindex.
2064
+ //
2065
+ // Staleness is detected by TABLE EXISTENCE (sqlite_master), never by count(*): on an
2066
+ // external-content FTS5 table `count(*)` returns the CONTENT row count, not the
2067
+ // indexed-doc count, so it can never reveal an un-backfilled index.
2068
+ const ftsDdl = memoryFtsDdl();
2069
+ const ftsExisted = Number(queryOne(
2070
+ runtime,
2071
+ database,
2072
+ "SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'kernel_memories_fts'",
2073
+ ).count) > 0;
2074
+ execSql(runtime, database, ftsDdl.create);
2075
+ for (const trigger of ftsDdl.triggers) {
2076
+ execSql(runtime, database, trigger);
2077
+ }
2078
+ if (!ftsExisted) {
2079
+ execSql(runtime, database, ftsDdl.rebuild);
2080
+ }
2081
+ memorySchemaEnsured = true;
2082
+ }
2083
+
2084
+ function resolveDatabasePath(config) {
2085
+ const brokerDatabasePath = config && config.databasePath;
2086
+ if (configuredDatabasePath && brokerDatabasePath && configuredDatabasePath !== brokerDatabasePath) {
2087
+ throw new Error([
2088
+ 'Kernel SQLite driver databasePath mismatch:',
2089
+ `driver is configured for ${configuredDatabasePath}`,
2090
+ `but broker config uses ${brokerDatabasePath}`,
2091
+ ].join(' '));
2092
+ }
2093
+ const databasePath = brokerDatabasePath || configuredDatabasePath;
2094
+ if (!databasePath) {
2095
+ throw new Error('Kernel SQLite driver requires a databasePath or broker config databasePath');
2096
+ }
2097
+ return databasePath;
2098
+ }
2099
+
2100
+ function getDatabase(config) {
2101
+ const databasePath = resolveDatabasePath(config);
2102
+ if (!db) {
2103
+ db = createDatabase(runtime, databasePath);
2104
+ openedDatabasePath = databasePath;
2105
+ } else if (openedDatabasePath !== databasePath) {
2106
+ throw new Error(`Kernel SQLite driver is already open for ${openedDatabasePath}`);
2107
+ }
2108
+ return db;
2109
+ }
2110
+
2111
+ return {
2112
+ runtime: {
2113
+ id: runtime.id,
2114
+ databaseClassName: runtime.databaseClassName,
2115
+ nativeCompileDependency: runtime.nativeCompileDependency,
2116
+ experimental: runtime.experimental,
2117
+ },
2118
+ databasePath: configuredDatabasePath,
2119
+ async exec(statement, config) {
2120
+ execSql(runtime, getDatabase(config), statement);
2121
+ },
2122
+ async queryAll(statement, config) {
2123
+ return queryAll(runtime, getDatabase(config), statement);
2124
+ },
2125
+ async issueOperation(operation, args = [], context = {}, config = {}) {
2126
+ const database = getDatabase(config);
2127
+ const READ_OPERATIONS = new Set(['ready', 'list', 'show', 'search', 'stats', 'blocked', 'stale', 'orphans', 'lint', 'children', 'owns', 'claims']);
2128
+ if (READ_OPERATIONS.has(operation)) {
2129
+ return runIssueReadOperation(runtime, database, operation, args, context);
2130
+ }
2131
+ // Mutations (create/update/close/comment/dep.add/dep.remove/claim/release) are
2132
+ // implemented through the broker's guarded-event path in a later wave.
2133
+ throw new Error(`Kernel SQLite driver issueOperation: mutation operation '${operation}' is not implemented yet (reads only)`);
2134
+ },
2135
+ // Git-style short-id support (kernel 9556660b): the candidate ids (+ titles)
2136
+ // whose id starts with `prefix`. Parameterized LIKE with escaped wildcards;
2137
+ // ordered by id ascending so an EXACT match (the shortest id sharing the
2138
+ // prefix) always sorts first and is never pushed out by the limit. Consumed
2139
+ // by the broker's issue-id prefix resolver, never by the contract directly.
2140
+ async findIssueIdsByPrefix(prefix, limit = 6, _context = {}, config = {}) {
2141
+ const escaped = String(prefix).replace(/[\\%_]/g, match => `\\${match}`);
2142
+ return allParams(
2143
+ runtime, getDatabase(config),
2144
+ "SELECT id, title FROM kernel_issues WHERE id LIKE ? ESCAPE '\\' ORDER BY id ASC LIMIT ?",
2145
+ [`${escaped}%`, limit],
2146
+ );
2147
+ },
2148
+ // --- Event-store primitives (Wave 2) — composed by broker.runGuardedEvent.
2149
+ // `context` is part of the broker contract but unused by these direct SQL
2150
+ // reads/writes (prefixed `_` for eslint no-unused-vars).
2151
+ async insertKernelEvent(event, _context = {}, config = {}) {
2152
+ return insertKernelEventRow(runtime, getDatabase(config), event);
2153
+ },
2154
+ async loadKernelEntity(entityType, entityId, _context = {}, config = {}) {
2155
+ return loadKernelEntityRow(runtime, getDatabase(config), entityType, entityId);
2156
+ },
2157
+ async listKernelEvents(entityType, entityId, _context = {}, config = {}) {
2158
+ return listKernelEventRows(runtime, getDatabase(config), entityType, entityId);
2159
+ },
2160
+ async loadKernelEventByIdempotencyKey(idempotencyKey, _context = {}, config = {}) {
2161
+ return loadKernelEventByIdempotencyKeyRow(runtime, getDatabase(config), idempotencyKey);
2162
+ },
2163
+ async insertKernelConflict(conflict, _context = {}, config = {}) {
2164
+ return insertKernelConflictRow(runtime, getDatabase(config), conflict);
2165
+ },
2166
+ async enqueueKernelProjection(entry, _context = {}, config = {}) {
2167
+ return enqueueKernelProjectionRow(runtime, getDatabase(config), entry);
2168
+ },
2169
+ // --- Projection-outbox read/update surface (Wave 5) — composed by the
2170
+ // broker's projection-outbox methods, consumed by runJsonlProjectionConsumer.
2171
+ // These never touch the append/CAS path; `context` is part of the broker
2172
+ // contract but unused by these direct reads/writes (prefixed `_`).
2173
+ async listProjectionOutbox(filter = {}, _context = {}, config = {}) {
2174
+ return listProjectionOutboxRows(runtime, getDatabase(config), filter);
2175
+ },
2176
+ async loadProjectionModel(_context = {}, config = {}) {
2177
+ return loadProjectionModelRows(runtime, getDatabase(config));
2178
+ },
2179
+ async markProjectionDelivered(ids = [], meta = {}, _context = {}, config = {}) {
2180
+ return markProjectionDeliveredRows(runtime, getDatabase(config), ids, meta);
2181
+ },
2182
+ async recordProjectionFailure(record, _context = {}, config = {}) {
2183
+ return recordProjectionFailureRows(runtime, getDatabase(config), record);
2184
+ },
2185
+ async deadLetterProjection(record, _context = {}, config = {}) {
2186
+ return deadLetterProjectionRows(runtime, getDatabase(config), record);
2187
+ },
2188
+ async listKernelDependencies(scope, _context = {}, config = {}) {
2189
+ return listKernelDependencyRows(runtime, getDatabase(config), scope);
2190
+ },
2191
+ // Claim-lease primitives (Wave 4) — composed by commitGuardedAccept /
2192
+ // resolveClaimAcquisition. loadActiveKernelClaim feeds planClaimAcquisition;
2193
+ // insertKernelClaim / updateKernelClaimState are the lease writes. The DB
2194
+ // partial-UNIQUE index (idx_kernel_claims_active_lease) enforces the
2195
+ // single-active-claim-per-issue invariant under concurrent writers.
2196
+ async loadActiveKernelClaim(issueId, _context = {}, config = {}) {
2197
+ return loadActiveKernelClaimRow(runtime, getDatabase(config), issueId);
2198
+ },
2199
+ async insertKernelClaim(claim, _context = {}, config = {}) {
2200
+ return insertKernelClaimRow(runtime, getDatabase(config), claim);
2201
+ },
2202
+ async updateKernelClaimState(claimId, state, _context = {}, config = {}) {
2203
+ return updateKernelClaimStateRow(runtime, getDatabase(config), claimId, state);
2204
+ },
2205
+ // commitGuardedAccept invokes this (typeof-guarded) INSIDE its BEGIN IMMEDIATE
2206
+ // transaction to apply an accepted issue event to the authority tables. The
2207
+ // returned summary ({id, revision, comment_id?}) flows back through
2208
+ // runGuardedEvent's result so runIssueOperation can shape the mutation response.
2209
+ async applyAcceptedIssueMutation(event, context = {}, config = {}) {
2210
+ return applyAcceptedMutation(runtime, getDatabase(config), event, context);
2211
+ },
2212
+ // Faithful-import write path: insert a kernel records bundle ({ issues, comments,
2213
+ // dependencies, events, activityEvents }) DIRECTLY into the authority tables,
2214
+ // preserving each issue's original created_at/updated_at + terminal status (bypassing
2215
+ // the now-stamping create/CAS path) and landing the legacy beads activity log in
2216
+ // kernel_events. Idempotent + transactional. `context` is part of the driver contract
2217
+ // but unused by this direct write (prefixed `_`).
2218
+ async importIssues(records = {}, options = {}, _context = {}, config = {}) {
2219
+ return importIssueRecords(runtime, getDatabase(config), records, options);
2220
+ },
2221
+ // --- Project-memory read model (written directly, not via the guarded path).
2222
+ // Synchronous by design: the project-memory facade is synchronous, and these
2223
+ // lazily ensure the kernel_memories table so a write never needs a prior
2224
+ // (async) broker.initialize().
2225
+ // --- Worktree-linkage registry (written directly, not via the guarded event
2226
+ // path). Synchronous like the memory facade so `forge worktree create` and the
2227
+ // synchronous orientation read can use them without a prior broker.initialize().
2228
+ // registerWorktree requires the 007 columns (callers use a migrated driver);
2229
+ // getWorktreeLinkage/listWorktrees tolerate a missing/empty table (safeAll) and
2230
+ // return null/[] so orientation falls back to the folder heuristic.
2231
+ registerWorktree(input, config = {}) {
2232
+ return upsertWorktreeRow(runtime, getDatabase(config), input);
2233
+ },
2234
+ getWorktreeLinkage(filter = {}, config = {}) {
2235
+ return loadWorktreeRowByPath(runtime, getDatabase(config), filter.path);
2236
+ },
2237
+ listWorktrees(filter = {}, config = {}) {
2238
+ return listWorktreeRows(runtime, getDatabase(config), filter);
2239
+ },
2240
+ // --- Stage-run registry (f61601ab). Direct writes like the worktree registry
2241
+ // (bypass the guarded event path), synchronous so a CLI verb / orientation read
2242
+ // can use them without a prior async broker.initialize(). Idempotent per
2243
+ // (issue_id, stage). getCurrentStage powers the real workflow-phase read.
2244
+ recordStageRun(input, config = {}) {
2245
+ return recordStageRunRow(runtime, getDatabase(config), input);
2246
+ },
2247
+ // Atomic complete(from)+start(to) in ONE transaction: a mid-transition failure
2248
+ // rolls back both writes so `current_stage` never reflects a half-transition.
2249
+ recordStageTransition(input, config = {}) {
2250
+ return recordStageTransitionRow(runtime, getDatabase(config), input);
2251
+ },
2252
+ listStageRuns(filter = {}, config = {}) {
2253
+ return listStageRunRows(runtime, getDatabase(config), filter.issue_id);
2254
+ },
2255
+ getCurrentStage(filter = {}, config = {}) {
2256
+ return loadCurrentStageRunRow(runtime, getDatabase(config), filter.issue_id);
2257
+ },
2258
+ recordMemory(entry, config = {}) {
2259
+ const database = getDatabase(config);
2260
+ ensureMemorySchema(database);
2261
+ return upsertMemoryRow(runtime, database, entry);
2262
+ },
2263
+ loadMemory(key, config = {}) {
2264
+ const database = getDatabase(config);
2265
+ ensureMemorySchema(database);
2266
+ return loadMemoryRow(runtime, database, key);
2267
+ },
2268
+ searchMemories(query, config = {}) {
2269
+ const database = getDatabase(config);
2270
+ ensureMemorySchema(database);
2271
+ return searchMemoryRows(runtime, database, query);
2272
+ },
2273
+ // BM25 top-N recall over the FTS5 index (token-AND). An empty query falls back to
2274
+ // the newest `limit` entries so recall never returns a bare full dump.
2275
+ searchMemoriesRanked(query, limit, config = {}) {
2276
+ const database = getDatabase(config);
2277
+ ensureMemorySchema(database);
2278
+ return searchMemoryRowsRanked(runtime, database, query, limit);
2279
+ },
2280
+ // The newest `limit` entries (default recall with no query). `options.agents` scopes
2281
+ // the read to a source_agent allow-list (e.g. human `remember` notes only).
2282
+ recentMemories(limit, options = {}, config = {}) {
2283
+ const database = getDatabase(config);
2284
+ ensureMemorySchema(database);
2285
+ return recentMemoryRows(runtime, database, limit, options.agents);
2286
+ },
2287
+ // Total stored memories (optionally scoped by `options.agents`) — lets recall report
2288
+ // "showing N of TOTAL".
2289
+ countMemories(options = {}, config = {}) {
2290
+ const database = getDatabase(config);
2291
+ ensureMemorySchema(database);
2292
+ return countMemoryRows(runtime, database, options.agents);
2293
+ },
2294
+ listMemories(config = {}) {
2295
+ const database = getDatabase(config);
2296
+ ensureMemorySchema(database);
2297
+ return listMemoryRows(runtime, database);
2298
+ },
2299
+ close() {
2300
+ closeDatabase(db);
2301
+ db = null;
2302
+ openedDatabasePath = null;
2303
+ memorySchemaEnsured = false;
2304
+ },
2305
+ };
2306
+ }
2307
+
2308
+ function assertCapability(runtime, capability, detail) {
2309
+ if (!detail.ok) {
2310
+ throw new Error(`Builtin SQLite runtime ${runtime.id} failed ${capability} validation: ${detail.reason}`);
2311
+ }
2312
+ return true;
2313
+ }
2314
+
2315
+ function validateWal(runtime, db) {
2316
+ const row = queryOne(runtime, db, 'PRAGMA journal_mode=WAL;');
2317
+ const mode = String(row.journal_mode || '').toLowerCase();
2318
+ return { ok: mode === 'wal', reason: `journal_mode=${mode || 'unknown'}` };
2319
+ }
2320
+
2321
+ function validateBusyTimeout(runtime, db) {
2322
+ const row = queryOne(runtime, db, 'PRAGMA busy_timeout=5000;');
2323
+ const timeout = Number(row.timeout);
2324
+ return { ok: timeout === 5000, reason: `timeout=${Number.isNaN(timeout) ? 'unknown' : timeout}` };
2325
+ }
2326
+
2327
+ function createProbeTableName(prefix) {
2328
+ probeCounter += 1;
2329
+ return `${prefix}_${process.pid}_${probeCounter}`;
2330
+ }
2331
+
2332
+ function validateTransactions(runtime, db) {
2333
+ const tableName = createProbeTableName('forge_transaction_probe');
2334
+ let committed = false;
2335
+ try {
2336
+ execSql(runtime, db, [
2337
+ 'BEGIN IMMEDIATE;',
2338
+ `CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY, value TEXT NOT NULL);`,
2339
+ `INSERT INTO ${tableName} (value) VALUES ('ok');`,
2340
+ 'COMMIT;',
2341
+ ].join('\n'));
2342
+ committed = true;
2343
+ const row = queryOne(runtime, db, `SELECT value FROM ${tableName} WHERE id = 1;`);
2344
+ return { ok: row.value === 'ok', reason: `value=${row.value || 'missing'}` };
2345
+ } catch (error) {
2346
+ if (!committed) {
2347
+ try {
2348
+ execSql(runtime, db, 'ROLLBACK;');
2349
+ } catch {
2350
+ // Ignore rollback errors from runtimes that already closed the failed transaction.
2351
+ }
2352
+ }
2353
+ return { ok: false, reason: error.message || String(error) };
2354
+ } finally {
2355
+ try {
2356
+ execSql(runtime, db, `DROP TABLE IF EXISTS ${tableName};`);
2357
+ } catch {
2358
+ // Probe cleanup must not hide the original capability result.
2359
+ }
2360
+ }
2361
+ }
2362
+
2363
+ function validateFts5(runtime, db) {
2364
+ const tableName = createProbeTableName('forge_fts_probe');
2365
+ try {
2366
+ execSql(runtime, db, `CREATE VIRTUAL TABLE ${tableName} USING fts5(content);`);
2367
+ execSql(runtime, db, `INSERT INTO ${tableName} (content) VALUES ('kernel sqlite driver');`);
2368
+ const row = queryOne(runtime, db, `SELECT count(*) AS count FROM ${tableName} WHERE ${tableName} MATCH 'sqlite';`);
2369
+ return { ok: Number(row.count) === 1, reason: `count=${row.count || 0}` };
2370
+ } catch (error) {
2371
+ return { ok: false, reason: error.message || String(error) };
2372
+ } finally {
2373
+ try {
2374
+ execSql(runtime, db, `DROP TABLE IF EXISTS ${tableName};`);
2375
+ } catch {
2376
+ // Probe cleanup must not hide the original capability result.
2377
+ }
2378
+ }
2379
+ }
2380
+
2381
+ function validateCheckpoint(runtime, db) {
2382
+ try {
2383
+ const row = queryOne(runtime, db, 'PRAGMA wal_checkpoint(TRUNCATE);');
2384
+ return { ok: Number(row.busy) === 0, reason: `busy=${row.busy}` };
2385
+ } catch (error) {
2386
+ return { ok: false, reason: error.message || String(error) };
2387
+ }
2388
+ }
2389
+
2390
+ async function createBackup(runtime, db, backupPath) {
2391
+ ensureFileBackedDatabaseDirectory(backupPath);
2392
+ if (fs.existsSync(backupPath)) {
2393
+ fs.rmSync(backupPath, { force: true });
2394
+ }
2395
+
2396
+ if (runtime.id === 'node:sqlite') {
2397
+ if (typeof runtime.module.backup === 'function') {
2398
+ await runtime.module.backup(db, backupPath);
2399
+ return;
2400
+ }
2401
+ if (typeof db.backup === 'function') {
2402
+ await db.backup(backupPath);
2403
+ return;
2404
+ }
2405
+ throw new Error('node:sqlite backup API is unavailable');
2406
+ }
2407
+
2408
+ if (runtime.id === 'bun:sqlite') {
2409
+ if (typeof db.serialize !== 'function') {
2410
+ throw new Error('bun:sqlite Database.serialize() is unavailable');
2411
+ }
2412
+ fs.writeFileSync(backupPath, db.serialize());
2413
+ return;
2414
+ }
2415
+
2416
+ throw new Error(`Unsupported builtin SQLite runtime: ${runtime.id}`);
2417
+ }
2418
+
2419
+ async function validateBackup(runtime, db, backupPath) {
2420
+ const tableName = createProbeTableName('forge_backup_probe');
2421
+ try {
2422
+ execSql(runtime, db, `CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY, value TEXT NOT NULL);`);
2423
+ execSql(runtime, db, `INSERT INTO ${tableName} (value) VALUES ('ok');`);
2424
+ await createBackup(runtime, db, backupPath);
2425
+ const backupDb = createDatabase(runtime, backupPath);
2426
+ try {
2427
+ const row = queryOne(runtime, backupDb, `SELECT value FROM ${tableName} WHERE id = 1;`);
2428
+ return {
2429
+ ok: row.value === 'ok' && fs.existsSync(backupPath),
2430
+ reason: `value=${row.value || 'missing'}`,
2431
+ };
2432
+ } finally {
2433
+ closeDatabase(backupDb);
2434
+ }
2435
+ } catch (error) {
2436
+ return { ok: false, reason: error.message || String(error) };
2437
+ } finally {
2438
+ try {
2439
+ execSql(runtime, db, `DROP TABLE IF EXISTS ${tableName};`);
2440
+ } catch {
2441
+ // Probe cleanup must not hide the original capability result.
2442
+ }
2443
+ }
2444
+ }
2445
+
2446
+ async function validateBuiltinSQLiteRuntimeDriver(options = {}, deps = {}) {
2447
+ const runtime = options.runtime || selectBuiltinSQLiteRuntime(deps);
2448
+ let tempDir = options.tempDir;
2449
+ let ownsTempDir = false;
2450
+ if (!tempDir && !options.databasePath && !options.backupPath) {
2451
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-kernel-sqlite-'));
2452
+ ownsTempDir = true;
2453
+ }
2454
+ const databasePath = options.databasePath
2455
+ || (tempDir ? path.join(tempDir, 'kernel.sqlite') : `${options.backupPath}.source.sqlite`);
2456
+ const backupPath = options.backupPath
2457
+ || (tempDir ? path.join(tempDir, 'kernel.backup.sqlite') : `${databasePath}.backup.sqlite`);
2458
+ let db;
2459
+
2460
+ try {
2461
+ db = createDatabase(runtime, databasePath);
2462
+ const capabilities = {
2463
+ wal: assertCapability(runtime, 'WAL', validateWal(runtime, db)),
2464
+ busyTimeout: assertCapability(runtime, 'busy_timeout', validateBusyTimeout(runtime, db)),
2465
+ transactions: assertCapability(runtime, 'transaction', validateTransactions(runtime, db)),
2466
+ fts5: assertCapability(runtime, 'FTS5', validateFts5(runtime, db)),
2467
+ checkpoint: assertCapability(runtime, 'checkpoint', validateCheckpoint(runtime, db)),
2468
+ backup: assertCapability(runtime, 'backup', await validateBackup(runtime, db, backupPath)),
2469
+ nativeCompileDependency: runtime.nativeCompileDependency,
2470
+ };
2471
+
2472
+ return {
2473
+ runtime: {
2474
+ id: runtime.id,
2475
+ databaseClassName: runtime.databaseClassName,
2476
+ nativeCompileDependency: runtime.nativeCompileDependency,
2477
+ experimental: runtime.experimental,
2478
+ },
2479
+ databasePath,
2480
+ backupPath,
2481
+ capabilities,
2482
+ };
2483
+ } finally {
2484
+ closeDatabase(db);
2485
+ if (ownsTempDir) {
2486
+ fs.rmSync(tempDir, { recursive: true, force: true });
2487
+ }
2488
+ }
2489
+ }
2490
+
2491
+ function createBuiltinSQLiteDriver(options = {}, deps = {}) {
2492
+ const runtime = options.runtime || selectBuiltinSQLiteRuntime(deps);
2493
+ return createDriver(runtime, options.databasePath);
2494
+ }
2495
+
2496
+ module.exports = {
2497
+ BUILTIN_SQLITE_RUNTIME_ORDER,
2498
+ CONFLICT_SIGNAL,
2499
+ classifyConflictSignal,
2500
+ createBuiltinSQLiteDriver,
2501
+ requireSqliteRuntimeModule,
2502
+ selectBuiltinSQLiteRuntime,
2503
+ validateBuiltinSQLiteRuntimeDriver,
2504
+ };