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