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