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,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR-shepherd digest — the thin CONSUMER half of the constant monitor (epic
|
|
5
|
+
* c2d398e5, 33e1bbd3). The constant watch loop is the PRODUCER: it writes the
|
|
6
|
+
* per-PR NDJSON journals under `.forge/pr-monitor/<repo>-<pr>/` (the `forge
|
|
7
|
+
* shepherd events` pull surface reads those same records back). Nothing, though,
|
|
8
|
+
* surfaced those events to a working agent. This module is a pure READER: it
|
|
9
|
+
* reads the NEW budget events across all PR journals since a persisted per-PR
|
|
10
|
+
* CONSUMER cursor, renders a COMPACT capped summary, and advances the cursor —
|
|
11
|
+
* the exact payload a harness hook (Claude UserPromptSubmit) injects each turn.
|
|
12
|
+
*
|
|
13
|
+
* The CORE (events/journal/watch/monitor) is untouched: this only READS the
|
|
14
|
+
* journal via `journal.readEventsSince` and keeps its OWN `consumer.cursor`
|
|
15
|
+
* (distinct from the watcher's snapshot), so consumption never disturbs
|
|
16
|
+
* production. Every function is fail-open — a bad journal degrades to skipped,
|
|
17
|
+
* never throws — because it feeds a hook that must never block a prompt.
|
|
18
|
+
*
|
|
19
|
+
* @module pr-monitor/digest
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
const journalMod = require('./journal');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The event types worth surfacing on each turn — the ACTIONABLE transitions
|
|
29
|
+
* (verdict flip, a failed check, a new review thread, terminal merge/close).
|
|
30
|
+
* Everything else (head pushes, green checks, degraded notices) stays in the
|
|
31
|
+
* journal for `forge shepherd events` but is NOT pushed, to keep the injected
|
|
32
|
+
* context tiny.
|
|
33
|
+
*/
|
|
34
|
+
const BUDGET_TYPES = Object.freeze(new Set([
|
|
35
|
+
'verdict.changed', 'check.failed', 'thread.opened', 'pr.merged', 'pr.closed',
|
|
36
|
+
]));
|
|
37
|
+
|
|
38
|
+
const DEFAULT_CAP = 8;
|
|
39
|
+
const CONSUMER_CURSOR_FILE = 'consumer.cursor';
|
|
40
|
+
|
|
41
|
+
/** Absolute `.forge/pr-monitor` root for a project. */
|
|
42
|
+
function monitorRoot(root) {
|
|
43
|
+
return path.join(root, '.forge', 'pr-monitor');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The per-PR consumer cursor path (distinct from the watcher's snapshot/pid). */
|
|
47
|
+
function cursorPath(dir) {
|
|
48
|
+
return path.join(dir, CONSUMER_CURSOR_FILE);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* List absolute PR journal dirs (those containing an events.ndjson). Fail-open:
|
|
53
|
+
* a missing monitor root or unreadable dir yields []. Injectable fs for tests.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} root
|
|
56
|
+
* @param {{ readdirSync?: Function, existsSync?: Function }} [deps]
|
|
57
|
+
* @returns {string[]}
|
|
58
|
+
*/
|
|
59
|
+
function discoverPrDirs(root, deps = {}) {
|
|
60
|
+
const readdir = deps.readdirSync || fs.readdirSync;
|
|
61
|
+
const exists = deps.existsSync || fs.existsSync;
|
|
62
|
+
try {
|
|
63
|
+
return readdir(monitorRoot(root), { withFileTypes: true })
|
|
64
|
+
.filter((e) => e.isDirectory())
|
|
65
|
+
.map((e) => path.join(monitorRoot(root), e.name))
|
|
66
|
+
.filter((dir) => exists(path.join(dir, 'events.ndjson')));
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read a PR's consumer cursor (the last consumed seq). Fail-open → 0 (no cursor,
|
|
74
|
+
* unreadable, or malformed all mean "start from the beginning").
|
|
75
|
+
*
|
|
76
|
+
* @param {string} dir
|
|
77
|
+
* @param {{ readFileSync?: Function }} [deps]
|
|
78
|
+
* @returns {number}
|
|
79
|
+
*/
|
|
80
|
+
function readConsumerCursor(dir, deps = {}) {
|
|
81
|
+
const readFile = deps.readFileSync || fs.readFileSync;
|
|
82
|
+
try {
|
|
83
|
+
const obj = JSON.parse(readFile(cursorPath(dir), 'utf8'));
|
|
84
|
+
const seq = Number(obj && obj.seq);
|
|
85
|
+
return Number.isFinite(seq) && seq >= 0 ? seq : 0;
|
|
86
|
+
} catch {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Persist a PR's consumer cursor. Fail-open: an unwritable cursor returns false
|
|
93
|
+
* (next turn re-reads the same events — a duplicate nudge, never a crash).
|
|
94
|
+
*
|
|
95
|
+
* @param {string} dir
|
|
96
|
+
* @param {number} seq
|
|
97
|
+
* @param {{ writeFileSync?: Function }} [deps]
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
function writeConsumerCursor(dir, seq, deps = {}) {
|
|
101
|
+
const writeFile = deps.writeFileSync || fs.writeFileSync;
|
|
102
|
+
try {
|
|
103
|
+
writeFile(cursorPath(dir), `${JSON.stringify({ seq: Number(seq) || 0 })}\n`);
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A compact one-line label for a budget event. PURE. Bounded so injected context
|
|
112
|
+
* stays tiny; the full record is always available via `forge shepherd events`.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} e - a journal event.
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
function renderEventLine(e) {
|
|
118
|
+
const pr = e.pr != null ? `#${e.pr}` : '#?';
|
|
119
|
+
const d = e.data || {};
|
|
120
|
+
let detail;
|
|
121
|
+
switch (e.type) {
|
|
122
|
+
case 'verdict.changed': detail = (e.verdict && (e.verdict.verdict || e.verdict.state)) || d.verdict || d.to || ''; break;
|
|
123
|
+
case 'check.failed': detail = d.name || d.check || ''; break;
|
|
124
|
+
case 'thread.opened': detail = d.author ? `by ${d.author}` : (d.path || ''); break;
|
|
125
|
+
case 'pr.merged': detail = 'merged'; break;
|
|
126
|
+
case 'pr.closed': detail = 'closed'; break;
|
|
127
|
+
default: detail = '';
|
|
128
|
+
}
|
|
129
|
+
const suffix = detail ? `: ${String(detail).slice(0, 60)}` : '';
|
|
130
|
+
return `- PR ${pr} ${e.type}${suffix}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* PURE: filter events to the budget types, cap the count, and render lines.
|
|
135
|
+
*
|
|
136
|
+
* @param {object[]} events
|
|
137
|
+
* @param {{ cap?: number }} [opts]
|
|
138
|
+
* @returns {{ lines: string[], total: number }}
|
|
139
|
+
*/
|
|
140
|
+
function renderDigestLines(events, { cap = DEFAULT_CAP } = {}) {
|
|
141
|
+
const budget = (Array.isArray(events) ? events : []).filter((e) => e && BUDGET_TYPES.has(e.type));
|
|
142
|
+
const lines = budget.map(renderEventLine);
|
|
143
|
+
return { lines: lines.slice(0, cap), total: lines.length };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Format the compact injected block (a header + capped lines + an overflow
|
|
148
|
+
* pointer), or '' when there is nothing to surface. PURE.
|
|
149
|
+
*/
|
|
150
|
+
function formatBlock(lines, total, cap, prs) {
|
|
151
|
+
if (lines.length === 0) return '';
|
|
152
|
+
const on = prs.length ? ` on PR(s) ${prs.join(', ')}` : '';
|
|
153
|
+
const more = total > cap ? `\n(+${total - cap} more — see \`forge shepherd events <pr> --since <seq>\`)` : '';
|
|
154
|
+
return `[forge PR shepherd] ${total} new event(s)${on}:\n${lines.join('\n')}${more}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Collect a compact digest of NEW budget events across every PR journal since the
|
|
159
|
+
* per-PR consumer cursor, advancing each cursor past EVERYTHING read (budget and
|
|
160
|
+
* non-budget alike, so skipped types never re-surface). Fail-open throughout.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} args
|
|
163
|
+
* @param {string} args.root - project root.
|
|
164
|
+
* @param {object} [args.journal] - journal module (test injection).
|
|
165
|
+
* @param {number} [args.cap] - max lines in the block.
|
|
166
|
+
* @param {object} [args.fsDeps] - injectable fs for discovery + cursor I/O.
|
|
167
|
+
* @returns {{ text: string, total: number, prs: string[] }}
|
|
168
|
+
*/
|
|
169
|
+
function collectDigest({ root, journal = journalMod, cap = DEFAULT_CAP, fsDeps = {} } = {}) {
|
|
170
|
+
const dirs = discoverPrDirs(root, fsDeps);
|
|
171
|
+
const allLines = [];
|
|
172
|
+
const prs = new Set();
|
|
173
|
+
for (const dir of dirs) {
|
|
174
|
+
const cursor = readConsumerCursor(dir, fsDeps);
|
|
175
|
+
let evs;
|
|
176
|
+
try {
|
|
177
|
+
evs = journal.readEventsSince(dir, cursor);
|
|
178
|
+
} catch {
|
|
179
|
+
evs = [];
|
|
180
|
+
}
|
|
181
|
+
if (!Array.isArray(evs) || evs.length === 0) continue;
|
|
182
|
+
const maxSeq = evs.reduce((m, e) => Math.max(m, Number(e.seq) || 0), cursor);
|
|
183
|
+
for (const e of evs) {
|
|
184
|
+
if (!e || !BUDGET_TYPES.has(e.type)) continue;
|
|
185
|
+
allLines.push(renderEventLine(e));
|
|
186
|
+
if (e.pr != null) prs.add(String(e.pr));
|
|
187
|
+
}
|
|
188
|
+
writeConsumerCursor(dir, maxSeq, fsDeps);
|
|
189
|
+
}
|
|
190
|
+
const capped = allLines.slice(0, cap);
|
|
191
|
+
return { text: formatBlock(capped, allLines.length, cap, [...prs]), total: allLines.length, prs: [...prs] };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
BUDGET_TYPES,
|
|
196
|
+
DEFAULT_CAP,
|
|
197
|
+
monitorRoot,
|
|
198
|
+
cursorPath,
|
|
199
|
+
discoverPrDirs,
|
|
200
|
+
readConsumerCursor,
|
|
201
|
+
writeConsumerCursor,
|
|
202
|
+
renderEventLine,
|
|
203
|
+
renderDigestLines,
|
|
204
|
+
formatBlock,
|
|
205
|
+
collectDigest,
|
|
206
|
+
};
|
|
Binary file
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR-monitor gather — turn ONE `gatherPrSnapshot` read (the SAME shared read the
|
|
5
|
+
* `--pull` verdict uses) into the normalized snapshot the differ compares. Because
|
|
6
|
+
* the verdict and the monitor events both derive from this one read, they can
|
|
7
|
+
* never disagree (the frame in docs/work/2026-07-13-pr-monitor/plan.md).
|
|
8
|
+
*
|
|
9
|
+
* The normalized snapshot is the diff subject:
|
|
10
|
+
* { repo, pr, headSha, prState, draft, verdict:{state,reason},
|
|
11
|
+
* checks:[{name,class}], threads:[{threadId,isResolved,isOutdated,commentCount,actionable,path}],
|
|
12
|
+
* reviews:[{author,state,commitOid,submittedAt}], comments:[{id,author}],
|
|
13
|
+
* behind, conflicts:(true|false|null), degraded:[{surface,error}] }
|
|
14
|
+
*
|
|
15
|
+
* @module pr-monitor/gather
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { gatherPrSnapshot } = require('../pr-pull');
|
|
19
|
+
const { isFailed, isGreen } = require('../pr-shepherd');
|
|
20
|
+
|
|
21
|
+
/** green | failed | pending — reuses the SAME predicates as the verdict core. */
|
|
22
|
+
function classifyCheck(check) {
|
|
23
|
+
if (isFailed(check)) return 'failed';
|
|
24
|
+
if (isGreen(check)) return 'green';
|
|
25
|
+
return 'pending';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeChecks(checks) {
|
|
29
|
+
return (Array.isArray(checks) ? checks : []).map((c) => ({
|
|
30
|
+
name: c.name || '',
|
|
31
|
+
class: classifyCheck(c),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeThreads(threads) {
|
|
36
|
+
return (Array.isArray(threads) ? threads : []).map((t) => ({
|
|
37
|
+
threadId: t.threadId || '',
|
|
38
|
+
isResolved: Boolean(t.isResolved),
|
|
39
|
+
isOutdated: Boolean(t.isOutdated),
|
|
40
|
+
commentCount: Array.isArray(t.comments) ? t.comments.length : 0,
|
|
41
|
+
actionable: !t.isResolved && !t.isOutdated,
|
|
42
|
+
path: t.path || null,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeReviews(reviews) {
|
|
47
|
+
return (Array.isArray(reviews) ? reviews : []).map((r) => ({
|
|
48
|
+
author: r.author || '',
|
|
49
|
+
state: r.state || '',
|
|
50
|
+
commitOid: r.commitOid || null,
|
|
51
|
+
submittedAt: r.submittedAt || null,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeComments(comments) {
|
|
56
|
+
return (Array.isArray(comments) ? comments : []).map((c) => ({
|
|
57
|
+
id: c.id || `${c.author || ''}:${c.createdAt || ''}`,
|
|
58
|
+
author: c.author || '',
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeDegraded(degraded) {
|
|
63
|
+
return (Array.isArray(degraded) ? degraded : []).map((d) => ({
|
|
64
|
+
surface: d.source || d.surface || 'unknown',
|
|
65
|
+
error: d.error || '',
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Conflict prediction → tri-state boolean: `true`/`false` when supported,
|
|
71
|
+
* `null` when unknown (unsupported git, unreadable ref) so the differ never
|
|
72
|
+
* emits a false conflict.appeared/cleared on missing data.
|
|
73
|
+
*/
|
|
74
|
+
function conflictBool(conflicts) {
|
|
75
|
+
if (conflicts?.supported === true) return Boolean(conflicts.conflicted);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Normalize a raw `gatherPrSnapshot` result into the monitor diff subject.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} snap - gatherPrSnapshot result.
|
|
83
|
+
* @param {{ repo: string, pr: string|number }} ctx
|
|
84
|
+
* @returns {object}
|
|
85
|
+
*/
|
|
86
|
+
function normalizeSnapshot(snap, ctx) {
|
|
87
|
+
const state = snap.state || {};
|
|
88
|
+
return {
|
|
89
|
+
repo: ctx.repo,
|
|
90
|
+
pr: String(ctx.pr),
|
|
91
|
+
headSha: state.headSha || '',
|
|
92
|
+
prState: String(state.state || 'OPEN').toUpperCase(),
|
|
93
|
+
draft: Boolean(snap.draft),
|
|
94
|
+
verdict: { state: snap.verdict || 'UNKNOWN', reason: null },
|
|
95
|
+
checks: normalizeChecks(state.checks),
|
|
96
|
+
threads: normalizeThreads(snap.threads),
|
|
97
|
+
reviews: normalizeReviews(snap.reviews),
|
|
98
|
+
comments: normalizeComments(snap.issueComments),
|
|
99
|
+
behind: snap.behind || 0,
|
|
100
|
+
conflicts: conflictBool(snap.conflicts),
|
|
101
|
+
degraded: normalizeDegraded(snap.degraded),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Gather + normalize the monitor snapshot for a PR. `ctx.gatherSnapshot` is
|
|
107
|
+
* injectable for tests; production uses the shared `gatherPrSnapshot`.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} ctx - the same ctx shape gatherPrSnapshot takes (pr, owner,
|
|
110
|
+
* repo, base, baseRef, cwd, self, adapter, now, settleWindowMs).
|
|
111
|
+
* @returns {Promise<object>} normalized snapshot.
|
|
112
|
+
*/
|
|
113
|
+
async function gatherMonitorSnapshot(ctx) {
|
|
114
|
+
const gather = ctx.gatherSnapshot || gatherPrSnapshot;
|
|
115
|
+
const snap = await gather(ctx);
|
|
116
|
+
return normalizeSnapshot(snap, ctx);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
gatherMonitorSnapshot,
|
|
121
|
+
normalizeSnapshot,
|
|
122
|
+
classifyCheck,
|
|
123
|
+
conflictBool,
|
|
124
|
+
};
|
|
@@ -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
|
+
};
|