forge-workflow 0.0.10 → 0.1.0-beta.3
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 +82 -5
- package/.forge/hooks/forge-native-hook.js +431 -0
- package/.forge/protected-paths.yaml +157 -0
- package/AGENTS.md +151 -61
- package/CHANGELOG.md +709 -0
- package/CLAUDE.md +9 -118
- package/QUICKSTART.md +175 -0
- package/README.md +275 -365
- package/bin/forge-cmd.js +120 -9
- package/bin/forge-preflight.js +26 -5
- package/bin/forge.js +532 -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/forge/TOOLCHAIN.md +670 -0
- 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 +121 -0
- package/docs/guides/SUPPORT.md +190 -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 +214 -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/{TOOLCHAIN.md → reference/TOOLCHAIN.md} +62 -47
- 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 +155 -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/activation/ensure-forge-home.js +135 -0
- package/lib/adapter-cli.js +307 -0
- package/lib/adapters/beads-issue-adapter.js +127 -0
- package/lib/adapters/beads-kernel-compat.js +1109 -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 +139 -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-detect.js +60 -0
- package/lib/beads-nudge.js +91 -0
- package/lib/beads-setup.js +121 -0
- package/lib/beads-sync-scaffold.js +25 -101
- package/lib/codex-skills.js +51 -1
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +780 -77
- package/lib/commands/_manifest.js +93 -0
- package/lib/commands/_registry.js +99 -34
- package/lib/commands/_resolve-command-opts.js +230 -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 +209 -0
- package/lib/commands/hooks.js +377 -0
- package/lib/commands/inbox.js +118 -0
- package/lib/commands/init.js +604 -0
- package/lib/commands/insights.js +79 -0
- package/lib/commands/issue.js +12 -1
- package/lib/commands/issues.js +17 -0
- package/lib/commands/lint.js +5 -0
- package/lib/commands/list.js +2 -2
- package/lib/commands/memory.js +81 -0
- package/lib/commands/merge.js +312 -0
- package/lib/commands/migrate.js +362 -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 +481 -29
- package/lib/commands/pr.js +88 -0
- package/lib/commands/preflight.js +211 -0
- package/lib/commands/prime.js +13 -0
- package/lib/commands/push.js +135 -2
- package/lib/commands/ready.js +2 -2
- package/lib/commands/recall.js +171 -0
- package/lib/commands/recap.js +75 -0
- package/lib/commands/recommend.js +0 -1
- package/lib/commands/release.js +104 -0
- package/lib/commands/remember.js +140 -0
- package/lib/commands/role.js +99 -0
- package/lib/commands/serve.js +581 -0
- package/lib/commands/setup.js +900 -971
- package/lib/commands/shepherd.js +501 -0
- package/lib/commands/ship.js +59 -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 +158 -21
- package/lib/commands/sync.js +34 -46
- package/lib/commands/team.js +4 -1
- package/lib/commands/test.js +43 -27
- package/lib/commands/update.js +2 -2
- package/lib/commands/upgrade.js +47 -0
- package/lib/commands/validate.js +43 -18
- package/lib/commands/worktree.js +362 -99
- package/lib/config-writer.js +202 -0
- package/lib/control-plane.js +236 -0
- package/lib/core/runtime-graph.js +977 -0
- package/lib/dep-guard/keyword-ripple.js +2 -2
- package/lib/deprecated-sync-cleanup.js +362 -0
- package/lib/detect-agent.js +2 -28
- package/lib/detect-worktree.js +35 -9
- 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 +382 -11
- package/lib/forge-lock.js +262 -0
- package/lib/gate-events.js +192 -0
- package/lib/global-flags.js +104 -0
- package/lib/greptile-match.js +7 -63
- package/lib/grounding/context-events.js +230 -0
- package/lib/grounding/read-first.js +112 -0
- package/lib/harness-capability-matrix.js +380 -0
- package/lib/hook-global-installer.js +347 -0
- package/lib/hook-renderer.js +541 -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/kernel/backing-issue.js +311 -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/owned-kernel.js +43 -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 +2540 -0
- package/lib/kernel/taxonomy-validator.js +394 -0
- package/lib/lefthook-check.js +3 -2
- 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/auto-actions.js +175 -0
- package/lib/pr-monitor/differ.js +195 -0
- package/lib/pr-monitor/digest.js +206 -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 +192 -0
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-monitor/watch-lifecycle.js +95 -0
- package/lib/pr-monitor/watch.js +247 -0
- package/lib/pr-pull.js +1314 -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 +99 -497
- package/lib/protected-path-manifest.js +281 -0
- package/lib/protected-state-surfaces.js +387 -0
- package/lib/release-readiness.js +2105 -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 +241 -20
- 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/scoring.js +17 -3
- package/lib/status/beads-snapshot.js +45 -2
- package/lib/status/presenter.js +169 -18
- 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 +251 -0
- package/lib/workflow/enforce-stage.js +351 -45
- package/lib/workflow/stage-transition.js +115 -0
- package/lib/workflow/stages.js +30 -6
- package/lib/workflow/state-manager.js +11 -22
- package/lib/workflow/state.js +23 -1
- package/lib/workflow-profiles.js +17 -5
- package/package.json +37 -35
- 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 +81 -57
- package/scripts/beads-upgrade-smoke.sh +24 -3
- 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.sh +22 -3
- 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 +49 -84
- 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 +50 -83
- 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/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-auto-actions.js +93 -0
- package/scripts/pr-coordinator.sh +3 -0
- package/scripts/pr-verdict-label.js +50 -0
- package/scripts/preflight-sonar.eslint.config.mjs +44 -0
- package/scripts/preflight.sh +21 -94
- package/scripts/protected-state-check.js +104 -0
- package/scripts/smart-status.sh +60 -57
- 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-utils.sh +3 -0
- package/scripts/test-ci-shard.js +13 -6
- package/scripts/test.js +95 -12
- 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} +44 -50
- 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} +132 -157
- 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/{.github/prompts/ship.prompt.md → skills/ship/SKILL.md} +81 -45
- 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/{.github/prompts/status.prompt.md → skills/status/SKILL.md} +20 -10
- 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/verify.prompt.md +0 -271
- package/.github/workflows/beads-to-github.yml +0 -89
- package/.github/workflows/github-to-beads.yml +0 -100
- 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 -281
- 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-bootstrap.js +0 -225
- package/lib/beads-health-check.js +0 -188
- package/lib/commands/commands-reset.js +0 -147
- package/opencode.json +0 -67
- package/scripts/beads-context.test.js +0 -584
- 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 -356
- package/scripts/github-beads-sync/label-mapper.mjs +0 -54
- package/scripts/github-beads-sync/mapping.mjs +0 -132
- package/scripts/github-beads-sync/reverse-sync-cli.mjs +0 -31
- package/scripts/github-beads-sync/reverse-sync.mjs +0 -162
- 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,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,192 @@
|
|
|
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 leads with the one-line actionable
|
|
9
|
+
* verdict (mirroring the `pr-verdict:*` label the workflow lands), then lists the
|
|
10
|
+
* unresolved review threads (grouped by author, ANY author) plus the failing and
|
|
11
|
+
* pending checks so async review-bot / human feedback in a window nobody is
|
|
12
|
+
* watching cannot rot. The verdict LABELS state (check-failed / threads-open /
|
|
13
|
+
* mergeable / …); it is NOT a merge action — it never merges, never resolves
|
|
14
|
+
* threads, never blocks, and is fail-closed (`unknown` on unreadable signals).
|
|
15
|
+
*
|
|
16
|
+
* Pure and deterministic: same bundle + same injected clock → same body, which
|
|
17
|
+
* is what lets the workflow rewrite the sticky comment in place without churn.
|
|
18
|
+
*
|
|
19
|
+
* @module pr-monitor/render-sticky
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Presentation-only headline for each canonical merge verdict (lib/pr-pull.js).
|
|
24
|
+
* The verdict VALUE is computed once by pr-pull (`forge shepherd --pull --json`)
|
|
25
|
+
* and passed in — this map only decides how to DISPLAY it, so there is no second
|
|
26
|
+
* verdict ladder to drift.
|
|
27
|
+
*/
|
|
28
|
+
const VERDICT_HEADLINE = {
|
|
29
|
+
UNKNOWN: '⚪ **Verdict: `unknown`** — a signal was unreadable; state unconfirmed (fail-closed).',
|
|
30
|
+
'BLOCKED-CONFLICT': '🔀 **Verdict: `blocked-conflict`** — branch conflicts with base; rebase/merge and resolve.',
|
|
31
|
+
BEHIND: '⬇️ **Verdict: `behind`** — branch is behind base; update/rebase (protection requires up-to-date).',
|
|
32
|
+
'BLOCKED-CHECKS': '🔴 **Verdict: `blocked-checks`** — a required check is failing/missing; fix it.',
|
|
33
|
+
'BLOCKED-THREADS': '🟠 **Verdict: `blocked-threads`** — unresolved review threads need addressing.',
|
|
34
|
+
'REVIEW-PENDING': '🟡 **Verdict: `review-pending`** — awaiting review / settle window; not ready yet.',
|
|
35
|
+
'CLEAN-MERGEABLE': '🟢 **Verdict: `clean-mergeable`** — green + zero unresolved threads; ready for a human to merge.',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render the one-line verdict headline for a canonical verdict string. Unknown or
|
|
40
|
+
* missing input falls closed to the `unknown` headline.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} verdict
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function verdictHeadline(verdict) {
|
|
46
|
+
return VERDICT_HEADLINE[String(verdict || '').toUpperCase()] || VERDICT_HEADLINE.UNKNOWN;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Hidden HTML marker: the workflow finds its prior comment by this string and
|
|
50
|
+
* UPDATES it in place, so the monitor never spams a PR with new comments. */
|
|
51
|
+
const STICKY_MARKER = '<!-- forge-pr-monitor -->';
|
|
52
|
+
|
|
53
|
+
/** Cap threads listed per author so a noisy PR can't produce an enormous body. */
|
|
54
|
+
const MAX_THREADS_PER_AUTHOR = 8;
|
|
55
|
+
|
|
56
|
+
/** Group unresolved review-thread comments by author → ordered [author, threads]. */
|
|
57
|
+
function groupByAuthor(comments) {
|
|
58
|
+
const byAuthor = new Map();
|
|
59
|
+
for (const c of (Array.isArray(comments) ? comments : [])) {
|
|
60
|
+
const author = String(c.author || 'unknown');
|
|
61
|
+
if (!byAuthor.has(author)) byAuthor.set(author, []);
|
|
62
|
+
byAuthor.get(author).push(c);
|
|
63
|
+
}
|
|
64
|
+
// Sort authors by descending thread count, then name — stable + deterministic.
|
|
65
|
+
return [...byAuthor.entries()].sort((a, b) => (b[1].length - a[1].length) || a[0].localeCompare(b[0]));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One-line locator for a thread: `path:line` when known, else the threadId. */
|
|
69
|
+
function threadLocator(t) {
|
|
70
|
+
if (t.path) return t.line != null ? `${t.path}:${t.line}` : t.path;
|
|
71
|
+
return t.threadId || '(thread)';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Render the unresolved-review-threads section (author-agnostic + fail-closed). */
|
|
75
|
+
function renderThreads(bundle, lines) {
|
|
76
|
+
// Fail-closed: if the thread read was not available for ANY reason — it threw
|
|
77
|
+
// (error set) OR the adapter cannot read comments at all (capability absent,
|
|
78
|
+
// error null) — NEVER render "zero / clean". Only a genuine available:true read
|
|
79
|
+
// may report "no unresolved threads". Guard on `!== true` (not `=== false`) so a
|
|
80
|
+
// producer that omits the flag is also treated as unread, never as clean.
|
|
81
|
+
if (bundle.unresolvedCommentsAvailable !== true) {
|
|
82
|
+
const why = bundle.unresolvedCommentsError || 'thread read unavailable (capability absent)';
|
|
83
|
+
lines.push('### Review threads');
|
|
84
|
+
lines.push(`⚠️ Review threads were **unreadable** this pass (\`${why}\`) — not treated as zero. Re-run once the read recovers.`);
|
|
85
|
+
lines.push('');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const comments = Array.isArray(bundle.unresolvedComments) ? bundle.unresolvedComments : [];
|
|
90
|
+
if (comments.length === 0) {
|
|
91
|
+
lines.push('### Review threads');
|
|
92
|
+
lines.push('✅ No unresolved review threads.');
|
|
93
|
+
lines.push('');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const groups = groupByAuthor(comments);
|
|
98
|
+
lines.push(`### Unresolved review threads (${comments.length})`);
|
|
99
|
+
lines.push('');
|
|
100
|
+
for (const [author, threads] of groups) {
|
|
101
|
+
lines.push(`- **${author}** — ${threads.length}`);
|
|
102
|
+
for (const t of threads.slice(0, MAX_THREADS_PER_AUTHOR)) {
|
|
103
|
+
lines.push(` - \`${threadLocator(t)}\``);
|
|
104
|
+
}
|
|
105
|
+
if (threads.length > MAX_THREADS_PER_AUTHOR) {
|
|
106
|
+
lines.push(` - …and ${threads.length - MAX_THREADS_PER_AUTHOR} more`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
lines.push('');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Render the failing / pending check sections (author-agnostic + fail-closed). */
|
|
113
|
+
function renderChecks(bundle, lines) {
|
|
114
|
+
// Fail-closed, mirroring renderThreads: empty ci arrays are AMBIGUOUS — they
|
|
115
|
+
// mean either "read, genuinely all-clear" or "never read (gather outage)". Only
|
|
116
|
+
// an explicit ciAvailable === true lets us render the summary; anything else
|
|
117
|
+
// ("!== true": false or missing) surfaces as unreadable, so the monitor never
|
|
118
|
+
// prints a false "no failing checks" for CI it did not actually read.
|
|
119
|
+
if (bundle.ciAvailable !== true) {
|
|
120
|
+
lines.push('### Checks');
|
|
121
|
+
lines.push('⚠️ Checks were **unreadable** this pass — not treated as green. Re-run once the read recovers.');
|
|
122
|
+
lines.push('');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ci = bundle.ci || {};
|
|
127
|
+
const failing = Array.isArray(ci.failing) ? ci.failing : [];
|
|
128
|
+
const pending = Array.isArray(ci.pending) ? ci.pending : [];
|
|
129
|
+
|
|
130
|
+
lines.push('### Checks');
|
|
131
|
+
if (failing.length === 0 && pending.length === 0) {
|
|
132
|
+
lines.push('✅ No failing or pending checks.');
|
|
133
|
+
} else {
|
|
134
|
+
if (failing.length > 0) {
|
|
135
|
+
lines.push(`- ❌ **Failing (${failing.length}):** ${failing.map((c) => `\`${c.name || '?'}\``).join(', ')}`);
|
|
136
|
+
}
|
|
137
|
+
if (pending.length > 0) {
|
|
138
|
+
lines.push(`- ⏳ **Pending (${pending.length}):** ${pending.map((c) => `\`${c.name || '?'}\``).join(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
lines.push('');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Render the sticky monitor comment for a PR-state bundle.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} bundle - a `gatherPrBundle` result (lib/pr-bundle.js).
|
|
148
|
+
* @param {object} [opts]
|
|
149
|
+
* @param {Date} [opts.now] - injected clock for deterministic output.
|
|
150
|
+
* @returns {{ marker: string, body: string }}
|
|
151
|
+
*/
|
|
152
|
+
function renderStickyComment(bundle = {}, opts = {}) {
|
|
153
|
+
const now = opts.now instanceof Date ? opts.now : new Date();
|
|
154
|
+
const lines = [];
|
|
155
|
+
|
|
156
|
+
// The marker MUST be the very first bytes so the workflow's substring match
|
|
157
|
+
// finds the prior comment regardless of any rendering below it.
|
|
158
|
+
lines.push(STICKY_MARKER);
|
|
159
|
+
lines.push('## 🔭 Forge PR Monitor');
|
|
160
|
+
lines.push('');
|
|
161
|
+
// Lead with the actionable verdict — the SAME value as the pr-verdict:* label
|
|
162
|
+
// and `forge shepherd --pull --json` (passed in via opts.verdict, computed once
|
|
163
|
+
// by pr-pull). Surface only: it labels state; this monitor **does not merge**
|
|
164
|
+
// and never resolves review threads.
|
|
165
|
+
lines.push(verdictHeadline(opts.verdict));
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push('_Surfaces open review + check state so async feedback never rots. This monitor **does not merge** and never resolves review threads — a human merges in the GitHub UI._');
|
|
168
|
+
lines.push('');
|
|
169
|
+
|
|
170
|
+
renderThreads(bundle, lines);
|
|
171
|
+
renderChecks(bundle, lines);
|
|
172
|
+
|
|
173
|
+
const branch = bundle.branch || {};
|
|
174
|
+
if ((branch.behind || 0) > 0) {
|
|
175
|
+
lines.push(`> Branch is **${branch.behind}** commit(s) behind base.`);
|
|
176
|
+
lines.push('');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
lines.push('---');
|
|
180
|
+
lines.push(`<sub>Updated ${now.toISOString()} · surface-only monitor · labels state, never merges, never resolves threads.</sub>`);
|
|
181
|
+
|
|
182
|
+
return { marker: STICKY_MARKER, body: lines.join('\n') };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
renderStickyComment,
|
|
187
|
+
verdictHeadline,
|
|
188
|
+
groupByAuthor,
|
|
189
|
+
threadLocator,
|
|
190
|
+
STICKY_MARKER,
|
|
191
|
+
MAX_THREADS_PER_AUTHOR,
|
|
192
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR-monitor sticky-comment upsert — race-safe, converges to EXACTLY ONE sticky
|
|
5
|
+
* comment per PR even under a concurrent burst of workflow runs.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: the pr-monitor workflow deliberately has NO `concurrency:`
|
|
8
|
+
* group. A per-PR group does not help — GitHub's queue replacement cancels the
|
|
9
|
+
* previously-PENDING run in a group UNCONDITIONALLY (independent of
|
|
10
|
+
* cancel-in-progress), and this workflow's triggers (check_suite:completed fires
|
|
11
|
+
* ~10+ times per push, plus reviews/comments) burst hard, so a group left a trail
|
|
12
|
+
* of CANCELLED runs that render as red/non-SUCCESS checks and tripped merge-gate
|
|
13
|
+
* tooling (kernel issue 97e6a146). Dropping the group removes the cancellations,
|
|
14
|
+
* but then two concurrent first-runs on a PR with no sticky yet would BOTH find
|
|
15
|
+
* nothing and BOTH create one → duplicate sticky comments. This module closes
|
|
16
|
+
* that race deterministically instead.
|
|
17
|
+
*
|
|
18
|
+
* Reconcile-to-one algorithm:
|
|
19
|
+
* 1. List marker comments. If none, create one, then RE-LIST (a concurrent run
|
|
20
|
+
* may have created its own in the same burst).
|
|
21
|
+
* 2. Pick the deterministic survivor: the LOWEST comment id (oldest). Every
|
|
22
|
+
* concurrent run picks the SAME survivor, so they never fight.
|
|
23
|
+
* 3. Update the survivor with the latest body; delete every other marker
|
|
24
|
+
* comment. Deletes are idempotent (a 404 means a peer already removed it).
|
|
25
|
+
*
|
|
26
|
+
* A create that lands AFTER a run's re-list is self-healed by the next event:
|
|
27
|
+
* every run reconciles to one, and events keep arriving, so the PR converges to a
|
|
28
|
+
* single sticky comment.
|
|
29
|
+
*
|
|
30
|
+
* @module pr-monitor/upsert-sticky
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const { execFileSync } = require('node:child_process');
|
|
34
|
+
|
|
35
|
+
/** Ids of comments whose body carries the sticky marker. */
|
|
36
|
+
function markerCommentIds(comments, marker) {
|
|
37
|
+
return (Array.isArray(comments) ? comments : [])
|
|
38
|
+
.filter((comment) => typeof comment.body === 'string' && comment.body.includes(marker))
|
|
39
|
+
.map((comment) => comment.id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ascending by numeric id, so the survivor (index 0) is the oldest comment. */
|
|
43
|
+
function sortIdsAscending(ids) {
|
|
44
|
+
return [...ids].sort((left, right) => Number(left) - Number(right));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Drive a client to exactly one sticky comment. `client` abstracts the GitHub
|
|
49
|
+
* calls so the reconcile logic is unit-testable without the network:
|
|
50
|
+
* - list() → array of { id, body }
|
|
51
|
+
* - create() → create a new sticky comment (body supplied by the client)
|
|
52
|
+
* - update(id) → overwrite comment `id` with the latest body
|
|
53
|
+
* - remove(id) → delete comment `id` (must tolerate an already-deleted 404)
|
|
54
|
+
*
|
|
55
|
+
* @returns {Promise<{ survivor: (number|string|null), deleted: Array<number|string> }>}
|
|
56
|
+
*/
|
|
57
|
+
async function upsertStickyComment({ marker }, client) {
|
|
58
|
+
let ids = markerCommentIds(await client.list(), marker);
|
|
59
|
+
|
|
60
|
+
if (ids.length === 0) {
|
|
61
|
+
await client.create();
|
|
62
|
+
// Re-list: a concurrent run may have created its own sticky in this burst.
|
|
63
|
+
ids = markerCommentIds(await client.list(), marker);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (ids.length === 0) {
|
|
67
|
+
// The just-created comment is not visible yet (eventual consistency); its
|
|
68
|
+
// body is already correct, and the next event will reconcile if a peer raced.
|
|
69
|
+
return { survivor: null, deleted: [] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ids = sortIdsAscending(ids);
|
|
73
|
+
const survivor = ids[0];
|
|
74
|
+
await client.update(survivor);
|
|
75
|
+
|
|
76
|
+
const deleted = [];
|
|
77
|
+
for (const id of ids.slice(1)) {
|
|
78
|
+
await client.remove(id);
|
|
79
|
+
deleted.push(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { survivor, deleted };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Single choke point for the `gh` CLI, matching lib/commands/merge.js. `gh` is a
|
|
86
|
+
// hardcoded literal (never user input) and args are an array (no shell), so the
|
|
87
|
+
// S4036 PATH-search finding is a false positive in this developer-tool context;
|
|
88
|
+
// one annotation here covers every call site. `encoding: 'utf8'` also pipes
|
|
89
|
+
// stderr onto the thrown error, so isAlreadyGone() below can classify failures.
|
|
90
|
+
function runGh(args) {
|
|
91
|
+
return execFileSync('gh', args, { encoding: 'utf8' }); // NOSONAR S4036 - hardcoded CLI (gh), args array (no shell), developer-tool context
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* True only when a `gh api` failure means the target comment is already absent
|
|
96
|
+
* (HTTP 404 / 410) — the benign "a peer got there first" case. Auth failures,
|
|
97
|
+
* rate limits (403), and every other error return false so they propagate and
|
|
98
|
+
* surface a diagnostic instead of silently breaking the exactly-one invariant.
|
|
99
|
+
*/
|
|
100
|
+
function isAlreadyGone(error) {
|
|
101
|
+
const text = `${error && error.stderr ? error.stderr : ''} ${error && error.message ? error.message : ''}`;
|
|
102
|
+
return /HTTP 404|HTTP 410|\bNot Found\b|\bGone\b/i.test(text);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* GitHub-backed client (shells to `gh api`, like lib/pr-monitor/gather.js). The
|
|
107
|
+
* reconcile logic above is unit-tested; the error-tolerance in update/remove is
|
|
108
|
+
* too, via an injectable `run`. create/update both send the same pre-rendered
|
|
109
|
+
* payload file the render step wrote, so the body is identical.
|
|
110
|
+
*/
|
|
111
|
+
function ghStickyClient({ repo, pr, payloadFile, run = runGh }) {
|
|
112
|
+
return {
|
|
113
|
+
async list() {
|
|
114
|
+
const out = run(['api', `repos/${repo}/issues/${pr}/comments`, '--paginate']);
|
|
115
|
+
return out && out.trim() ? JSON.parse(out) : [];
|
|
116
|
+
},
|
|
117
|
+
async create() {
|
|
118
|
+
run(['api', '-X', 'POST', `repos/${repo}/issues/${pr}/comments`, '--input', payloadFile]);
|
|
119
|
+
},
|
|
120
|
+
async update(id) {
|
|
121
|
+
try {
|
|
122
|
+
run(['api', '-X', 'PATCH', `repos/${repo}/issues/comments/${id}`, '--input', payloadFile]);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (!isAlreadyGone(error)) {
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
// Our chosen survivor was deleted by a peer whose survivor had a lower id:
|
|
128
|
+
// the peer's sticky wins, exactly-one still holds, so we are done.
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
async remove(id) {
|
|
132
|
+
try {
|
|
133
|
+
run(['api', '-X', 'DELETE', `repos/${repo}/issues/comments/${id}`]);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (!isAlreadyGone(error)) {
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
// Already gone (a concurrent run deleted it first) — the goal state holds.
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function main() {
|
|
145
|
+
const repo = process.env.GH_REPO;
|
|
146
|
+
const pr = process.env.PR;
|
|
147
|
+
const payloadFile = process.env.STICKY_PAYLOAD_FILE || 'monitor-payload.json';
|
|
148
|
+
const { STICKY_MARKER } = require('./render-sticky');
|
|
149
|
+
|
|
150
|
+
const client = ghStickyClient({ repo, pr, payloadFile });
|
|
151
|
+
const { survivor, deleted } = await upsertStickyComment({ marker: STICKY_MARKER }, client);
|
|
152
|
+
const survivorLabel = survivor === null ? 'created (not yet visible)' : survivor;
|
|
153
|
+
console.log(`Sticky comment reconciled to one: survivor=${survivorLabel}, deleted=${deleted.length}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (require.main === module) {
|
|
157
|
+
main().catch((error) => {
|
|
158
|
+
console.error(error.message);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
upsertStickyComment,
|
|
165
|
+
markerCommentIds,
|
|
166
|
+
sortIdsAscending,
|
|
167
|
+
isAlreadyGone,
|
|
168
|
+
ghStickyClient,
|
|
169
|
+
};
|
|
@@ -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
|
+
};
|