forge-workflow 0.0.10 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (454) hide show
  1. package/.claude/rules/{greptile-review-process.md → review-process.md} +56 -41
  2. package/.claude/scripts/{greptile-resolve.sh → review-resolve.sh} +13 -3
  3. package/.cursor/rules/permissions-guidance.mdc +2 -2
  4. package/.forge/hooks/check-tdd.js +3 -0
  5. package/.forge/hooks/forge-native-hook.js +245 -0
  6. package/.forge/protected-paths.yaml +157 -0
  7. package/AGENTS.md +150 -61
  8. package/CHANGELOG.md +681 -0
  9. package/CLAUDE.md +9 -118
  10. package/QUICKSTART.md +171 -0
  11. package/README.md +271 -363
  12. package/bin/forge-cmd.js +120 -9
  13. package/bin/forge-preflight.js +26 -5
  14. package/bin/forge.js +461 -489
  15. package/docs/INDEX.md +93 -0
  16. package/docs/PROJECT_DESIGN.md +685 -0
  17. package/docs/architecture/index.md +66 -0
  18. package/docs/architecture/notes/README.md +35 -0
  19. package/docs/architecture/subsystems/README.md +46 -0
  20. package/docs/forge/TOOLCHAIN.md +670 -0
  21. package/docs/forge/VALIDATION.md +82 -0
  22. package/docs/{AGENT_INSTALL_PROMPT.md → guides/AGENT_INSTALL_PROMPT.md} +3 -3
  23. package/docs/guides/BEADS_GITHUB_SYNC.md +32 -0
  24. package/docs/{ENHANCED_ONBOARDING.md → guides/ENHANCED_ONBOARDING.md} +16 -12
  25. package/docs/guides/GREPTILE_SETUP.md +46 -0
  26. package/docs/guides/MANUAL_REVIEW_GUIDE.md +58 -0
  27. package/docs/guides/MIGRATION.md +56 -0
  28. package/docs/guides/SETUP.md +118 -0
  29. package/docs/guides/SUPPORT.md +185 -0
  30. package/docs/guides/WORKFLOW_TEMPLATES.md +74 -0
  31. package/docs/guides/memory-backends.md +183 -0
  32. package/docs/reference/ADAPTERS.md +128 -0
  33. package/docs/reference/AGENT_SKILL_PARITY.md +175 -0
  34. package/docs/reference/COMMANDS.md +205 -0
  35. package/docs/reference/DECISION_DRIFT_GUARDS.md +97 -0
  36. package/docs/{EXAMPLES.md → reference/EXAMPLES.md} +7 -5
  37. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +135 -0
  38. package/docs/reference/HERMES_INTEGRATION.md +118 -0
  39. package/docs/reference/INSIGHTS_RECAP.md +63 -0
  40. package/docs/reference/INSTALL.md +164 -0
  41. package/docs/reference/KERNEL_TAXONOMY_VALIDATION.md +161 -0
  42. package/docs/reference/PROTECTED_PATH_MANIFEST.md +25 -0
  43. package/docs/reference/RELEASE.md +68 -0
  44. package/docs/reference/RESEARCH_TEMPLATE.md +292 -0
  45. package/docs/{ROADMAP.md → reference/ROADMAP.md} +12 -9
  46. package/docs/reference/SKILLS.md +35 -0
  47. package/docs/reference/STATUS_BOARD.md +80 -0
  48. package/docs/reference/TEMPLATES.md +106 -0
  49. package/docs/{TOOLCHAIN.md → reference/TOOLCHAIN.md} +62 -47
  50. package/docs/reference/VALIDATION.md +82 -0
  51. package/docs/reference/agent-permissions.md +169 -0
  52. package/docs/reference/beads-to-kernel-migration-ux.md +61 -0
  53. package/docs/reference/control-plane-guarantees.md +125 -0
  54. package/docs/reference/dependency-chain.md +331 -0
  55. package/docs/reference/forge-kernel-issue-command-contract.md +161 -0
  56. package/docs/reference/forge-kernel-schema.md +72 -0
  57. package/docs/reference/kernel-conflict-evaluators.md +27 -0
  58. package/docs/reference/patch-md-format.md +77 -0
  59. package/docs/reference/protected-state-surfaces.md +59 -0
  60. package/docs/reference/shepherd.md +115 -0
  61. package/docs/reference/superpowers-analysis.md +320 -0
  62. package/docs/reference/superpowers-integration-options.md +404 -0
  63. package/docs/reference/test-environment.md +519 -0
  64. package/docs/reference/upgrade-safety.md +59 -0
  65. package/lefthook.yml +18 -0
  66. package/lib/adapter-cli.js +307 -0
  67. package/lib/adapters/beads-issue-adapter.js +127 -0
  68. package/lib/adapters/beads-kernel-compat.js +1042 -0
  69. package/lib/adapters/greptile-review-adapter.js +141 -0
  70. package/lib/adapters/kernel-issue-adapter.js +101 -0
  71. package/lib/adapters/pr-state-adapter.js +484 -0
  72. package/lib/adoption-profiles.js +126 -0
  73. package/lib/agents/README.md +2 -6
  74. package/lib/agents/claude.plugin.json +3 -8
  75. package/lib/agents/codex.plugin.json +9 -1
  76. package/lib/agents/cursor.plugin.json +2 -6
  77. package/lib/agents/hermes.plugin.json +22 -0
  78. package/lib/agents-config.js +39 -1236
  79. package/lib/audit-evidence.js +282 -0
  80. package/lib/beads-setup.js +121 -0
  81. package/lib/beads-sync-scaffold.js +25 -101
  82. package/lib/codex-skills.js +51 -1
  83. package/lib/commands/_issue.js +741 -77
  84. package/lib/commands/_manifest.js +91 -0
  85. package/lib/commands/_registry.js +85 -34
  86. package/lib/commands/_resolve-command-opts.js +261 -0
  87. package/lib/commands/_serve-security.js +270 -0
  88. package/lib/commands/adapter.js +12 -0
  89. package/lib/commands/add.js +118 -0
  90. package/lib/commands/audit.js +70 -0
  91. package/lib/commands/blocked.js +5 -0
  92. package/lib/commands/board.js +64 -0
  93. package/lib/commands/claim.js +21 -2
  94. package/lib/commands/claims.js +7 -0
  95. package/lib/commands/clean.js +485 -75
  96. package/lib/commands/close.js +2 -2
  97. package/lib/commands/comment.js +5 -0
  98. package/lib/commands/control.js +148 -0
  99. package/lib/commands/create.js +2 -2
  100. package/lib/commands/dev.js +185 -7
  101. package/lib/commands/doc-gate.js +336 -0
  102. package/lib/commands/doctor.js +156 -0
  103. package/lib/commands/explain.js +15 -0
  104. package/lib/commands/export.js +237 -0
  105. package/lib/commands/gate.js +192 -0
  106. package/lib/commands/hooks.js +242 -0
  107. package/lib/commands/inbox.js +118 -0
  108. package/lib/commands/init.js +598 -0
  109. package/lib/commands/insights.js +79 -0
  110. package/lib/commands/issue.js +12 -1
  111. package/lib/commands/issues.js +17 -0
  112. package/lib/commands/lint.js +5 -0
  113. package/lib/commands/list.js +2 -2
  114. package/lib/commands/merge.js +312 -0
  115. package/lib/commands/migrate.js +523 -0
  116. package/lib/commands/new.js +12 -0
  117. package/lib/commands/options.js +241 -0
  118. package/lib/commands/orient.js +13 -0
  119. package/lib/commands/orphans.js +5 -0
  120. package/lib/commands/patch.js +67 -0
  121. package/lib/commands/plan.js +436 -24
  122. package/lib/commands/preflight.js +211 -0
  123. package/lib/commands/prime.js +13 -0
  124. package/lib/commands/push.js +69 -2
  125. package/lib/commands/ready.js +2 -2
  126. package/lib/commands/recall.js +116 -0
  127. package/lib/commands/recap.js +61 -0
  128. package/lib/commands/recommend.js +0 -1
  129. package/lib/commands/release.js +91 -0
  130. package/lib/commands/remember.js +74 -0
  131. package/lib/commands/role.js +99 -0
  132. package/lib/commands/serve.js +581 -0
  133. package/lib/commands/setup.js +838 -972
  134. package/lib/commands/shepherd.js +436 -0
  135. package/lib/commands/ship.js +23 -1
  136. package/lib/commands/show.js +2 -2
  137. package/lib/commands/stage.js +192 -0
  138. package/lib/commands/stale.js +5 -0
  139. package/lib/commands/status.js +158 -21
  140. package/lib/commands/sync.js +34 -46
  141. package/lib/commands/team.js +4 -1
  142. package/lib/commands/test.js +43 -27
  143. package/lib/commands/update.js +2 -2
  144. package/lib/commands/upgrade.js +47 -0
  145. package/lib/commands/validate.js +43 -18
  146. package/lib/commands/worktree.js +307 -100
  147. package/lib/config-writer.js +202 -0
  148. package/lib/control-plane.js +236 -0
  149. package/lib/core/runtime-graph.js +946 -0
  150. package/lib/dep-guard/keyword-ripple.js +2 -2
  151. package/lib/deprecated-sync-cleanup.js +362 -0
  152. package/lib/detect-agent.js +2 -28
  153. package/lib/detect-worktree.js +35 -9
  154. package/lib/doc-gate/declaration.js +177 -0
  155. package/lib/doc-gate/detect.js +289 -0
  156. package/lib/doc-gate/gate.js +375 -0
  157. package/lib/doc-gate/okf-config.js +128 -0
  158. package/lib/doc-gate/okf.js +429 -0
  159. package/lib/docs-command.js +1161 -6
  160. package/lib/forge-issues.js +382 -11
  161. package/lib/forge-lock.js +262 -0
  162. package/lib/gate-events.js +193 -0
  163. package/lib/global-flags.js +74 -0
  164. package/lib/greptile-match.js +7 -63
  165. package/lib/harness-capability-matrix.js +380 -0
  166. package/lib/hook-global-installer.js +347 -0
  167. package/lib/hook-renderer.js +451 -0
  168. package/lib/inbox.js +391 -0
  169. package/lib/insights.js +397 -0
  170. package/lib/issue-adapter.js +156 -0
  171. package/lib/issue-backend.js +145 -0
  172. package/lib/issue-render.js +220 -0
  173. package/lib/kernel/backing-issue.js +305 -0
  174. package/lib/kernel/broker.js +1218 -0
  175. package/lib/kernel/cli-broker-factory.js +130 -0
  176. package/lib/kernel/conflict-signal.js +82 -0
  177. package/lib/kernel/evaluators.js +195 -0
  178. package/lib/kernel/fs-class.js +495 -0
  179. package/lib/kernel/issue-command-contract.js +559 -0
  180. package/lib/kernel/issue-id-resolver.js +186 -0
  181. package/lib/kernel/lease-enforcer.js +158 -0
  182. package/lib/kernel/migrations.js +333 -0
  183. package/lib/kernel/planning-buckets-schema.js +109 -0
  184. package/lib/kernel/projection-jsonl-writer.js +450 -0
  185. package/lib/kernel/readiness-model.js +329 -0
  186. package/lib/kernel/schema.js +356 -0
  187. package/lib/kernel/sqlite-driver.js +2504 -0
  188. package/lib/kernel/taxonomy-validator.js +394 -0
  189. package/lib/lefthook-check.js +3 -2
  190. package/lib/lefthook-wiring.js +413 -0
  191. package/lib/mcp-config-renderer.js +288 -0
  192. package/lib/memory/graphiti-mcp.js +106 -0
  193. package/lib/memory/router.js +387 -0
  194. package/lib/memory/typed-api.js +102 -0
  195. package/lib/memory-digest.js +195 -0
  196. package/lib/merge-rules.js +395 -0
  197. package/lib/migrate-dry-run.js +466 -0
  198. package/lib/orientation.js +863 -0
  199. package/lib/package-manager-remediation.js +103 -0
  200. package/lib/package-root.js +381 -0
  201. package/lib/patch-intent.js +890 -0
  202. package/lib/plugin-catalog.js +3 -4
  203. package/lib/plugin-manager.js +0 -5
  204. package/lib/pr-bundle.js +186 -0
  205. package/lib/pr-monitor/differ.js +195 -0
  206. package/lib/pr-monitor/events.js +0 -0
  207. package/lib/pr-monitor/gather.js +124 -0
  208. package/lib/pr-monitor/journal.js +299 -0
  209. package/lib/pr-monitor/monitor.js +146 -0
  210. package/lib/pr-monitor/render-sticky.js +157 -0
  211. package/lib/pr-monitor/watch-lifecycle.js +95 -0
  212. package/lib/pr-monitor/watch.js +247 -0
  213. package/lib/pr-pull.js +1273 -0
  214. package/lib/pr-shepherd.js +494 -0
  215. package/lib/pr-state-validator.js +59 -0
  216. package/lib/preflight/gates.js +237 -0
  217. package/lib/preflight/runner.js +116 -0
  218. package/lib/project-discovery.js +0 -53
  219. package/lib/project-memory.js +99 -497
  220. package/lib/protected-path-manifest.js +281 -0
  221. package/lib/protected-state-surfaces.js +387 -0
  222. package/lib/release-readiness.js +2089 -0
  223. package/lib/reset.js +59 -45
  224. package/lib/review-adapter.js +68 -0
  225. package/lib/rules-sync.js +260 -0
  226. package/lib/runtime-health.js +241 -20
  227. package/lib/safety-config-renderer.js +268 -0
  228. package/lib/setup-action-log.js +1 -7
  229. package/lib/setup.js +27 -65
  230. package/lib/shell-utils.js +76 -6
  231. package/lib/skills-sync.js +330 -0
  232. package/lib/smart-status/scoring.js +17 -3
  233. package/lib/status/beads-snapshot.js +45 -2
  234. package/lib/status/presenter.js +169 -18
  235. package/lib/status/snapshot.js +186 -0
  236. package/lib/sync-backend.js +202 -0
  237. package/lib/untrusted-content.js +52 -0
  238. package/lib/upgrade-safety.js +199 -0
  239. package/lib/workflow/enforce-stage.js +296 -47
  240. package/lib/workflow/stage-transition.js +115 -0
  241. package/lib/workflow/stages.js +30 -6
  242. package/lib/workflow/state-manager.js +11 -22
  243. package/lib/workflow/state.js +23 -1
  244. package/lib/workflow-profiles.js +17 -5
  245. package/package.json +37 -35
  246. package/rules/documentation.md +19 -0
  247. package/rules/kernel-tracking.md +26 -0
  248. package/rules/security.md +22 -0
  249. package/rules/tdd.md +20 -0
  250. package/rules/workflow.md +27 -0
  251. package/scripts/auto-backing-issue.js +47 -0
  252. package/scripts/beads-context.sh +81 -57
  253. package/scripts/beads-upgrade-smoke.sh +24 -3
  254. package/scripts/bootstrap-windows-tools.sh +78 -0
  255. package/scripts/branch-protection.js +2 -3
  256. package/scripts/check-agents.js +34 -137
  257. package/scripts/commitlint.js +3 -1
  258. package/scripts/conflict-detect.sh +3 -0
  259. package/scripts/dep-guard.sh +22 -3
  260. package/scripts/file-index.sh +3 -0
  261. package/scripts/forge-team/lib/claim.sh +34 -18
  262. package/scripts/forge-team/lib/dashboard.sh +61 -86
  263. package/scripts/forge-team/lib/epic.sh +99 -263
  264. package/scripts/forge-team/lib/hooks.sh +26 -28
  265. package/scripts/forge-team/lib/identity.sh +4 -4
  266. package/scripts/forge-team/lib/sync-github.sh +49 -84
  267. package/scripts/forge-team/lib/verify.sh +93 -83
  268. package/scripts/forge-team/lib/workload.sh +41 -65
  269. package/scripts/forge-team/tests/claim.test.sh +25 -19
  270. package/scripts/forge-team/tests/dashboard.test.sh +31 -46
  271. package/scripts/forge-team/tests/epic.test.sh +52 -71
  272. package/scripts/forge-team/tests/hooks.test.sh +38 -50
  273. package/scripts/forge-team/tests/identity.test.sh +3 -3
  274. package/scripts/forge-team/tests/integration.test.sh +44 -66
  275. package/scripts/forge-team/tests/sync-github.test.sh +50 -83
  276. package/scripts/forge-team/tests/verify.test.sh +37 -46
  277. package/scripts/forge-team/tests/workflow-integration.test.sh +4 -4
  278. package/scripts/forge-team/tests/workload.test.sh +32 -66
  279. package/scripts/gen-command-manifest.js +153 -0
  280. package/scripts/gen-embedded-assets.mjs +129 -0
  281. package/scripts/install.ps1 +139 -0
  282. package/scripts/install.sh +268 -0
  283. package/scripts/lib/release-asset.mjs +84 -0
  284. package/scripts/parity-check.mjs +145 -0
  285. package/scripts/parity-check.test.mjs +58 -0
  286. package/scripts/pin-agentic-workflow-images.js +112 -0
  287. package/scripts/pr-coordinator.sh +3 -0
  288. package/scripts/preflight-sonar.eslint.config.mjs +44 -0
  289. package/scripts/preflight.sh +21 -94
  290. package/scripts/protected-state-check.js +104 -0
  291. package/scripts/smart-status.sh +60 -57
  292. package/scripts/spikes/config-race-bench.js +111 -0
  293. package/scripts/spikes/harness-capability-matrix.js +13 -0
  294. package/scripts/spikes/patch-anchor-stability-bench.js +125 -0
  295. package/scripts/spikes/protected-path-manifest.js +20 -0
  296. package/scripts/spikes/skill-auto-invoke-parity.js +292 -0
  297. package/scripts/sync-agent-skills.js +62 -0
  298. package/scripts/sync-utils.sh +3 -0
  299. package/scripts/test-ci-shard.js +13 -6
  300. package/scripts/test.js +95 -12
  301. package/skills/claim-safety/SKILL.md +102 -0
  302. package/skills/claim-safety/evals/evals.json +46 -0
  303. package/{.github/prompts/dev.prompt.md → skills/dev/SKILL.md} +44 -50
  304. package/skills/dev/evals/evals.json +50 -0
  305. package/skills/hermes-forge/SKILL.md +185 -0
  306. package/skills/hermes-forge/evals/evals.json +46 -0
  307. package/skills/issue-basics/SKILL.md +111 -0
  308. package/skills/issue-basics/evals/evals.json +46 -0
  309. package/skills/kernel/SKILL.md +166 -0
  310. package/skills/kernel/evals/evals.json +50 -0
  311. package/skills/memory/SKILL.md +102 -0
  312. package/skills/parallel-deep-research/SKILL.md +14 -11
  313. package/skills/parallel-deep-research/evals/evals.json +11 -27
  314. package/{.github/prompts/plan.prompt.md → skills/plan/SKILL.md} +132 -157
  315. package/skills/plan/evals/evals.json +42 -0
  316. package/skills/research/SKILL.md +195 -0
  317. package/skills/research/evals/evals.json +42 -0
  318. package/{.github/prompts/review.prompt.md → skills/review/SKILL.md} +98 -62
  319. package/skills/review/evals/evals.json +42 -0
  320. package/skills/rollback/SKILL.md +110 -0
  321. package/skills/rollback/evals/evals.json +46 -0
  322. package/skills/rollback/references/methods.md +204 -0
  323. package/{.cursor/commands/rollback.md → skills/rollback/references/workflow-integration.md} +10 -284
  324. package/skills/shepherd/SKILL.md +66 -0
  325. package/skills/shepherd/evals/evals.json +42 -0
  326. package/{.github/prompts/ship.prompt.md → skills/ship/SKILL.md} +81 -45
  327. package/skills/ship/evals/evals.json +42 -0
  328. package/skills/smith/SKILL.md +142 -0
  329. package/skills/smith/evals/evals.json +46 -0
  330. package/skills/smith/references/autonomy-and-gates.md +94 -0
  331. package/{.github/prompts/sonarcloud.prompt.md → skills/sonarcloud/SKILL.md} +14 -3
  332. package/skills/sonarcloud/evals/evals.json +46 -0
  333. package/skills/sonarcloud-analysis/SKILL.md +18 -13
  334. package/skills/sonarcloud-analysis/evals/evals.json +11 -15
  335. package/{.github/prompts/status.prompt.md → skills/status/SKILL.md} +20 -10
  336. package/skills/status/evals/evals.json +50 -0
  337. package/skills/triage-ready/SKILL.md +121 -0
  338. package/skills/triage-ready/evals/evals.json +42 -0
  339. package/{.github/prompts/validate.prompt.md → skills/validate/SKILL.md} +52 -29
  340. package/skills/validate/evals/evals.json +42 -0
  341. package/skills/verify/SKILL.md +299 -0
  342. package/skills/verify/evals/evals.json +50 -0
  343. package/.claude/commands/dev.md +0 -345
  344. package/.claude/commands/plan.md +0 -566
  345. package/.claude/commands/premerge.md +0 -186
  346. package/.claude/commands/research.md +0 -42
  347. package/.claude/commands/review.md +0 -451
  348. package/.claude/commands/rollback.md +0 -721
  349. package/.claude/commands/ship.md +0 -213
  350. package/.claude/commands/sonarcloud.md +0 -152
  351. package/.claude/commands/status.md +0 -90
  352. package/.claude/commands/validate.md +0 -288
  353. package/.claude/commands/verify.md +0 -269
  354. package/.claude/rules/workflow.md +0 -121
  355. package/.cline/workflows/dev.md +0 -342
  356. package/.cline/workflows/plan.md +0 -563
  357. package/.cline/workflows/premerge.md +0 -183
  358. package/.cline/workflows/research.md +0 -39
  359. package/.cline/workflows/review.md +0 -448
  360. package/.cline/workflows/rollback.md +0 -718
  361. package/.cline/workflows/ship.md +0 -210
  362. package/.cline/workflows/sonarcloud.md +0 -146
  363. package/.cline/workflows/status.md +0 -87
  364. package/.cline/workflows/validate.md +0 -285
  365. package/.cline/workflows/verify.md +0 -266
  366. package/.codex/config.toml +0 -11
  367. package/.codex/skills/dev/SKILL.md +0 -345
  368. package/.codex/skills/plan/SKILL.md +0 -566
  369. package/.codex/skills/premerge/SKILL.md +0 -186
  370. package/.codex/skills/research/SKILL.md +0 -42
  371. package/.codex/skills/review/SKILL.md +0 -451
  372. package/.codex/skills/rollback/SKILL.md +0 -721
  373. package/.codex/skills/ship/SKILL.md +0 -213
  374. package/.codex/skills/sonarcloud/SKILL.md +0 -149
  375. package/.codex/skills/status/SKILL.md +0 -90
  376. package/.codex/skills/validate/SKILL.md +0 -288
  377. package/.codex/skills/verify/SKILL.md +0 -269
  378. package/.cursor/commands/dev.md +0 -342
  379. package/.cursor/commands/plan.md +0 -563
  380. package/.cursor/commands/premerge.md +0 -183
  381. package/.cursor/commands/research.md +0 -39
  382. package/.cursor/commands/review.md +0 -448
  383. package/.cursor/commands/ship.md +0 -210
  384. package/.cursor/commands/sonarcloud.md +0 -146
  385. package/.cursor/commands/status.md +0 -87
  386. package/.cursor/commands/validate.md +0 -285
  387. package/.cursor/commands/verify.md +0 -266
  388. package/.cursorrules +0 -149
  389. package/.github/prompts/premerge.prompt.md +0 -188
  390. package/.github/prompts/research.prompt.md +0 -44
  391. package/.github/prompts/rollback.prompt.md +0 -723
  392. package/.github/prompts/verify.prompt.md +0 -271
  393. package/.github/workflows/beads-to-github.yml +0 -89
  394. package/.github/workflows/github-to-beads.yml +0 -100
  395. package/.kilocode/workflows/dev.md +0 -346
  396. package/.kilocode/workflows/plan.md +0 -567
  397. package/.kilocode/workflows/premerge.md +0 -187
  398. package/.kilocode/workflows/research.md +0 -43
  399. package/.kilocode/workflows/review.md +0 -452
  400. package/.kilocode/workflows/rollback.md +0 -722
  401. package/.kilocode/workflows/ship.md +0 -214
  402. package/.kilocode/workflows/sonarcloud.md +0 -150
  403. package/.kilocode/workflows/status.md +0 -91
  404. package/.kilocode/workflows/validate.md +0 -289
  405. package/.kilocode/workflows/verify.md +0 -270
  406. package/.opencode/commands/dev.md +0 -345
  407. package/.opencode/commands/plan.md +0 -566
  408. package/.opencode/commands/premerge.md +0 -186
  409. package/.opencode/commands/research.md +0 -42
  410. package/.opencode/commands/review.md +0 -451
  411. package/.opencode/commands/rollback.md +0 -721
  412. package/.opencode/commands/ship.md +0 -213
  413. package/.opencode/commands/sonarcloud.md +0 -149
  414. package/.opencode/commands/status.md +0 -90
  415. package/.opencode/commands/validate.md +0 -288
  416. package/.opencode/commands/verify.md +0 -269
  417. package/.roo/commands/dev.md +0 -346
  418. package/.roo/commands/plan.md +0 -567
  419. package/.roo/commands/premerge.md +0 -187
  420. package/.roo/commands/research.md +0 -43
  421. package/.roo/commands/review.md +0 -452
  422. package/.roo/commands/rollback.md +0 -722
  423. package/.roo/commands/ship.md +0 -214
  424. package/.roo/commands/sonarcloud.md +0 -150
  425. package/.roo/commands/status.md +0 -91
  426. package/.roo/commands/validate.md +0 -289
  427. package/.roo/commands/verify.md +0 -270
  428. package/docs/BEADS_GITHUB_SYNC.md +0 -281
  429. package/docs/GREPTILE_SETUP.md +0 -400
  430. package/docs/MANUAL_REVIEW_GUIDE.md +0 -106
  431. package/docs/SETUP.md +0 -663
  432. package/docs/VALIDATION.md +0 -363
  433. package/lib/agents/cline.plugin.json +0 -29
  434. package/lib/agents/copilot.plugin.json +0 -24
  435. package/lib/agents/kilocode.plugin.json +0 -22
  436. package/lib/agents/opencode.plugin.json +0 -23
  437. package/lib/agents/roo.plugin.json +0 -30
  438. package/lib/beads-bootstrap.js +0 -225
  439. package/lib/beads-health-check.js +0 -188
  440. package/lib/commands/commands-reset.js +0 -147
  441. package/opencode.json +0 -67
  442. package/scripts/beads-context.test.js +0 -584
  443. package/scripts/github-beads-sync/comment.mjs +0 -64
  444. package/scripts/github-beads-sync/config.mjs +0 -148
  445. package/scripts/github-beads-sync/github-api.mjs +0 -131
  446. package/scripts/github-beads-sync/index.mjs +0 -356
  447. package/scripts/github-beads-sync/label-mapper.mjs +0 -54
  448. package/scripts/github-beads-sync/mapping.mjs +0 -132
  449. package/scripts/github-beads-sync/reverse-sync-cli.mjs +0 -31
  450. package/scripts/github-beads-sync/reverse-sync.mjs +0 -162
  451. package/scripts/github-beads-sync/run-bd.mjs +0 -161
  452. package/scripts/github-beads-sync/sanitize.mjs +0 -121
  453. package/scripts/github-beads-sync.config.json +0 -26
  454. package/scripts/sync-commands.js +0 -600
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `forge role <role> --use <skill> [--ideology <name>]`
5
+ *
6
+ * Thin SWAP verb over the sparse config writer: binds a role to a skill and/or
7
+ * ideology by writing `roles.<role>.skill` / `roles.<role>.ideology` in
8
+ * `.forge/config.yaml`. `forge options roles --json` reflects the binding
9
+ * through the shipped resolver (`applyRoleConfig`).
10
+ *
11
+ * Write-time validation errors BEFORE anything is written:
12
+ * - unknown role (not in the KNOWN closed set ROLE_IDS)
13
+ * - unresolvable skill (no SKILL.md under `.skills/ > skills/`)
14
+ *
15
+ * The skill name is OPEN-WORLD (a bring-your-own skill is fine) — it is checked
16
+ * for existence/trust, never against the closed PLAN_SUBSKILL enum.
17
+ */
18
+
19
+ const { setConfigOverride, resolveSkill } = require('../config-writer');
20
+ const { ROLE_IDS } = require('../core/runtime-graph');
21
+
22
+ function usage() {
23
+ return 'Usage: forge role <role> --use <skill> [--ideology <name>]';
24
+ }
25
+
26
+ function parseArgs(args) {
27
+ const parsed = { role: args[0], skill: undefined, ideology: undefined };
28
+ for (let i = 1; i < args.length; i += 1) {
29
+ const token = args[i];
30
+ if (token === '--use' || token === '--skill') {
31
+ if (i + 1 >= args.length) {
32
+ return { error: `${token} requires a value.` };
33
+ }
34
+ parsed.skill = args[i + 1];
35
+ i += 1;
36
+ } else if (token === '--ideology') {
37
+ if (i + 1 >= args.length) {
38
+ return { error: `${token} requires a value.` };
39
+ }
40
+ parsed.ideology = args[i + 1];
41
+ i += 1;
42
+ } else {
43
+ return { error: `Unknown argument '${token}'.` };
44
+ }
45
+ }
46
+ return { parsed };
47
+ }
48
+
49
+ async function handler(args, _flags, projectRoot = process.cwd()) {
50
+ const { parsed, error } = parseArgs(args);
51
+ if (error) {
52
+ return { success: false, error: `${error}\n${usage()}` };
53
+ }
54
+ if (!parsed.role) {
55
+ return { success: false, error: usage() };
56
+ }
57
+ if (!ROLE_IDS.has(parsed.role)) {
58
+ return {
59
+ success: false,
60
+ error: `Unknown role '${parsed.role}'. Known roles: ${[...ROLE_IDS].join(', ')}`,
61
+ };
62
+ }
63
+ if (parsed.skill === undefined && parsed.ideology === undefined) {
64
+ return { success: false, error: `Nothing to set — pass --use and/or --ideology.\n${usage()}` };
65
+ }
66
+ if (parsed.skill !== undefined) {
67
+ if (typeof parsed.skill !== 'string' || parsed.skill === '') {
68
+ return { success: false, error: `--use requires a skill name.\n${usage()}` };
69
+ }
70
+ if (!resolveSkill(projectRoot, parsed.skill)) {
71
+ return {
72
+ success: false,
73
+ error: `Unknown skill '${parsed.skill}' — no SKILL.md found under .skills/ or skills/.`,
74
+ };
75
+ }
76
+ }
77
+ if (parsed.ideology !== undefined && (typeof parsed.ideology !== 'string' || parsed.ideology === '')) {
78
+ return { success: false, error: `--ideology requires a name.\n${usage()}` };
79
+ }
80
+
81
+ const writes = [];
82
+ if (parsed.skill !== undefined) {
83
+ setConfigOverride(projectRoot, ['roles', parsed.role, 'skill'], parsed.skill);
84
+ writes.push(`roles.${parsed.role}.skill=${parsed.skill}`);
85
+ }
86
+ if (parsed.ideology !== undefined) {
87
+ setConfigOverride(projectRoot, ['roles', parsed.role, 'ideology'], parsed.ideology);
88
+ writes.push(`roles.${parsed.role}.ideology=${parsed.ideology}`);
89
+ }
90
+
91
+ return { success: true, output: `Updated ${writes.join(', ')}` };
92
+ }
93
+
94
+ module.exports = {
95
+ name: 'role',
96
+ description: 'Bind a role to a skill/ideology in .forge/config.yaml',
97
+ usage: usage(),
98
+ handler,
99
+ };
@@ -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
+ };