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.
- package/.claude/rules/{greptile-review-process.md → review-process.md} +56 -41
- package/.claude/scripts/{greptile-resolve.sh → review-resolve.sh} +13 -3
- package/.cursor/rules/permissions-guidance.mdc +2 -2
- package/.forge/hooks/check-tdd.js +3 -0
- package/.forge/hooks/forge-native-hook.js +245 -0
- package/.forge/protected-paths.yaml +157 -0
- package/AGENTS.md +151 -61
- package/CHANGELOG.md +681 -0
- package/CLAUDE.md +9 -106
- package/QUICKSTART.md +171 -0
- package/README.md +271 -363
- package/bin/forge-cmd.js +120 -9
- package/bin/forge-preflight.js +26 -5
- package/bin/forge.js +466 -489
- package/docs/INDEX.md +93 -0
- package/docs/PROJECT_DESIGN.md +685 -0
- package/docs/architecture/index.md +66 -0
- package/docs/architecture/notes/README.md +35 -0
- package/docs/architecture/subsystems/README.md +46 -0
- package/docs/{TOOLCHAIN.md → forge/TOOLCHAIN.md} +56 -47
- package/docs/forge/VALIDATION.md +82 -0
- package/docs/{AGENT_INSTALL_PROMPT.md → guides/AGENT_INSTALL_PROMPT.md} +3 -3
- package/docs/guides/BEADS_GITHUB_SYNC.md +32 -0
- package/docs/{ENHANCED_ONBOARDING.md → guides/ENHANCED_ONBOARDING.md} +16 -12
- package/docs/guides/GREPTILE_SETUP.md +46 -0
- package/docs/guides/MANUAL_REVIEW_GUIDE.md +58 -0
- package/docs/guides/MIGRATION.md +56 -0
- package/docs/guides/SETUP.md +118 -0
- package/docs/guides/SUPPORT.md +185 -0
- package/docs/guides/WORKFLOW_TEMPLATES.md +74 -0
- package/docs/guides/memory-backends.md +183 -0
- package/docs/reference/ADAPTERS.md +128 -0
- package/docs/reference/AGENT_SKILL_PARITY.md +175 -0
- package/docs/reference/COMMANDS.md +205 -0
- package/docs/reference/DECISION_DRIFT_GUARDS.md +97 -0
- package/docs/{EXAMPLES.md → reference/EXAMPLES.md} +7 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +135 -0
- package/docs/reference/HERMES_INTEGRATION.md +118 -0
- package/docs/reference/INSIGHTS_RECAP.md +63 -0
- package/docs/reference/INSTALL.md +164 -0
- package/docs/reference/KERNEL_TAXONOMY_VALIDATION.md +161 -0
- package/docs/reference/PROTECTED_PATH_MANIFEST.md +25 -0
- package/docs/reference/RELEASE.md +68 -0
- package/docs/reference/RESEARCH_TEMPLATE.md +292 -0
- package/docs/{ROADMAP.md → reference/ROADMAP.md} +12 -9
- package/docs/reference/SKILLS.md +35 -0
- package/docs/reference/STATUS_BOARD.md +80 -0
- package/docs/reference/TEMPLATES.md +106 -0
- package/docs/reference/TOOLCHAIN.md +658 -0
- package/docs/reference/VALIDATION.md +82 -0
- package/docs/reference/agent-permissions.md +169 -0
- package/docs/reference/beads-to-kernel-migration-ux.md +61 -0
- package/docs/reference/control-plane-guarantees.md +125 -0
- package/docs/reference/dependency-chain.md +331 -0
- package/docs/reference/forge-kernel-issue-command-contract.md +161 -0
- package/docs/reference/forge-kernel-schema.md +72 -0
- package/docs/reference/kernel-conflict-evaluators.md +27 -0
- package/docs/reference/patch-md-format.md +77 -0
- package/docs/reference/protected-state-surfaces.md +59 -0
- package/docs/reference/shepherd.md +115 -0
- package/docs/reference/superpowers-analysis.md +320 -0
- package/docs/reference/superpowers-integration-options.md +404 -0
- package/docs/reference/test-environment.md +519 -0
- package/docs/reference/upgrade-safety.md +59 -0
- package/lefthook.yml +18 -0
- package/lib/adapter-cli.js +307 -0
- package/lib/adapters/beads-issue-adapter.js +127 -0
- package/lib/adapters/beads-kernel-compat.js +1042 -0
- package/lib/adapters/greptile-review-adapter.js +141 -0
- package/lib/adapters/kernel-issue-adapter.js +101 -0
- package/lib/adapters/pr-state-adapter.js +484 -0
- package/lib/adoption-profiles.js +126 -0
- package/lib/agents/README.md +2 -6
- package/lib/agents/claude.plugin.json +3 -8
- package/lib/agents/codex.plugin.json +9 -1
- package/lib/agents/cursor.plugin.json +2 -6
- package/lib/agents/hermes.plugin.json +22 -0
- package/lib/agents-config.js +39 -1236
- package/lib/audit-evidence.js +282 -0
- package/lib/beads-setup.js +225 -28
- package/lib/beads-sync-scaffold.js +36 -107
- package/lib/codex-skills.js +51 -1
- package/lib/commands/_issue.js +744 -70
- package/lib/commands/_manifest.js +91 -0
- package/lib/commands/_registry.js +85 -34
- package/lib/commands/_resolve-command-opts.js +261 -0
- package/lib/commands/_serve-security.js +270 -0
- package/lib/commands/adapter.js +12 -0
- package/lib/commands/add.js +118 -0
- package/lib/commands/audit.js +70 -0
- package/lib/commands/blocked.js +5 -0
- package/lib/commands/board.js +64 -0
- package/lib/commands/claim.js +21 -2
- package/lib/commands/claims.js +7 -0
- package/lib/commands/clean.js +485 -75
- package/lib/commands/close.js +2 -2
- package/lib/commands/comment.js +5 -0
- package/lib/commands/control.js +148 -0
- package/lib/commands/create.js +2 -2
- package/lib/commands/dev.js +185 -7
- package/lib/commands/doc-gate.js +336 -0
- package/lib/commands/doctor.js +156 -0
- package/lib/commands/explain.js +15 -0
- package/lib/commands/export.js +237 -0
- package/lib/commands/gate.js +192 -0
- package/lib/commands/hooks.js +242 -0
- package/lib/commands/inbox.js +118 -0
- package/lib/commands/init.js +598 -0
- package/lib/commands/insights.js +79 -0
- package/lib/commands/issue.js +12 -1
- package/lib/commands/issues.js +66 -0
- package/lib/commands/lint.js +5 -0
- package/lib/commands/list.js +2 -2
- package/lib/commands/merge.js +312 -0
- package/lib/commands/migrate.js +523 -0
- package/lib/commands/new.js +12 -0
- package/lib/commands/options.js +241 -0
- package/lib/commands/orient.js +13 -0
- package/lib/commands/orphans.js +5 -0
- package/lib/commands/patch.js +67 -0
- package/lib/commands/plan.js +436 -24
- package/lib/commands/preflight.js +211 -0
- package/lib/commands/prime.js +13 -0
- package/lib/commands/push.js +69 -2
- package/lib/commands/ready.js +2 -2
- package/lib/commands/recall.js +116 -0
- package/lib/commands/recap.js +61 -0
- package/lib/commands/recommend.js +22 -2
- package/lib/commands/release.js +91 -0
- package/lib/commands/remember.js +74 -0
- package/lib/commands/role.js +99 -0
- package/lib/commands/serve.js +581 -0
- package/lib/commands/setup.js +851 -979
- package/lib/commands/shepherd.js +436 -0
- package/lib/commands/ship.js +23 -1
- package/lib/commands/show.js +2 -2
- package/lib/commands/stage.js +192 -0
- package/lib/commands/stale.js +5 -0
- package/lib/commands/status.js +329 -11
- package/lib/commands/sync.js +34 -46
- package/lib/commands/team.js +15 -2
- package/lib/commands/test.js +58 -7
- package/lib/commands/update.js +2 -2
- package/lib/commands/upgrade.js +47 -0
- package/lib/commands/validate.js +56 -25
- package/lib/commands/worktree.js +308 -128
- package/lib/config-writer.js +202 -0
- package/lib/control-plane.js +236 -0
- package/lib/core/runtime-graph.js +946 -0
- package/lib/dep-guard/keyword-ripple.js +184 -0
- package/lib/deprecated-sync-cleanup.js +362 -0
- package/lib/detect-agent.js +2 -28
- package/lib/detect-worktree.js +42 -17
- package/lib/doc-gate/declaration.js +177 -0
- package/lib/doc-gate/detect.js +289 -0
- package/lib/doc-gate/gate.js +375 -0
- package/lib/doc-gate/okf-config.js +128 -0
- package/lib/doc-gate/okf.js +429 -0
- package/lib/docs-command.js +1161 -6
- package/lib/forge-issues.js +697 -0
- package/lib/forge-lock.js +262 -0
- package/lib/gate-events.js +193 -0
- package/lib/global-flags.js +74 -0
- package/lib/greptile-match.js +7 -63
- package/lib/harness-capability-matrix.js +380 -0
- package/lib/hook-global-installer.js +347 -0
- package/lib/hook-renderer.js +451 -0
- package/lib/inbox.js +391 -0
- package/lib/insights.js +397 -0
- package/lib/issue-adapter.js +156 -0
- package/lib/issue-backend.js +145 -0
- package/lib/issue-render.js +220 -0
- package/lib/issue-sync/authority.js +100 -0
- package/lib/issue-sync/github-pull.js +184 -0
- package/lib/issue-sync/import-primitives.js +98 -0
- package/lib/issue-sync/legacy-link-bridge.js +436 -0
- package/lib/issue-sync/link-store.js +292 -0
- package/lib/issue-sync/project-github.js +123 -0
- package/lib/issue-sync/reconcile.js +195 -0
- package/lib/issue-sync/schema.js +126 -0
- package/lib/kernel/backing-issue.js +305 -0
- package/lib/kernel/broker.js +1218 -0
- package/lib/kernel/cli-broker-factory.js +130 -0
- package/lib/kernel/conflict-signal.js +82 -0
- package/lib/kernel/evaluators.js +195 -0
- package/lib/kernel/fs-class.js +495 -0
- package/lib/kernel/issue-command-contract.js +559 -0
- package/lib/kernel/issue-id-resolver.js +186 -0
- package/lib/kernel/lease-enforcer.js +158 -0
- package/lib/kernel/migrations.js +333 -0
- package/lib/kernel/planning-buckets-schema.js +109 -0
- package/lib/kernel/projection-jsonl-writer.js +450 -0
- package/lib/kernel/readiness-model.js +329 -0
- package/lib/kernel/schema.js +356 -0
- package/lib/kernel/sqlite-driver.js +2504 -0
- package/lib/kernel/taxonomy-validator.js +394 -0
- package/lib/lefthook-check.js +8 -4
- package/lib/lefthook-wiring.js +413 -0
- package/lib/mcp-config-renderer.js +288 -0
- package/lib/memory/graphiti-mcp.js +106 -0
- package/lib/memory/router.js +387 -0
- package/lib/memory/typed-api.js +102 -0
- package/lib/memory-digest.js +195 -0
- package/lib/merge-rules.js +395 -0
- package/lib/migrate-dry-run.js +466 -0
- package/lib/orientation.js +863 -0
- package/lib/package-manager-remediation.js +103 -0
- package/lib/package-root.js +381 -0
- package/lib/patch-intent.js +890 -0
- package/lib/plugin-catalog.js +3 -4
- package/lib/plugin-manager.js +0 -5
- package/lib/pr-bundle.js +186 -0
- package/lib/pr-monitor/differ.js +195 -0
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/gather.js +124 -0
- package/lib/pr-monitor/journal.js +299 -0
- package/lib/pr-monitor/monitor.js +146 -0
- package/lib/pr-monitor/render-sticky.js +157 -0
- package/lib/pr-monitor/watch-lifecycle.js +95 -0
- package/lib/pr-monitor/watch.js +247 -0
- package/lib/pr-pull.js +1273 -0
- package/lib/pr-shepherd.js +494 -0
- package/lib/pr-state-validator.js +59 -0
- package/lib/preflight/gates.js +237 -0
- package/lib/preflight/runner.js +116 -0
- package/lib/project-discovery.js +0 -53
- package/lib/project-memory.js +166 -0
- package/lib/protected-path-manifest.js +281 -0
- package/lib/protected-state-surfaces.js +387 -0
- package/lib/release-readiness.js +2089 -0
- package/lib/reset.js +59 -45
- package/lib/review-adapter.js +68 -0
- package/lib/rules-sync.js +260 -0
- package/lib/runtime-health.js +332 -23
- package/lib/safety-config-renderer.js +268 -0
- package/lib/setup-action-log.js +1 -7
- package/lib/setup.js +27 -65
- package/lib/shell-utils.js +76 -6
- package/lib/skills-sync.js +330 -0
- package/lib/smart-status/conflicts.js +205 -0
- package/lib/smart-status/scoring.js +191 -0
- package/lib/status/beads-snapshot.js +145 -0
- package/lib/status/presenter.js +216 -0
- package/lib/status/snapshot.js +186 -0
- package/lib/sync-backend.js +202 -0
- package/lib/untrusted-content.js +52 -0
- package/lib/upgrade-safety.js +199 -0
- package/lib/workflow/enforce-stage.js +298 -47
- package/lib/workflow/stage-transition.js +115 -0
- package/lib/workflow/stages.js +30 -6
- package/lib/workflow/state-manager.js +159 -14
- package/lib/workflow/state.js +23 -1
- package/lib/workflow-profiles.js +17 -5
- package/package.json +46 -36
- package/rules/documentation.md +19 -0
- package/rules/kernel-tracking.md +26 -0
- package/rules/security.md +22 -0
- package/rules/tdd.md +20 -0
- package/rules/workflow.md +27 -0
- package/scripts/auto-backing-issue.js +47 -0
- package/scripts/beads-context.sh +165 -22
- package/scripts/beads-migrate-to-dolt.sh +7 -0
- package/scripts/beads-upgrade-smoke.sh +284 -0
- package/scripts/behavioral-judge.sh +115 -11
- package/scripts/benchmark.js +349 -63
- package/scripts/bootstrap-windows-tools.sh +78 -0
- package/scripts/branch-protection.js +2 -3
- package/scripts/check-agents.js +34 -137
- package/scripts/commitlint.js +3 -1
- package/scripts/conflict-detect.sh +3 -0
- package/scripts/dep-guard-analyze.js +52 -17
- package/scripts/dep-guard-keyword-ripple.js +29 -0
- package/scripts/dep-guard-render-review.js +86 -0
- package/scripts/dep-guard.sh +64 -232
- package/scripts/file-index.sh +3 -0
- package/scripts/forge-team/lib/claim.sh +34 -18
- package/scripts/forge-team/lib/dashboard.sh +61 -86
- package/scripts/forge-team/lib/epic.sh +99 -263
- package/scripts/forge-team/lib/hooks.sh +26 -28
- package/scripts/forge-team/lib/identity.sh +4 -4
- package/scripts/forge-team/lib/sync-github.sh +144 -47
- package/scripts/forge-team/lib/verify.sh +93 -83
- package/scripts/forge-team/lib/workload.sh +41 -65
- package/scripts/forge-team/tests/claim.test.sh +25 -19
- package/scripts/forge-team/tests/dashboard.test.sh +31 -46
- package/scripts/forge-team/tests/epic.test.sh +52 -71
- package/scripts/forge-team/tests/hooks.test.sh +38 -50
- package/scripts/forge-team/tests/identity.test.sh +3 -3
- package/scripts/forge-team/tests/integration.test.sh +44 -66
- package/scripts/forge-team/tests/sync-github.test.sh +183 -79
- package/scripts/forge-team/tests/verify.test.sh +37 -46
- package/scripts/forge-team/tests/workflow-integration.test.sh +4 -4
- package/scripts/forge-team/tests/workload.test.sh +32 -66
- package/scripts/gen-command-manifest.js +153 -0
- package/scripts/gen-embedded-assets.mjs +129 -0
- package/scripts/install.ps1 +139 -0
- package/scripts/install.sh +268 -0
- package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
- package/scripts/lib/release-asset.mjs +84 -0
- package/scripts/parity-check.mjs +145 -0
- package/scripts/parity-check.test.mjs +58 -0
- package/scripts/pin-agentic-workflow-images.js +112 -0
- package/scripts/pr-coordinator.sh +3 -0
- package/scripts/preflight-sonar.eslint.config.mjs +44 -0
- package/scripts/preflight.sh +108 -0
- package/scripts/protected-state-check.js +104 -0
- package/scripts/smart-status-score.js +31 -0
- package/scripts/smart-status-sessions.js +51 -0
- package/scripts/smart-status.sh +117 -369
- package/scripts/spikes/config-race-bench.js +111 -0
- package/scripts/spikes/harness-capability-matrix.js +13 -0
- package/scripts/spikes/patch-anchor-stability-bench.js +125 -0
- package/scripts/spikes/protected-path-manifest.js +20 -0
- package/scripts/spikes/skill-auto-invoke-parity.js +292 -0
- package/scripts/sync-agent-skills.js +62 -0
- package/scripts/sync-agentic-workflow.js +48 -0
- package/scripts/sync-utils.sh +3 -0
- package/scripts/test-ci-shard.js +251 -0
- package/scripts/test-dashboard.js +188 -52
- package/scripts/test-full-suite.js +186 -0
- package/scripts/test-profile.js +278 -0
- package/scripts/test.js +302 -28
- package/scripts/validate.js +143 -0
- package/scripts/validate.sh +18 -1
- package/skills/claim-safety/SKILL.md +102 -0
- package/skills/claim-safety/evals/evals.json +46 -0
- package/{.github/prompts/dev.prompt.md → skills/dev/SKILL.md} +46 -52
- package/skills/dev/evals/evals.json +50 -0
- package/skills/hermes-forge/SKILL.md +185 -0
- package/skills/hermes-forge/evals/evals.json +46 -0
- package/skills/issue-basics/SKILL.md +111 -0
- package/skills/issue-basics/evals/evals.json +46 -0
- package/skills/kernel/SKILL.md +166 -0
- package/skills/kernel/evals/evals.json +50 -0
- package/skills/memory/SKILL.md +102 -0
- package/skills/parallel-deep-research/SKILL.md +14 -11
- package/skills/parallel-deep-research/evals/evals.json +11 -27
- package/{.github/prompts/plan.prompt.md → skills/plan/SKILL.md} +134 -159
- package/skills/plan/evals/evals.json +42 -0
- package/skills/research/SKILL.md +195 -0
- package/skills/research/evals/evals.json +42 -0
- package/{.github/prompts/review.prompt.md → skills/review/SKILL.md} +98 -62
- package/skills/review/evals/evals.json +42 -0
- package/skills/rollback/SKILL.md +110 -0
- package/skills/rollback/evals/evals.json +46 -0
- package/skills/rollback/references/methods.md +204 -0
- package/{.cursor/commands/rollback.md → skills/rollback/references/workflow-integration.md} +10 -284
- package/skills/shepherd/SKILL.md +66 -0
- package/skills/shepherd/evals/evals.json +42 -0
- package/skills/ship/SKILL.md +251 -0
- package/skills/ship/evals/evals.json +42 -0
- package/skills/smith/SKILL.md +142 -0
- package/skills/smith/evals/evals.json +46 -0
- package/skills/smith/references/autonomy-and-gates.md +94 -0
- package/{.github/prompts/sonarcloud.prompt.md → skills/sonarcloud/SKILL.md} +14 -3
- package/skills/sonarcloud/evals/evals.json +46 -0
- package/skills/sonarcloud-analysis/SKILL.md +18 -13
- package/skills/sonarcloud-analysis/evals/evals.json +11 -15
- package/skills/status/SKILL.md +102 -0
- package/skills/status/evals/evals.json +50 -0
- package/skills/triage-ready/SKILL.md +121 -0
- package/skills/triage-ready/evals/evals.json +42 -0
- package/{.github/prompts/validate.prompt.md → skills/validate/SKILL.md} +52 -29
- package/skills/validate/evals/evals.json +42 -0
- package/skills/verify/SKILL.md +299 -0
- package/skills/verify/evals/evals.json +50 -0
- package/.claude/commands/dev.md +0 -345
- package/.claude/commands/plan.md +0 -566
- package/.claude/commands/premerge.md +0 -186
- package/.claude/commands/research.md +0 -42
- package/.claude/commands/review.md +0 -451
- package/.claude/commands/rollback.md +0 -721
- package/.claude/commands/ship.md +0 -213
- package/.claude/commands/sonarcloud.md +0 -152
- package/.claude/commands/status.md +0 -90
- package/.claude/commands/validate.md +0 -288
- package/.claude/commands/verify.md +0 -269
- package/.claude/rules/workflow.md +0 -121
- package/.cline/workflows/dev.md +0 -342
- package/.cline/workflows/plan.md +0 -563
- package/.cline/workflows/premerge.md +0 -183
- package/.cline/workflows/research.md +0 -39
- package/.cline/workflows/review.md +0 -448
- package/.cline/workflows/rollback.md +0 -718
- package/.cline/workflows/ship.md +0 -210
- package/.cline/workflows/sonarcloud.md +0 -146
- package/.cline/workflows/status.md +0 -87
- package/.cline/workflows/validate.md +0 -285
- package/.cline/workflows/verify.md +0 -266
- package/.codex/config.toml +0 -11
- package/.codex/skills/dev/SKILL.md +0 -345
- package/.codex/skills/plan/SKILL.md +0 -566
- package/.codex/skills/premerge/SKILL.md +0 -186
- package/.codex/skills/research/SKILL.md +0 -42
- package/.codex/skills/review/SKILL.md +0 -451
- package/.codex/skills/rollback/SKILL.md +0 -721
- package/.codex/skills/ship/SKILL.md +0 -213
- package/.codex/skills/sonarcloud/SKILL.md +0 -149
- package/.codex/skills/status/SKILL.md +0 -90
- package/.codex/skills/validate/SKILL.md +0 -288
- package/.codex/skills/verify/SKILL.md +0 -269
- package/.cursor/commands/dev.md +0 -342
- package/.cursor/commands/plan.md +0 -563
- package/.cursor/commands/premerge.md +0 -183
- package/.cursor/commands/research.md +0 -39
- package/.cursor/commands/review.md +0 -448
- package/.cursor/commands/ship.md +0 -210
- package/.cursor/commands/sonarcloud.md +0 -146
- package/.cursor/commands/status.md +0 -87
- package/.cursor/commands/validate.md +0 -285
- package/.cursor/commands/verify.md +0 -266
- package/.cursorrules +0 -149
- package/.github/prompts/premerge.prompt.md +0 -188
- package/.github/prompts/research.prompt.md +0 -44
- package/.github/prompts/rollback.prompt.md +0 -723
- package/.github/prompts/ship.prompt.md +0 -215
- package/.github/prompts/status.prompt.md +0 -92
- package/.github/prompts/verify.prompt.md +0 -271
- package/.github/workflows/beads-to-github.yml +0 -56
- package/.github/workflows/github-to-beads.yml +0 -97
- package/.kilocode/workflows/dev.md +0 -346
- package/.kilocode/workflows/plan.md +0 -567
- package/.kilocode/workflows/premerge.md +0 -187
- package/.kilocode/workflows/research.md +0 -43
- package/.kilocode/workflows/review.md +0 -452
- package/.kilocode/workflows/rollback.md +0 -722
- package/.kilocode/workflows/ship.md +0 -214
- package/.kilocode/workflows/sonarcloud.md +0 -150
- package/.kilocode/workflows/status.md +0 -91
- package/.kilocode/workflows/validate.md +0 -289
- package/.kilocode/workflows/verify.md +0 -270
- package/.opencode/commands/dev.md +0 -345
- package/.opencode/commands/plan.md +0 -566
- package/.opencode/commands/premerge.md +0 -186
- package/.opencode/commands/research.md +0 -42
- package/.opencode/commands/review.md +0 -451
- package/.opencode/commands/rollback.md +0 -721
- package/.opencode/commands/ship.md +0 -213
- package/.opencode/commands/sonarcloud.md +0 -149
- package/.opencode/commands/status.md +0 -90
- package/.opencode/commands/validate.md +0 -288
- package/.opencode/commands/verify.md +0 -269
- package/.roo/commands/dev.md +0 -346
- package/.roo/commands/plan.md +0 -567
- package/.roo/commands/premerge.md +0 -187
- package/.roo/commands/research.md +0 -43
- package/.roo/commands/review.md +0 -452
- package/.roo/commands/rollback.md +0 -722
- package/.roo/commands/ship.md +0 -214
- package/.roo/commands/sonarcloud.md +0 -150
- package/.roo/commands/status.md +0 -91
- package/.roo/commands/validate.md +0 -289
- package/.roo/commands/verify.md +0 -270
- package/docs/BEADS_GITHUB_SYNC.md +0 -255
- package/docs/GREPTILE_SETUP.md +0 -400
- package/docs/MANUAL_REVIEW_GUIDE.md +0 -106
- package/docs/SETUP.md +0 -663
- package/docs/VALIDATION.md +0 -363
- package/lib/agents/cline.plugin.json +0 -29
- package/lib/agents/copilot.plugin.json +0 -24
- package/lib/agents/kilocode.plugin.json +0 -22
- package/lib/agents/opencode.plugin.json +0 -23
- package/lib/agents/roo.plugin.json +0 -30
- package/lib/beads-health-check.js +0 -143
- package/lib/commands/commands-reset.js +0 -147
- package/opencode.json +0 -67
- package/scripts/beads-context.test.js +0 -567
- package/scripts/github-beads-sync/comment.mjs +0 -64
- package/scripts/github-beads-sync/config.mjs +0 -148
- package/scripts/github-beads-sync/github-api.mjs +0 -131
- package/scripts/github-beads-sync/index.mjs +0 -332
- package/scripts/github-beads-sync/label-mapper.mjs +0 -54
- package/scripts/github-beads-sync/mapping.mjs +0 -78
- package/scripts/github-beads-sync/reverse-sync-cli.mjs +0 -31
- package/scripts/github-beads-sync/reverse-sync.mjs +0 -138
- package/scripts/github-beads-sync/run-bd.mjs +0 -161
- package/scripts/github-beads-sync/sanitize.mjs +0 -121
- package/scripts/github-beads-sync.config.json +0 -26
- 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
|
+
};
|