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,299 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PR-monitor journal — per-PR append-only NDJSON event log plus an atomic
5
+ * snapshot fingerprint, under `.forge/pr-monitor/<repo>-<pr>/`. The journal is
6
+ * the CURSOR AUTHORITY: it survives crashes and is shared across processes and
7
+ * worktrees, and works with no kernel configured. A consumer keeps its own `seq`
8
+ * cursor and reads new events with `readEventsSince`.
9
+ *
10
+ * Ordering contract (exactly-once): a monitor pass APPENDS events, THEN writes
11
+ * the snapshot together with an `appliedSeq` cursor (the highest seq that
12
+ * snapshot accounts for). If a crash lands between the append and the snapshot
13
+ * write, the persisted `appliedSeq` still points BEFORE the just-appended tail,
14
+ * so the next pass re-diffs the old snapshot, recomputes the same `(type,key)`
15
+ * events, and `seenIdentities(dir, appliedSeq)` filters exactly that pending tail
16
+ * — no duplicate is ever journaled. Crucially the dedup is scoped to that
17
+ * pending/crash-recovery window, NOT the whole journal history: a value that
18
+ * flips back to a prior state (fail → green → fail on the same sha) legitimately
19
+ * re-emits, because the earlier identity sits at/below `appliedSeq`.
20
+ *
21
+ * Concurrency: multiple processes/worktrees can poll the same PR. The
22
+ * read→diff→dedup→append→snapshot critical section is serialized across
23
+ * processes by `withJournalLock` (a crash-recoverable lock-directory), so
24
+ * concurrent passes can never interleave appends or reuse a sequence number.
25
+ *
26
+ * @module pr-monitor/journal
27
+ */
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+ const { eventIdentity } = require('./events');
32
+
33
+ /** Sanitize a repo slug for a filesystem directory name. */
34
+ function sanitize(part) {
35
+ return String(part || '').replace(/[^A-Za-z0-9._-]+/g, '-');
36
+ }
37
+
38
+ /**
39
+ * Resolve (and create) the per-PR journal directory.
40
+ *
41
+ * @param {{ root: string, repo: string, pr: string|number }} ctx
42
+ * @returns {string} absolute directory path
43
+ */
44
+ function journalDir({ root, repo, pr }) {
45
+ const dir = path.join(root, '.forge', 'pr-monitor', `${sanitize(repo)}-${sanitize(pr)}`);
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ return dir;
48
+ }
49
+
50
+ function journalPath(dir) { return path.join(dir, 'events.ndjson'); }
51
+ function snapshotPath(dir) { return path.join(dir, 'snapshot.json'); }
52
+ function pidPath(dir) { return path.join(dir, 'watch.pid'); }
53
+ function lockPath(dir) { return path.join(dir, 'journal.lock'); }
54
+
55
+ /**
56
+ * Read all journal records (NDJSON), skipping any unparseable line so one
57
+ * corrupt tail line never blinds the cursor.
58
+ *
59
+ * @param {string} dir
60
+ * @returns {object[]}
61
+ */
62
+ function readAllEvents(dir) {
63
+ const file = journalPath(dir);
64
+ if (!fs.existsSync(file)) return [];
65
+ const out = [];
66
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
67
+ const trimmed = line.trim();
68
+ if (!trimmed) continue;
69
+ try { out.push(JSON.parse(trimmed)); } catch { /* skip corrupt line */ }
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Records with `seq > sinceSeq`, in journal order. */
75
+ function readEventsSince(dir, sinceSeq) {
76
+ const since = Number(sinceSeq) || 0;
77
+ return readAllEvents(dir).filter((e) => Number(e.seq) > since);
78
+ }
79
+
80
+ /** Highest `seq` recorded so far (0 when empty). */
81
+ function lastSeq(dir) {
82
+ let max = 0;
83
+ for (const e of readAllEvents(dir)) {
84
+ const s = Number(e.seq) || 0;
85
+ if (s > max) max = s;
86
+ }
87
+ return max;
88
+ }
89
+
90
+ /**
91
+ * Set of `(type,key)` identities journaled with `seq > sinceSeq` — the dedup
92
+ * guard, SCOPED to the crash-recovery/pending window above the snapshot cursor.
93
+ *
94
+ * With `sinceSeq = 0` (no snapshot, or an explicit full scan) it covers the
95
+ * whole history, which is the correct behaviour during crash recovery when the
96
+ * snapshot is missing. With `sinceSeq = appliedSeq` it covers only the events
97
+ * the current snapshot has NOT yet accounted for, so an identity that recurs
98
+ * after a full round-trip (e.g. fail → green → fail) is NOT suppressed forever.
99
+ *
100
+ * @param {string} dir
101
+ * @param {number} [sinceSeq=0]
102
+ * @returns {Set<string>}
103
+ */
104
+ function seenIdentities(dir, sinceSeq = 0) {
105
+ const since = Number(sinceSeq) || 0;
106
+ const set = new Set();
107
+ for (const e of readAllEvents(dir)) {
108
+ if ((Number(e.seq) || 0) > since) set.add(eventIdentity(e));
109
+ }
110
+ return set;
111
+ }
112
+
113
+ /** Append finalized records as NDJSON lines (atomic per-line append). */
114
+ function appendEvents(dir, records) {
115
+ if (!records?.length) return;
116
+ const payload = records.map((r) => JSON.stringify(r)).join('\n') + '\n';
117
+ fs.appendFileSync(journalPath(dir), payload);
118
+ }
119
+
120
+ /**
121
+ * Read the persisted snapshot + fingerprint + `appliedSeq`, or null when
122
+ * absent/unreadable. `appliedSeq` is the highest journal seq this snapshot
123
+ * accounts for (0 for legacy snapshots written before the cursor existed); it
124
+ * bounds the dedup window in `seenIdentities`.
125
+ *
126
+ * @param {string} dir
127
+ * @returns {{ snapshot: object, fingerprint: string, appliedSeq: number }|null}
128
+ */
129
+ function readSnapshot(dir) {
130
+ const file = snapshotPath(dir);
131
+ if (!fs.existsSync(file)) return null;
132
+ try {
133
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
134
+ return {
135
+ snapshot: data.snapshot || null,
136
+ fingerprint: data.fingerprint || null,
137
+ appliedSeq: Number(data.appliedSeq) || 0,
138
+ };
139
+ } catch { return null; }
140
+ }
141
+
142
+ /**
143
+ * Atomically persist the snapshot + fingerprint + `appliedSeq` (write temp, then
144
+ * rename). The rename is atomic on the same filesystem, so a reader never sees a
145
+ * half-write.
146
+ *
147
+ * @param {string} dir
148
+ * @param {{ snapshot: object, fingerprint: string, appliedSeq?: number }} payload
149
+ */
150
+ function writeSnapshot(dir, { snapshot, fingerprint, appliedSeq = 0 }) {
151
+ const tmp = path.join(dir, `.snapshot.${process.pid}.${Date.now()}.tmp`);
152
+ fs.writeFileSync(tmp, JSON.stringify({ snapshot, fingerprint, appliedSeq: Number(appliedSeq) || 0 }));
153
+ fs.renameSync(tmp, snapshotPath(dir));
154
+ }
155
+
156
+ /** Non-blocking sleep for the lock retry loop. */
157
+ function delay(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); }
158
+
159
+ /**
160
+ * Is the held lock stale? A lock is stale when its owner process is dead, or
161
+ * when it has out-lived `staleMs` (guards against a reused pid or a crash that
162
+ * left no readable owner file). Either way it is safe to steal.
163
+ */
164
+ function lockIsStale(lock, staleMs) {
165
+ let ownerPid = null;
166
+ let stampedAt = null;
167
+ try {
168
+ const [pidStr, tsStr] = fs.readFileSync(path.join(lock, 'owner'), 'utf8').split(':');
169
+ ownerPid = Number.parseInt(pidStr, 10);
170
+ stampedAt = Number.parseInt(tsStr, 10);
171
+ } catch { /* owner file missing/unreadable → fall through to age check */ }
172
+ if (ownerPid && !pidAlive(ownerPid)) return true;
173
+ let ageBase = stampedAt;
174
+ if (!Number.isFinite(ageBase)) {
175
+ try { ageBase = fs.statSync(lock).mtimeMs; } catch { return true; }
176
+ }
177
+ return Date.now() - ageBase > staleMs;
178
+ }
179
+
180
+ /** Remove a lock directory (owner file + dir), best-effort. */
181
+ function releaseLock(lock) {
182
+ try { fs.rmSync(lock, { recursive: true, force: true }); } catch { /* already gone */ }
183
+ }
184
+
185
+ /**
186
+ * Run `fn` while holding the per-PR journal lock, serializing the
187
+ * read→diff→dedup→append→snapshot critical section across processes and
188
+ * worktrees. The lock is a directory created with an atomic `mkdir` (fails with
189
+ * EEXIST when held); a crash-recoverable staleness check lets a later caller
190
+ * steal a lock whose owner died or that out-lived `staleMs`. The lock is always
191
+ * released in a `finally`, on success or throw.
192
+ *
193
+ * A heartbeat refreshes the owner timestamp for the whole duration of the
194
+ * awaited `fn()`, at `staleMs / 3` intervals. Without this, `lockIsStale()`
195
+ * only looks at the ONE timestamp written at acquisition time — a live but
196
+ * slow pass (close to or past `staleMs`) would look stale to a competing
197
+ * caller purely on age, even though its owner PID is alive, and get its lock
198
+ * stolen mid-flight, letting two passes interleave journal appends and reuse a
199
+ * sequence number. The heartbeat is stopped in the existing `finally` cleanup
200
+ * alongside the lock release.
201
+ *
202
+ * @param {string} dir - journal directory.
203
+ * @param {() => Promise<T>|T} fn - critical section.
204
+ * @param {{ staleMs?: number, retries?: number, waitMs?: number, heartbeatMs?: number }} [opts]
205
+ * `heartbeatMs` defaults to `staleMs / 3` (floor 10ms) and is exposed mainly
206
+ * so tests can exercise the heartbeat without waiting out a full-size
207
+ * `staleMs` window.
208
+ * @returns {Promise<T>}
209
+ * @template T
210
+ */
211
+ async function withJournalLock(dir, fn, opts = {}) {
212
+ const { staleMs = 30000, retries = 600, waitMs = 25 } = opts;
213
+ const heartbeatMs = opts.heartbeatMs ?? Math.max(10, Math.floor(staleMs / 3));
214
+ const lock = lockPath(dir);
215
+ let acquired = false;
216
+ for (let i = 0; i < retries && !acquired; i += 1) {
217
+ try {
218
+ fs.mkdirSync(lock);
219
+ acquired = true;
220
+ } catch (err) {
221
+ if (err.code !== 'EEXIST') throw err;
222
+ if (lockIsStale(lock, staleMs)) { releaseLock(lock); continue; }
223
+ await delay(waitMs);
224
+ }
225
+ }
226
+ if (!acquired) throw new Error(`journal lock busy after ${retries} tries: ${lock}`);
227
+ const ownerFile = path.join(lock, 'owner');
228
+ const stampOwner = () => {
229
+ try { fs.writeFileSync(ownerFile, `${process.pid}:${Date.now()}`); } catch { /* lock released/stolen underneath us */ }
230
+ };
231
+ stampOwner();
232
+ const heartbeat = setInterval(stampOwner, heartbeatMs);
233
+ if (typeof heartbeat.unref === 'function') heartbeat.unref();
234
+ try {
235
+ return await fn();
236
+ } finally {
237
+ clearInterval(heartbeat);
238
+ releaseLock(lock);
239
+ }
240
+ }
241
+
242
+ /** Write the watcher pid file. */
243
+ function writePid(dir, pid) {
244
+ fs.writeFileSync(pidPath(dir), String(pid == null ? process.pid : pid));
245
+ }
246
+
247
+ /** Read the watcher pid (number) or null. */
248
+ function readPid(dir) {
249
+ const file = pidPath(dir);
250
+ if (!fs.existsSync(file)) return null;
251
+ const n = Number.parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
252
+ return Number.isFinite(n) ? n : null;
253
+ }
254
+
255
+ /** Remove the watcher pid file (best-effort). */
256
+ function removePid(dir) {
257
+ try { fs.unlinkSync(pidPath(dir)); } catch { /* already gone */ }
258
+ }
259
+
260
+ /** Is `pid` a live process? `process.kill(pid, 0)` probes without signaling. */
261
+ function pidAlive(pid) {
262
+ if (!pid) return false;
263
+ try { process.kill(pid, 0); return true; } catch (err) { return err?.code === 'EPERM'; }
264
+ }
265
+
266
+ /**
267
+ * A watcher is "running" when the pid file names a live process that is NOT us.
268
+ * Stale pid files (dead process) return false so a poll falls back to an inline
269
+ * pass.
270
+ *
271
+ * @param {string} dir
272
+ * @returns {boolean}
273
+ */
274
+ function watcherRunning(dir) {
275
+ const pid = readPid(dir);
276
+ return pidAlive(pid) && pid !== process.pid;
277
+ }
278
+
279
+ module.exports = {
280
+ sanitize,
281
+ journalDir,
282
+ journalPath,
283
+ snapshotPath,
284
+ pidPath,
285
+ lockPath,
286
+ withJournalLock,
287
+ readAllEvents,
288
+ readEventsSince,
289
+ lastSeq,
290
+ seenIdentities,
291
+ appendEvents,
292
+ readSnapshot,
293
+ writeSnapshot,
294
+ writePid,
295
+ readPid,
296
+ removePid,
297
+ pidAlive,
298
+ watcherRunning,
299
+ };
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PR-monitor orchestration — one bounded pass (gather → diff → dedup → append →
5
+ * persist) and the `events --since` poll surface. The watch streaming loop and
6
+ * the ship lifecycle hook land in a follow-up (Tier-1 PR-B).
7
+ *
8
+ * @module pr-monitor/monitor
9
+ */
10
+
11
+ const { finalizeEvent, eventIdentity, fingerprint } = require('./events');
12
+ const { diffSnapshots } = require('./differ');
13
+ const journal = require('./journal');
14
+
15
+ /** ISO-8601 timestamp; injectable via ctx.now for deterministic tests. */
16
+ function defaultNow() { return new Date().toISOString(); }
17
+
18
+ /**
19
+ * Filter candidate events down to those whose `(type,key)` identity has NOT
20
+ * already been journaled — the crash-safety dedup guard.
21
+ */
22
+ function dedupeAgainstJournal(candidates, seen) {
23
+ return candidates.filter((c) => !seen.has(eventIdentity(c)));
24
+ }
25
+
26
+ /**
27
+ * Envelope filtered candidates into journal records with monotonic seq + ts.
28
+ */
29
+ function finalizeRecords(candidates, { baseSeq, ts, snapshot }) {
30
+ return candidates.map((c, i) => finalizeEvent(c, {
31
+ seq: baseSeq + i + 1,
32
+ ts,
33
+ repo: snapshot.repo,
34
+ pr: snapshot.pr,
35
+ headSha: snapshot.headSha,
36
+ verdict: snapshot.verdict,
37
+ }));
38
+ }
39
+
40
+ /**
41
+ * The read→diff→dedup→sequence→append→snapshot critical section of a single
42
+ * pass. Runs ONLY while holding the journal lock (see `runMonitorPass`), so it
43
+ * may assume no concurrent writer.
44
+ *
45
+ * Dedup is SCOPED to the snapshot's `appliedSeq` cursor: only identities the
46
+ * current snapshot has not yet accounted for filter a candidate. When the
47
+ * snapshot is missing (crash recovery) `appliedSeq` is 0, so the guard falls
48
+ * back to the full history and no duplicate survives a crash between append and
49
+ * snapshot write. When the snapshot is current, the window is empty, so a state
50
+ * that flips back to a prior value (fail → green → fail on the same sha) emits a
51
+ * fresh event instead of being suppressed forever.
52
+ */
53
+ async function runMonitorPassLocked(ctx) {
54
+ const { dir, gather, now = defaultNow, enrich } = ctx;
55
+ const next = await gather();
56
+ const prevRecord = journal.readSnapshot(dir);
57
+ const prev = prevRecord ? prevRecord.snapshot : null;
58
+ const appliedSeq = prevRecord ? prevRecord.appliedSeq || 0 : 0;
59
+ const fp = fingerprint(next);
60
+
61
+ const candidates = diffSnapshots(prev, next);
62
+ const filtered = dedupeAgainstJournal(candidates, journal.seenIdentities(dir, appliedSeq));
63
+
64
+ if (!filtered.length) {
65
+ // Backpressure: only rewrite the snapshot when the fingerprint actually moved.
66
+ const changed = prevRecord?.fingerprint !== fp;
67
+ // Even with no new events, advance the cursor to the current tail so the
68
+ // dedup window stays anchored at the snapshot (prevents unbounded scans).
69
+ if (changed) {
70
+ journal.writeSnapshot(dir, { snapshot: next, fingerprint: fp, appliedSeq: journal.lastSeq(dir) });
71
+ }
72
+ return { events: [], changed, fingerprint: fp };
73
+ }
74
+
75
+ const baseSeq = journal.lastSeq(dir);
76
+ const records = finalizeRecords(filtered, {
77
+ baseSeq,
78
+ ts: now(),
79
+ snapshot: next,
80
+ });
81
+ if (typeof enrich === 'function') await enrich(records);
82
+
83
+ journal.appendEvents(dir, records);
84
+ // appliedSeq = the new tail, so the next pass's dedup window starts empty and
85
+ // only a crash BEFORE this write leaves the tail inside the recovery window.
86
+ journal.writeSnapshot(dir, { snapshot: next, fingerprint: fp, appliedSeq: baseSeq + records.length });
87
+ return { events: records, changed: true, fingerprint: fp };
88
+ }
89
+
90
+ /**
91
+ * Run ONE bounded monitor pass: gather the current snapshot, diff it against the
92
+ * persisted one, dedup by content identity, APPEND new events, THEN persist the
93
+ * snapshot (this order is what makes a crash between the two idempotent). The
94
+ * whole critical section — including the gather and the no-events snapshot
95
+ * update path — runs under a cross-process journal lock so concurrent passes
96
+ * from other processes/worktrees can never interleave appends or reuse a seq.
97
+ *
98
+ * @param {object} ctx
99
+ * @param {string} ctx.dir - journal directory (from journal.journalDir).
100
+ * @param {() => Promise<object>} ctx.gather - returns a normalized snapshot.
101
+ * @param {() => string} [ctx.now] - timestamp source (test injection).
102
+ * @param {(records: object[]) => Promise<void>|void} [ctx.enrich] - optional hook
103
+ * to enrich records (e.g. attach log excerpts to check.failed) before append.
104
+ * @param {object} [ctx.lockOpts] - override lock staleMs/retries/waitMs (tests).
105
+ * @returns {Promise<{ events: object[], changed: boolean, fingerprint: string }>}
106
+ */
107
+ async function runMonitorPass(ctx) {
108
+ return journal.withJournalLock(ctx.dir, () => runMonitorPassLocked(ctx), ctx.lockOpts);
109
+ }
110
+
111
+ /**
112
+ * `forge shepherd events <pr> --since <seq>` core: run one inline pass when no
113
+ * watcher owns this PR, then return every journaled event with `seq > since`.
114
+ * This is the agent-agnostic PULL surface — stdout NDJSON, nothing under .claude.
115
+ *
116
+ * @param {object} ctx
117
+ * @param {string} ctx.dir
118
+ * @param {() => Promise<object>} ctx.gather
119
+ * @param {number} [ctx.since]
120
+ * @param {() => string} [ctx.now]
121
+ * @param {(dir: string) => boolean} [ctx.watcherRunning]
122
+ * @param {(records: object[]) => Promise<void>|void} [ctx.enrich]
123
+ * @returns {Promise<{ events: object[], since: number, ranPass: boolean }>}
124
+ */
125
+ async function pollEvents(ctx) {
126
+ const { dir, gather, since = 0 } = ctx;
127
+ const isRunning = (ctx.watcherRunning || journal.watcherRunning)(dir);
128
+ let ranPass = false;
129
+ if (!isRunning) {
130
+ await runMonitorPass({ dir, gather, now: ctx.now, enrich: ctx.enrich });
131
+ ranPass = true;
132
+ }
133
+ return {
134
+ events: journal.readEventsSince(dir, since),
135
+ since: Number(since) || 0,
136
+ ranPass,
137
+ };
138
+ }
139
+
140
+ module.exports = {
141
+ runMonitorPass,
142
+ pollEvents,
143
+ dedupeAgainstJournal,
144
+ finalizeRecords,
145
+ defaultNow,
146
+ };
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PR-monitor sticky-comment renderer — turn ONE read-only `gatherPrBundle`
5
+ * result (lib/pr-bundle.js) into the Markdown body of the single sticky PR
6
+ * comment the pr-monitor GitHub workflow keeps up to date.
7
+ *
8
+ * This is the SURFACE half of the monitor: it lists the unresolved review
9
+ * threads (grouped by author, ANY author) plus the failing and pending checks
10
+ * so async review-bot / human feedback in a window nobody is watching cannot
11
+ * rot. It is deliberately NOT a merge decision — it never emits a pass/fail
12
+ * verdict and never claims a PR is ready to merge. That belongs to the
13
+ * trustworthy-shepherd redesign, not here.
14
+ *
15
+ * Pure and deterministic: same bundle + same injected clock → same body, which
16
+ * is what lets the workflow rewrite the sticky comment in place without churn.
17
+ *
18
+ * @module pr-monitor/render-sticky
19
+ */
20
+
21
+ /** Hidden HTML marker: the workflow finds its prior comment by this string and
22
+ * UPDATES it in place, so the monitor never spams a PR with new comments. */
23
+ const STICKY_MARKER = '<!-- forge-pr-monitor -->';
24
+
25
+ /** Cap threads listed per author so a noisy PR can't produce an enormous body. */
26
+ const MAX_THREADS_PER_AUTHOR = 8;
27
+
28
+ /** Group unresolved review-thread comments by author → ordered [author, threads]. */
29
+ function groupByAuthor(comments) {
30
+ const byAuthor = new Map();
31
+ for (const c of (Array.isArray(comments) ? comments : [])) {
32
+ const author = String(c.author || 'unknown');
33
+ if (!byAuthor.has(author)) byAuthor.set(author, []);
34
+ byAuthor.get(author).push(c);
35
+ }
36
+ // Sort authors by descending thread count, then name — stable + deterministic.
37
+ return [...byAuthor.entries()].sort((a, b) => (b[1].length - a[1].length) || a[0].localeCompare(b[0]));
38
+ }
39
+
40
+ /** One-line locator for a thread: `path:line` when known, else the threadId. */
41
+ function threadLocator(t) {
42
+ if (t.path) return t.line != null ? `${t.path}:${t.line}` : t.path;
43
+ return t.threadId || '(thread)';
44
+ }
45
+
46
+ /** Render the unresolved-review-threads section (author-agnostic + fail-closed). */
47
+ function renderThreads(bundle, lines) {
48
+ // Fail-closed: if the thread read was not available for ANY reason — it threw
49
+ // (error set) OR the adapter cannot read comments at all (capability absent,
50
+ // error null) — NEVER render "zero / clean". Only a genuine available:true read
51
+ // may report "no unresolved threads". Guard on `!== true` (not `=== false`) so a
52
+ // producer that omits the flag is also treated as unread, never as clean.
53
+ if (bundle.unresolvedCommentsAvailable !== true) {
54
+ const why = bundle.unresolvedCommentsError || 'thread read unavailable (capability absent)';
55
+ lines.push('### Review threads');
56
+ lines.push(`⚠️ Review threads were **unreadable** this pass (\`${why}\`) — not treated as zero. Re-run once the read recovers.`);
57
+ lines.push('');
58
+ return;
59
+ }
60
+
61
+ const comments = Array.isArray(bundle.unresolvedComments) ? bundle.unresolvedComments : [];
62
+ if (comments.length === 0) {
63
+ lines.push('### Review threads');
64
+ lines.push('✅ No unresolved review threads.');
65
+ lines.push('');
66
+ return;
67
+ }
68
+
69
+ const groups = groupByAuthor(comments);
70
+ lines.push(`### Unresolved review threads (${comments.length})`);
71
+ lines.push('');
72
+ for (const [author, threads] of groups) {
73
+ lines.push(`- **${author}** — ${threads.length}`);
74
+ for (const t of threads.slice(0, MAX_THREADS_PER_AUTHOR)) {
75
+ lines.push(` - \`${threadLocator(t)}\``);
76
+ }
77
+ if (threads.length > MAX_THREADS_PER_AUTHOR) {
78
+ lines.push(` - …and ${threads.length - MAX_THREADS_PER_AUTHOR} more`);
79
+ }
80
+ }
81
+ lines.push('');
82
+ }
83
+
84
+ /** Render the failing / pending check sections (author-agnostic + fail-closed). */
85
+ function renderChecks(bundle, lines) {
86
+ // Fail-closed, mirroring renderThreads: empty ci arrays are AMBIGUOUS — they
87
+ // mean either "read, genuinely all-clear" or "never read (gather outage)". Only
88
+ // an explicit ciAvailable === true lets us render the summary; anything else
89
+ // ("!== true": false or missing) surfaces as unreadable, so the monitor never
90
+ // prints a false "no failing checks" for CI it did not actually read.
91
+ if (bundle.ciAvailable !== true) {
92
+ lines.push('### Checks');
93
+ lines.push('⚠️ Checks were **unreadable** this pass — not treated as green. Re-run once the read recovers.');
94
+ lines.push('');
95
+ return;
96
+ }
97
+
98
+ const ci = bundle.ci || {};
99
+ const failing = Array.isArray(ci.failing) ? ci.failing : [];
100
+ const pending = Array.isArray(ci.pending) ? ci.pending : [];
101
+
102
+ lines.push('### Checks');
103
+ if (failing.length === 0 && pending.length === 0) {
104
+ lines.push('✅ No failing or pending checks.');
105
+ } else {
106
+ if (failing.length > 0) {
107
+ lines.push(`- ❌ **Failing (${failing.length}):** ${failing.map((c) => `\`${c.name || '?'}\``).join(', ')}`);
108
+ }
109
+ if (pending.length > 0) {
110
+ lines.push(`- ⏳ **Pending (${pending.length}):** ${pending.map((c) => `\`${c.name || '?'}\``).join(', ')}`);
111
+ }
112
+ }
113
+ lines.push('');
114
+ }
115
+
116
+ /**
117
+ * Render the sticky monitor comment for a PR-state bundle.
118
+ *
119
+ * @param {object} bundle - a `gatherPrBundle` result (lib/pr-bundle.js).
120
+ * @param {object} [opts]
121
+ * @param {Date} [opts.now] - injected clock for deterministic output.
122
+ * @returns {{ marker: string, body: string }}
123
+ */
124
+ function renderStickyComment(bundle = {}, opts = {}) {
125
+ const now = opts.now instanceof Date ? opts.now : new Date();
126
+ const lines = [];
127
+
128
+ // The marker MUST be the very first bytes so the workflow's substring match
129
+ // finds the prior comment regardless of any rendering below it.
130
+ lines.push(STICKY_MARKER);
131
+ lines.push('## 🔭 Forge PR Monitor');
132
+ lines.push('');
133
+ lines.push('_Surfaces open review + check state so async feedback never rots. This monitor **does not merge** and does not post a pass/fail verdict._');
134
+ lines.push('');
135
+
136
+ renderThreads(bundle, lines);
137
+ renderChecks(bundle, lines);
138
+
139
+ const branch = bundle.branch || {};
140
+ if ((branch.behind || 0) > 0) {
141
+ lines.push(`> Branch is **${branch.behind}** commit(s) behind base.`);
142
+ lines.push('');
143
+ }
144
+
145
+ lines.push('---');
146
+ lines.push(`<sub>Updated ${now.toISOString()} · surface-only monitor · never merges, never verdicts.</sub>`);
147
+
148
+ return { marker: STICKY_MARKER, body: lines.join('\n') };
149
+ }
150
+
151
+ module.exports = {
152
+ renderStickyComment,
153
+ groupByAuthor,
154
+ threadLocator,
155
+ STICKY_MARKER,
156
+ MAX_THREADS_PER_AUTHOR,
157
+ };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PR-monitor lifecycle — auto-start the watch loop, detached and idempotent, on
5
+ * `forge ship` success. This is what makes the monitor CONSTANT without an agent
6
+ * having to remember to run it: the moment a PR exists, a background
7
+ * `forge shepherd watch <pr>` begins keeping the journal warm, and any harness
8
+ * re-attaches later with `forge shepherd events <pr> --since <seq>`.
9
+ *
10
+ * Contract (all guaranteed here): NEVER throws, NEVER blocks, NEVER fails ship.
11
+ * The detached child is `unref`'d so it cannot keep the ship process alive, and
12
+ * every branch is wrapped so a spawn/gh failure degrades to "not started" rather
13
+ * than surfacing to the caller. Stop-on-merge belongs to the watch loop's
14
+ * terminal pass, not here.
15
+ *
16
+ * @module pr-monitor/watch-lifecycle
17
+ */
18
+
19
+ const path = require('node:path');
20
+ const { spawn, execFileSync } = require('node:child_process');
21
+
22
+ const journal = require('./journal');
23
+
24
+ /** Absolute path to the forge CLI entrypoint (this file is lib/pr-monitor/). */
25
+ function forgeBin() {
26
+ return path.join(__dirname, '..', '..', 'bin', 'forge.js');
27
+ }
28
+
29
+ /**
30
+ * Best-effort repo slug (the bare repo NAME, matching the shepherd's `ctx.repo`)
31
+ * from `git remote get-url origin`, so the idempotency check hits the same
32
+ * journal dir the watcher itself uses. Returns null on any failure — the caller
33
+ * then falls through to spawn and relies on the watch loop's own de-dup.
34
+ */
35
+ function defaultResolveSlug({ cwd, exec = execFileSync }) {
36
+ try {
37
+ const url = exec('git', ['remote', 'get-url', 'origin'], {
38
+ cwd, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
39
+ }).trim();
40
+ const match = /[/:][^/]+\/([^/]+?)(?:\.git)?$/.exec(url);
41
+ return match ? match[1] : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Start (or no-op) a detached `forge shepherd watch <pr>`.
49
+ *
50
+ * @param {object} opts
51
+ * @param {string|number} opts.prNumber - the PR to watch.
52
+ * @param {string} [opts.cwd] - repo root (default process.cwd()).
53
+ * @param {Function} [opts.spawn] - child spawner (test injection).
54
+ * @param {Function} [opts.exec] - git runner for slug resolution (test injection).
55
+ * @param {object} [opts.journal] - journal module (test injection).
56
+ * @param {Function} [opts.resolveSlug] - slug resolver (test injection).
57
+ * @returns {{ started: boolean, pid?: number|null, reason?: string }} — never throws.
58
+ */
59
+ function startPrWatcherDetached(opts = {}) {
60
+ const { prNumber, cwd = process.cwd() } = opts;
61
+ const spawnFn = opts.spawn || spawn;
62
+ const journalMod = opts.journal || journal;
63
+ const resolveSlug = opts.resolveSlug || defaultResolveSlug;
64
+ try {
65
+ if (!prNumber) return { started: false, reason: 'no-pr' };
66
+
67
+ const slug = resolveSlug({ cwd, exec: opts.exec });
68
+ if (slug) {
69
+ const dir = journalMod.journalDir({ root: cwd, repo: slug, pr: prNumber });
70
+ if (journalMod.watcherRunning(dir)) return { started: false, reason: 'already-running' };
71
+ }
72
+
73
+ const child = spawnFn(
74
+ process.execPath,
75
+ [forgeBin(), 'shepherd', 'watch', String(prNumber)],
76
+ { cwd, detached: true, stdio: 'ignore', windowsHide: true },
77
+ );
78
+ // spawn can emit an ASYNC 'error' (ENOENT/EACCES) AFTER returning; with no
79
+ // listener that becomes an unhandled exception that could crash ship. A no-op
80
+ // handler keeps a failed detached start best-effort (the watch loop's own
81
+ // journal claim is the authoritative de-dup anyway).
82
+ if (child && typeof child.on === 'function') child.on('error', () => {});
83
+ if (child && typeof child.unref === 'function') child.unref();
84
+ return { started: true, pid: child?.pid ?? null };
85
+ } catch (err) {
86
+ // Lifecycle auto-start must never fail ship — degrade to "not started".
87
+ return { started: false, reason: err.message };
88
+ }
89
+ }
90
+
91
+ module.exports = {
92
+ startPrWatcherDetached,
93
+ defaultResolveSlug,
94
+ forgeBin,
95
+ };