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,581 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `forge serve [--port N] [--open]`
|
|
5
|
+
*
|
|
6
|
+
* A minimal LOCAL loopback companion that turns the static dashboard
|
|
7
|
+
* (web/dashboard/) into an interactive one. It is a DUMB RELAY: the browser
|
|
8
|
+
* never mutates the kernel directly — it fires the SAME `forge` verbs the CLI
|
|
9
|
+
* and agents use, in-process, through the existing command dispatch. ALL
|
|
10
|
+
* validation lives in the verb/broker handlers; this server adds no parallel
|
|
11
|
+
* validator.
|
|
12
|
+
*
|
|
13
|
+
* Endpoints:
|
|
14
|
+
* GET /health — capability probe (app.js flips SNAPSHOT -> LIVE).
|
|
15
|
+
* GET /data.json — current snapshot (regenerated on demand, debounced).
|
|
16
|
+
* POST /api/mutation — the write path. Body { token, verb, args }.
|
|
17
|
+
* GET /* (static) — the dashboard assets.
|
|
18
|
+
*
|
|
19
|
+
* Security model (see docs/work/2026-07-13-forge-serve/design.md):
|
|
20
|
+
* - Binds 127.0.0.1 ONLY (never a public interface).
|
|
21
|
+
* - Per-run random token, minted at startup, injected into the served page
|
|
22
|
+
* URL fragment (#token=…), REQUIRED on every POST (constant-time compare).
|
|
23
|
+
* - POST also rejects any non-loopback Host / foreign Origin (CSRF fence).
|
|
24
|
+
* - No accounts, no cloud, no daemon; the token dies with the process.
|
|
25
|
+
* - Zero agent/token cost: reads SQLite/kernel + writes via the broker; it
|
|
26
|
+
* NEVER spawns an agent.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const http = require('node:http');
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
const crypto = require('node:crypto');
|
|
33
|
+
const { spawn } = require('node:child_process');
|
|
34
|
+
|
|
35
|
+
// Shared-machine hardening (single-instance lock, owner-only perms + audit,
|
|
36
|
+
// hash-chained mutation journal). No dependency on serve.js, so a top-level
|
|
37
|
+
// require is safe (unlike _registry — see the lazy-require note below).
|
|
38
|
+
const security = require('./_serve-security');
|
|
39
|
+
|
|
40
|
+
// NOTE: `_registry` / `_resolve-command-opts` are require()d LAZILY at call time
|
|
41
|
+
// (inside routeMutation / registry), never destructured at module top. serve.js
|
|
42
|
+
// is listed in the static command manifest, so `_manifest` require()s serve.js
|
|
43
|
+
// while serve.js is itself mid-load requiring `_registry` — a top-level
|
|
44
|
+
// destructure would capture `_registry`'s not-yet-assigned exports (undefined).
|
|
45
|
+
// A lazy require resolves the fully-initialized module every time.
|
|
46
|
+
|
|
47
|
+
const COMMANDS_DIR = __dirname;
|
|
48
|
+
const DEFAULT_PORT = 8730;
|
|
49
|
+
const MAX_BODY = 1024 * 1024; // 1 MB — issue/comment bodies are user data.
|
|
50
|
+
const SNAPSHOT_TTL_MS = 3000; // debounce background regeneration.
|
|
51
|
+
const GEN_TIMEOUT_MS = 30000;
|
|
52
|
+
|
|
53
|
+
// verb -> forge command. Every verb is an EXISTING handler; issue verbs append
|
|
54
|
+
// --json so the handler returns the machine envelope. No shell is ever invoked,
|
|
55
|
+
// so issue/comment bodies are inert user data (the broker stores them).
|
|
56
|
+
const VERB_MAP = {
|
|
57
|
+
'issue.create': { command: 'create', json: true },
|
|
58
|
+
'issue.update': { command: 'update', json: true },
|
|
59
|
+
'issue.close': { command: 'close', json: true },
|
|
60
|
+
'issue.comment': { command: 'comment', json: true },
|
|
61
|
+
gate: { command: 'gate', json: false },
|
|
62
|
+
role: { command: 'role', json: false },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const MIME = {
|
|
66
|
+
'.html': 'text/html; charset=utf-8',
|
|
67
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
68
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
69
|
+
'.css': 'text/css; charset=utf-8',
|
|
70
|
+
'.json': 'application/json; charset=utf-8',
|
|
71
|
+
'.svg': 'image/svg+xml',
|
|
72
|
+
'.map': 'application/json; charset=utf-8',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Generated bundles that index.html <script>-loads. When absent (a fresh
|
|
76
|
+
// worktree gitignores them), return an empty 200 so the page cleanly falls to
|
|
77
|
+
// the live /data.json path with no console error.
|
|
78
|
+
const OPTIONAL_BUNDLES = new Set(['/snapshot.js', '/docs.js']);
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Arg parsing
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function parseServeArgs(args = [], flags = {}) {
|
|
85
|
+
let port = Number(flags.port) || DEFAULT_PORT;
|
|
86
|
+
let open = Boolean(flags.open);
|
|
87
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
88
|
+
const token = args[i];
|
|
89
|
+
if (token === '--open') open = true;
|
|
90
|
+
else if (token === '--port') { port = Number(args[i + 1]) || port; i += 1; }
|
|
91
|
+
else if (typeof token === 'string' && token.startsWith('--port=')) {
|
|
92
|
+
port = Number(token.slice('--port='.length)) || port;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { port, open };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Security helpers
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function mintToken() {
|
|
103
|
+
return crypto.randomBytes(32).toString('hex');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function verifyToken(provided, expected) {
|
|
107
|
+
if (typeof provided !== 'string' || provided.length !== expected.length) return false;
|
|
108
|
+
return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function hostname(hostHeader) {
|
|
112
|
+
if (typeof hostHeader !== 'string') return '';
|
|
113
|
+
// Strip the :port; handle bare IPv6 defensively.
|
|
114
|
+
const lastColon = hostHeader.lastIndexOf(':');
|
|
115
|
+
const host = lastColon > hostHeader.indexOf(']') ? hostHeader.slice(0, lastColon) : hostHeader;
|
|
116
|
+
return host.replace(/^\[|\]$/g, '').toLowerCase();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
120
|
+
|
|
121
|
+
function isLoopbackRequest(req) {
|
|
122
|
+
if (!LOOPBACK_HOSTS.has(hostname(req.headers.host))) return false;
|
|
123
|
+
const origin = req.headers.origin;
|
|
124
|
+
if (typeof origin === 'string' && origin.length > 0) {
|
|
125
|
+
try {
|
|
126
|
+
if (!LOOPBACK_HOSTS.has(new URL(origin).hostname.toLowerCase())) return false;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Extract the per-run token from a GET request: prefer the `X-Forge-Token`
|
|
135
|
+
// header, fall back to a `?token=` query param. The served page holds the token
|
|
136
|
+
// in its URL fragment and attaches it on the data fetch. Mirrors the POST path,
|
|
137
|
+
// which reads the token from the JSON body.
|
|
138
|
+
function readRequestToken(req) {
|
|
139
|
+
const header = req.headers['x-forge-token'];
|
|
140
|
+
if (typeof header === 'string' && header.length > 0) return header;
|
|
141
|
+
try {
|
|
142
|
+
return new URL(req.url || '/', 'http://127.0.0.1').searchParams.get('token') || '';
|
|
143
|
+
} catch {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Response helpers
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
function sendJson(res, status, payload) {
|
|
153
|
+
const body = JSON.stringify(payload);
|
|
154
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
155
|
+
res.end(body);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sendText(res, status, text, contentType = 'text/plain; charset=utf-8') {
|
|
159
|
+
res.writeHead(status, { 'Content-Type': contentType });
|
|
160
|
+
res.end(text);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function readBody(req, limit = MAX_BODY) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
let size = 0;
|
|
166
|
+
const chunks = [];
|
|
167
|
+
req.on('data', (chunk) => {
|
|
168
|
+
size += chunk.length;
|
|
169
|
+
if (size > limit) {
|
|
170
|
+
reject(new Error('request body too large'));
|
|
171
|
+
req.destroy();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
chunks.push(chunk);
|
|
175
|
+
});
|
|
176
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
177
|
+
req.on('error', reject);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Mutation relay — reuse the CLI's own dispatch, never a parallel validator.
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
let SHARED_REGISTRY = null;
|
|
186
|
+
function registry(deps = {}) {
|
|
187
|
+
if (deps?.registry) return deps.registry;
|
|
188
|
+
const loadCommands = deps.loadCommands || require('./_registry').loadCommands;
|
|
189
|
+
if (!SHARED_REGISTRY) SHARED_REGISTRY = loadCommands(COMMANDS_DIR);
|
|
190
|
+
return SHARED_REGISTRY;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validateVerbArgs(verb, args) {
|
|
194
|
+
const spec = VERB_MAP[verb];
|
|
195
|
+
if (!spec) {
|
|
196
|
+
return { error: `Unknown verb '${verb}'. Allowed: ${Object.keys(VERB_MAP).join(', ')}` };
|
|
197
|
+
}
|
|
198
|
+
if (!Array.isArray(args) || !args.every((a) => typeof a === 'string')) {
|
|
199
|
+
return { error: 'args must be an array of strings' };
|
|
200
|
+
}
|
|
201
|
+
return { spec };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Route a { verb, args } envelope to the EXISTING forge command handler,
|
|
205
|
+
// in-process. Returns { ok, output, error } — the handler's own success/
|
|
206
|
+
// rejection is surfaced verbatim (unknown gate, locked gate, invalid role, an
|
|
207
|
+
// issue-command-contract validation error).
|
|
208
|
+
async function routeMutation(verb, args, projectRoot, deps = {}) {
|
|
209
|
+
const { spec, error } = validateVerbArgs(verb, args);
|
|
210
|
+
if (error) return { ok: false, error };
|
|
211
|
+
|
|
212
|
+
const argv = spec.json && !args.includes('--json') ? [...args, '--json'] : args;
|
|
213
|
+
const resolveOpts = deps.resolveCommandOpts || require('./_resolve-command-opts').resolveCommandOpts;
|
|
214
|
+
const exec = deps.executeCommand || require('./_registry').executeCommand;
|
|
215
|
+
|
|
216
|
+
const { commandOpts, args: dispatchArgs } = await resolveOpts(
|
|
217
|
+
spec.command,
|
|
218
|
+
argv,
|
|
219
|
+
{ env: process.env, projectRoot },
|
|
220
|
+
);
|
|
221
|
+
const result = await exec(
|
|
222
|
+
registry(deps).commands,
|
|
223
|
+
spec.command,
|
|
224
|
+
dispatchArgs,
|
|
225
|
+
{},
|
|
226
|
+
projectRoot,
|
|
227
|
+
{ commandOpts },
|
|
228
|
+
);
|
|
229
|
+
return {
|
|
230
|
+
ok: result && result.success !== false,
|
|
231
|
+
output: result && typeof result.output === 'string' ? result.output : undefined,
|
|
232
|
+
error: result ? (result.error || undefined) : 'no result',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Snapshot (GET /data.json) — regenerate on demand, debounced.
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
function defaultGenerate(projectRoot, dashboardDir) {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const script = path.join(dashboardDir, 'generate-snapshot.mjs');
|
|
243
|
+
const child = spawn(process.execPath, [script], { cwd: projectRoot, stdio: 'ignore' });
|
|
244
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error('snapshot generation timed out')); }, GEN_TIMEOUT_MS);
|
|
245
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
246
|
+
child.on('exit', (code) => {
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
if (code === 0) resolve();
|
|
249
|
+
else reject(new Error(`snapshot generator exited ${code}`));
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readBakedSnapshot(dashboardDir) {
|
|
255
|
+
const dataPath = path.join(dashboardDir, 'data.json');
|
|
256
|
+
if (fs.existsSync(dataPath)) return fs.readFileSync(dataPath);
|
|
257
|
+
return Buffer.from('{}');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Regenerate the snapshot (bounded, single-flight). Best-effort: a generator
|
|
261
|
+
// failure keeps the last baked data.json rather than surfacing an error.
|
|
262
|
+
function regenerate(ctx) {
|
|
263
|
+
const cache = ctx.snapshot;
|
|
264
|
+
if (cache.inFlight) return cache.inFlight;
|
|
265
|
+
cache.inFlight = (async () => {
|
|
266
|
+
try { await cache.generate(ctx.projectRoot, ctx.dashboardDir); } catch { /* keep baked */ }
|
|
267
|
+
cache.buffer = readBakedSnapshot(ctx.dashboardDir);
|
|
268
|
+
cache.lastGen = Date.now();
|
|
269
|
+
})().finally(() => { cache.inFlight = null; });
|
|
270
|
+
return cache.inFlight;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Reads NEVER block on the heavy generator (it shells forge/git/gh): serve the
|
|
274
|
+
// baked snapshot immediately and refresh in the BACKGROUND when stale. Only a
|
|
275
|
+
// cold start with nothing baked does one bounded synchronous generation, so the
|
|
276
|
+
// first paint isn't empty. Mutations call regenerate() directly (see
|
|
277
|
+
// handleMutation) so a post-write refetch is fresh.
|
|
278
|
+
async function getSnapshot(ctx) {
|
|
279
|
+
const cache = ctx.snapshot;
|
|
280
|
+
const hasBaked = cache.buffer || fs.existsSync(path.join(ctx.dashboardDir, 'data.json'));
|
|
281
|
+
if (!hasBaked) {
|
|
282
|
+
await regenerate(ctx);
|
|
283
|
+
return cache.buffer;
|
|
284
|
+
}
|
|
285
|
+
if (!cache.buffer) cache.buffer = readBakedSnapshot(ctx.dashboardDir);
|
|
286
|
+
if (Date.now() - cache.lastGen > SNAPSHOT_TTL_MS && !cache.inFlight) regenerate(ctx);
|
|
287
|
+
return cache.buffer;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Static host
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
// Map a raw request URL to a SAFE absolute path inside `dashboardDir`, or null
|
|
295
|
+
// if it would escape (path traversal) or is malformed. This is the ONLY place a
|
|
296
|
+
// user-controlled URL is turned into a filesystem path — every fs access must go
|
|
297
|
+
// through the value it returns. The pattern (decode -> strip query/hash -> reject
|
|
298
|
+
// null bytes -> resolve a GUARANTEED-RELATIVE `'.' + path` under a pre-resolved
|
|
299
|
+
// root -> containment check on that same resolved value) is what breaks the taint
|
|
300
|
+
// flow both for real attackers and for static analysis (CodeQL).
|
|
301
|
+
function resolveStaticPath(dashboardDir, urlPath) {
|
|
302
|
+
let clean;
|
|
303
|
+
try {
|
|
304
|
+
// Drop query (?) and fragment (#), then percent-decode. A malformed escape
|
|
305
|
+
// (e.g. a lone '%') throws — treat that as "not found".
|
|
306
|
+
clean = decodeURIComponent(String(urlPath).split('?')[0].split('#')[0]);
|
|
307
|
+
} catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
// Reject NUL bytes outright (can truncate paths at the syscall boundary).
|
|
311
|
+
if (clean.indexOf('\0') !== -1) return null;
|
|
312
|
+
// Normalize to a single leading slash so the join below is always relative.
|
|
313
|
+
const normalized = '/' + clean.replace(/^\/+/, '');
|
|
314
|
+
const rel = normalized === '/' ? '/index.html' : normalized;
|
|
315
|
+
// Resolve the root FIRST, then join a guaranteed-relative path ('.' + rel).
|
|
316
|
+
// Prefixing with '.' means path.resolve can never treat `rel` as absolute and
|
|
317
|
+
// discard the root, so the result is always rooted at `root`.
|
|
318
|
+
const root = path.resolve(dashboardDir);
|
|
319
|
+
const resolved = path.resolve(root, '.' + rel);
|
|
320
|
+
// Containment check on the EXACT value that will reach the fs calls. If it does
|
|
321
|
+
// not stay within root, refuse (path traversal).
|
|
322
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
|
|
323
|
+
return resolved;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function serveStatic(res, dashboardDir, urlPath) {
|
|
327
|
+
const abs = resolveStaticPath(dashboardDir, urlPath);
|
|
328
|
+
if (!abs) return sendText(res, 403, 'forbidden');
|
|
329
|
+
if (!fs.existsSync(abs) || fs.statSync(abs).isDirectory()) {
|
|
330
|
+
// Absent optional bundle -> empty 200 so live mode has no console noise.
|
|
331
|
+
if (OPTIONAL_BUNDLES.has(urlPath.split('?')[0])) {
|
|
332
|
+
return sendText(res, 200, '// forge serve: live mode (bundle not baked)\n', MIME['.js']);
|
|
333
|
+
}
|
|
334
|
+
return sendText(res, 404, 'not found');
|
|
335
|
+
}
|
|
336
|
+
const type = MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream';
|
|
337
|
+
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
|
338
|
+
return res.end(fs.readFileSync(abs));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Request router
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
async function handleMutation(req, res, ctx) {
|
|
346
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' });
|
|
347
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'forbidden: non-loopback request' });
|
|
348
|
+
|
|
349
|
+
let parsed;
|
|
350
|
+
try {
|
|
351
|
+
parsed = JSON.parse(await readBody(req));
|
|
352
|
+
} catch {
|
|
353
|
+
return sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
354
|
+
}
|
|
355
|
+
if (!parsed || !verifyToken(parsed.token, ctx.token)) {
|
|
356
|
+
return sendJson(res, 403, { ok: false, error: 'invalid or missing token' });
|
|
357
|
+
}
|
|
358
|
+
const result = await routeMutation(parsed.verb, parsed.args || [], ctx.projectRoot, ctx.deps);
|
|
359
|
+
// Tamper-evident audit trail: record EVERY mutation attempt (accepted or
|
|
360
|
+
// rejected) into the hash-chained journal. The `actor`/`origin` are advisory
|
|
361
|
+
// coordination metadata only — loopback requests are not authenticated beyond
|
|
362
|
+
// the per-run token, so this records WHAT the server did, not a proven WHO.
|
|
363
|
+
// Best-effort: a journal write must never break the response.
|
|
364
|
+
try {
|
|
365
|
+
security.appendJournal(ctx.projectRoot, {
|
|
366
|
+
verb: typeof parsed.verb === 'string' ? parsed.verb : null,
|
|
367
|
+
ok: Boolean(result.ok),
|
|
368
|
+
actor: 'local',
|
|
369
|
+
origin: typeof req.headers.origin === 'string' ? req.headers.origin : null,
|
|
370
|
+
});
|
|
371
|
+
} catch { /* journal is best-effort; never fail the mutation on it */ }
|
|
372
|
+
// On a successful write, refresh the baked snapshot BEFORE responding so the
|
|
373
|
+
// client's follow-up /data.json refetch reflects the change. Best-effort.
|
|
374
|
+
if (result.ok) { try { await regenerate(ctx); } catch { /* keep baked */ } }
|
|
375
|
+
return sendJson(res, result.ok ? 200 : 422, result);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function handleRequest(req, res, ctx) {
|
|
379
|
+
const urlPath = (req.url || '/').split('?')[0];
|
|
380
|
+
if (urlPath === '/health') {
|
|
381
|
+
return sendJson(res, 200, { ok: true, forge_serve: true, version: 1 });
|
|
382
|
+
}
|
|
383
|
+
if (urlPath === '/api/mutation') {
|
|
384
|
+
return handleMutation(req, res, ctx);
|
|
385
|
+
}
|
|
386
|
+
if (urlPath === '/data.json') {
|
|
387
|
+
// The snapshot carries the FULL issue/message data, so an unauthenticated
|
|
388
|
+
// read by any other local user/process is a real disclosure. Gate it with
|
|
389
|
+
// the SAME fence as POST /api/mutation: loopback-only + valid per-run token
|
|
390
|
+
// (constant-time compare). /health stays token-free (capability probe only).
|
|
391
|
+
if (!isLoopbackRequest(req) || !verifyToken(readRequestToken(req), ctx.token)) {
|
|
392
|
+
return sendJson(res, 403, { ok: false, error: 'forbidden: invalid or missing token' });
|
|
393
|
+
}
|
|
394
|
+
const buf = await getSnapshot(ctx);
|
|
395
|
+
res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-store' });
|
|
396
|
+
return res.end(buf);
|
|
397
|
+
}
|
|
398
|
+
if (req.method !== 'GET') return sendText(res, 405, 'method not allowed');
|
|
399
|
+
return serveStatic(res, ctx.dashboardDir, req.url || '/');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function buildContext(projectRoot, dashboardDir, token, deps = {}) {
|
|
403
|
+
return {
|
|
404
|
+
projectRoot,
|
|
405
|
+
dashboardDir,
|
|
406
|
+
token,
|
|
407
|
+
deps,
|
|
408
|
+
snapshot: {
|
|
409
|
+
buffer: null,
|
|
410
|
+
lastGen: 0,
|
|
411
|
+
inFlight: null,
|
|
412
|
+
generate: deps.generate || defaultGenerate,
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function createServeServer(ctx) {
|
|
418
|
+
return http.createServer((req, res) => {
|
|
419
|
+
handleRequest(req, res, ctx).catch((err) => {
|
|
420
|
+
if (!res.headersSent) sendJson(res, 500, { ok: false, error: err.message });
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// Startup
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
// Resolve the OS "open a URL" helper to an ABSOLUTE path — never a bare command
|
|
430
|
+
// name resolved through $PATH. A writable PATH entry could otherwise shadow the
|
|
431
|
+
// executable (S4036); an absolute path in a fixed system directory forecloses it.
|
|
432
|
+
function browserOpener() {
|
|
433
|
+
if (process.platform === 'win32') {
|
|
434
|
+
const root = process.env.SystemRoot || 'C:\\Windows';
|
|
435
|
+
return { file: path.join(root, 'System32', 'cmd.exe'), args: ['/c', 'start', ''] };
|
|
436
|
+
}
|
|
437
|
+
if (process.platform === 'darwin') return { file: '/usr/bin/open', args: [] };
|
|
438
|
+
return { file: '/usr/bin/xdg-open', args: [] };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function openBrowser(url) {
|
|
442
|
+
try {
|
|
443
|
+
const { file, args } = browserOpener();
|
|
444
|
+
const child = spawn(file, [...args, url], { detached: true, stdio: 'ignore' });
|
|
445
|
+
// spawn() failures (e.g. the opener binary is missing) surface ASYNChronously
|
|
446
|
+
// as an 'error' event, NOT via the try/catch — an unhandled one would crash
|
|
447
|
+
// `forge serve`. Opening a browser is best-effort; swallow it (URL is printed).
|
|
448
|
+
child.on('error', () => { /* best-effort; the URL is always printed */ });
|
|
449
|
+
child.unref();
|
|
450
|
+
} catch {
|
|
451
|
+
// Opening a browser is best-effort; the URL is always printed.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Register signal handlers that release the single-instance lock, close the
|
|
456
|
+
// server, and resolve the handler's promise, so `forge serve` exits cleanly on
|
|
457
|
+
// Ctrl-C without leaving a stale lock behind. `onClose` releases the lock.
|
|
458
|
+
function installServeShutdown(server, resolve, onClose = () => {}) {
|
|
459
|
+
let done = false;
|
|
460
|
+
const shutdown = () => {
|
|
461
|
+
if (done) return; // SIGINT then SIGTERM must not double-release/close.
|
|
462
|
+
done = true;
|
|
463
|
+
try { onClose(); } catch { /* best effort */ }
|
|
464
|
+
server.close(() => resolve({ success: true, output: 'forge serve stopped.' }));
|
|
465
|
+
};
|
|
466
|
+
process.once('SIGINT', shutdown);
|
|
467
|
+
process.once('SIGTERM', shutdown);
|
|
468
|
+
// Last-ditch release if the process exits some other way (uncaught, exit()).
|
|
469
|
+
process.once('exit', () => { try { onClose(); } catch { /* best effort */ } });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Print the loopback URL (token in the fragment), optionally open a browser, and
|
|
473
|
+
// wire shutdown. Extracted so startListening's listen-callback stays shallow.
|
|
474
|
+
function announceServe(server, token, options, resolve) {
|
|
475
|
+
const url = `http://127.0.0.1:${server.address().port}/#token=${token}`;
|
|
476
|
+
process.stdout.write(
|
|
477
|
+
`\nforge serve — interactive dashboard (loopback only)\n ${url}\n`
|
|
478
|
+
+ ' Token dies with this process. Press Ctrl-C to stop.\n\n',
|
|
479
|
+
);
|
|
480
|
+
if (options.open) openBrowser(url);
|
|
481
|
+
installServeShutdown(server, resolve, options.onClose);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function startListening(server, port, token, options = {}) {
|
|
485
|
+
return new Promise((resolve, reject) => {
|
|
486
|
+
server.on('error', reject);
|
|
487
|
+
server.listen(port, '127.0.0.1', () => announceServe(server, token, options, resolve));
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Startup path audit: warn LOUDLY if the serve state dir / lock / journal are
|
|
492
|
+
// group- or other-readable on a shared box (POSIX). On Windows the mode bits do
|
|
493
|
+
// not reflect ACLs, so we print an honest one-line caveat instead of a false
|
|
494
|
+
// alarm. Never blocks startup — hardening is advisory, not a gate.
|
|
495
|
+
function auditServePaths(projectRoot) {
|
|
496
|
+
const audit = security.auditPaths([
|
|
497
|
+
security.serveStateDir(projectRoot),
|
|
498
|
+
security.lockPath(projectRoot),
|
|
499
|
+
security.journalPath(projectRoot),
|
|
500
|
+
]);
|
|
501
|
+
if (!audit.ok) {
|
|
502
|
+
const paths = audit.offenders.map((o) => o.path).join(', ');
|
|
503
|
+
process.stderr.write(
|
|
504
|
+
`\n⚠ forge serve: sensitive path(s) are readable by other users: ${paths}\n`
|
|
505
|
+
+ ' On a shared machine another local user could read the token/journal.\n'
|
|
506
|
+
+ ' Tighten perms (chmod 700 .forge/serve) or run on a single-user box.\n\n',
|
|
507
|
+
);
|
|
508
|
+
} else if (audit.results.some((r) => r.exists && r.platformCaveat)) {
|
|
509
|
+
process.stderr.write(
|
|
510
|
+
'note: forge serve stores its lock/journal under .forge/serve. On Windows,'
|
|
511
|
+
+ ' file perms are enforced by NTFS ACLs, not POSIX mode bits — keep the'
|
|
512
|
+
+ ' project on a single-user profile for the strongest isolation.\n',
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Verify the hash-chained mutation journal at startup so the tamper-evident
|
|
518
|
+
// control actually RUNS on every serve (not just in tests). A broken chain means
|
|
519
|
+
// a past record was edited or a non-tail record removed since we last wrote it —
|
|
520
|
+
// warn LOUDLY but NEVER block startup (integrity signal, not a gate). `warn` is
|
|
521
|
+
// injectable for testing; production writes to stderr. Returns the verify result.
|
|
522
|
+
function verifyJournalAtStartup(projectRoot, warn = (m) => process.stderr.write(m)) {
|
|
523
|
+
const result = security.verifyJournal(projectRoot);
|
|
524
|
+
if (!result.ok) {
|
|
525
|
+
warn(
|
|
526
|
+
`\n⚠ forge serve: mutation journal integrity check FAILED at record #${result.brokenAt} `
|
|
527
|
+
+ `(${result.reason}).\n`
|
|
528
|
+
+ ` A past entry in ${security.journalPath(projectRoot)} was edited or removed since it was written.\n`
|
|
529
|
+
+ ' The tamper-evident history is compromised — investigate before trusting it.\n\n',
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
return result;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function handler(args, flags = {}, projectRoot = process.cwd(), _opts = {}) {
|
|
536
|
+
const { port, open } = parseServeArgs(args, flags);
|
|
537
|
+
const dashboardDir = path.join(projectRoot, 'web', 'dashboard');
|
|
538
|
+
if (!fs.existsSync(path.join(dashboardDir, 'index.html'))) {
|
|
539
|
+
return { success: false, error: `Dashboard not found at ${dashboardDir}. Run from the repo root.` };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Single-instance guard: refuse to start a rogue second server for this
|
|
543
|
+
// project (a live holder blocks; a stale lock from a dead PID is reclaimed).
|
|
544
|
+
const lock = security.acquireLock(projectRoot, { port });
|
|
545
|
+
if (!lock.ok) {
|
|
546
|
+
return {
|
|
547
|
+
success: false,
|
|
548
|
+
error: `forge serve is already running for this project (pid ${lock.held.pid}`
|
|
549
|
+
+ `${lock.held.port ? `, port ${lock.held.port}` : ''}). Stop it before starting another, `
|
|
550
|
+
+ 'or a second server would silently port-squat. If that process is dead, remove '
|
|
551
|
+
+ `${security.lockPath(projectRoot)}.`,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
auditServePaths(projectRoot);
|
|
555
|
+
verifyJournalAtStartup(projectRoot);
|
|
556
|
+
|
|
557
|
+
const token = mintToken();
|
|
558
|
+
const ctx = buildContext(projectRoot, dashboardDir, token);
|
|
559
|
+
const server = createServeServer(ctx);
|
|
560
|
+
return startListening(server, port, token, {
|
|
561
|
+
open,
|
|
562
|
+
onClose: () => security.releaseLock(projectRoot),
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
module.exports = {
|
|
567
|
+
name: 'serve',
|
|
568
|
+
description: 'Serve the interactive dashboard over 127.0.0.1 (trigger forge verbs from the browser)',
|
|
569
|
+
usage: 'forge serve [--port N] [--open]',
|
|
570
|
+
handler,
|
|
571
|
+
// Exposed for tests + reuse (in-process, no socket).
|
|
572
|
+
routeMutation,
|
|
573
|
+
parseServeArgs,
|
|
574
|
+
buildContext,
|
|
575
|
+
createServeServer,
|
|
576
|
+
verifyToken,
|
|
577
|
+
isLoopbackRequest,
|
|
578
|
+
resolveStaticPath,
|
|
579
|
+
verifyJournalAtStartup,
|
|
580
|
+
VERB_MAP,
|
|
581
|
+
};
|