session-orchestrator 5.1.0 → 5.3.0
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/.agents/skills/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +6 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +7 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +8 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
- package/.agents/skills/close/SKILL.md +21 -0
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +7 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +7 -2
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +7 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +7 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +7 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +8 -3
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +22 -0
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +7 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +20 -0
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +6 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +6 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +8 -2
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +21 -0
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +6 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +22 -0
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +6 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +6 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +7 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +21 -0
- package/.agents/skills/test/agents/openai.yaml +5 -0
- package/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +7 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/autopilot/SKILL.md +5 -4
- package/.codex-plugin/skills/bootstrap/SKILL.md +8 -4
- package/.codex-plugin/skills/brainstorm/SKILL.md +11 -4
- package/.codex-plugin/skills/close/SKILL.md +3 -3
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -1
- package/.codex-plugin/skills/debug/SKILL.md +11 -4
- package/.codex-plugin/skills/discovery/SKILL.md +8 -4
- package/.codex-plugin/skills/dispatcher/SKILL.md +4 -4
- package/.codex-plugin/skills/eli5/SKILL.md +9 -4
- package/.codex-plugin/skills/eval/SKILL.md +9 -4
- package/.codex-plugin/skills/evolve/SKILL.md +9 -4
- package/.codex-plugin/skills/go/SKILL.md +3 -3
- package/.codex-plugin/skills/grill/SKILL.md +11 -4
- package/.codex-plugin/skills/harness-audit/SKILL.md +4 -3
- package/.codex-plugin/skills/memory-cleanup/SKILL.md +9 -4
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -1
- package/.codex-plugin/skills/persona-panel/SKILL.md +5 -5
- package/.codex-plugin/skills/plan/SKILL.md +8 -4
- package/.codex-plugin/skills/portfolio/SKILL.md +3 -3
- package/.codex-plugin/skills/reconcile/SKILL.md +9 -4
- package/.codex-plugin/skills/release/SKILL.md +3 -3
- package/.codex-plugin/skills/repo-audit/SKILL.md +6 -4
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.codex-plugin/skills/spinout/SKILL.md +4 -4
- package/.codex-plugin/skills/sunset-review/SKILL.md +5 -4
- package/.codex-plugin/skills/test/SKILL.md +3 -3
- package/.codex-plugin/skills/ux-grill/SKILL.md +11 -4
- package/.cursor/commands/autopilot.md +4 -4
- package/.cursor/commands/bootstrap.md +5 -4
- package/.cursor/commands/brainstorm.md +5 -4
- package/.cursor/commands/close.md +4 -3
- package/.cursor/commands/debug.md +4 -4
- package/.cursor/commands/discovery.md +4 -4
- package/.cursor/commands/dispatcher.md +4 -4
- package/.cursor/commands/eli5.md +4 -4
- package/.cursor/commands/eval.md +4 -4
- package/.cursor/commands/evolve.md +4 -4
- package/.cursor/commands/go.md +4 -3
- package/.cursor/commands/grill.md +4 -4
- package/.cursor/commands/harness-audit.md +3 -3
- package/.cursor/commands/memory-cleanup.md +4 -4
- package/.cursor/commands/persona-panel.md +4 -4
- package/.cursor/commands/plan.md +5 -4
- package/.cursor/commands/portfolio.md +3 -3
- package/.cursor/commands/reconcile.md +4 -4
- package/.cursor/commands/release.md +4 -3
- package/.cursor/commands/repo-audit.md +4 -4
- package/.cursor/commands/session.md +1 -1
- package/.cursor/commands/spinout.md +4 -4
- package/.cursor/commands/sunset-review.md +4 -4
- package/.cursor/commands/test.md +3 -3
- package/.cursor/commands/ux-grill.md +4 -4
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/010-session-workflow.mdc +2 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/skills/bootstrap/SKILL.md +1 -0
- package/.cursor/skills/close/SKILL.md +13 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/debug/SKILL.md +0 -1
- package/.cursor/skills/discovery/SKILL.md +0 -1
- package/.cursor/skills/dispatcher/SKILL.md +0 -1
- package/.cursor/skills/eli5/SKILL.md +0 -1
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/evolve/SKILL.md +0 -1
- package/.cursor/skills/go/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +0 -1
- package/.cursor/skills/harness-audit/SKILL.md +12 -0
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor/skills/portfolio/SKILL.md +12 -0
- package/.cursor/skills/release/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +0 -1
- package/.cursor/skills/sunset-review/SKILL.md +0 -1
- package/.cursor/skills/test/SKILL.md +12 -0
- package/.cursor/skills/ux-grill/SKILL.md +0 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +13 -4
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +197 -0
- package/README.md +11 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +17 -3
- package/docs/baseline.md +12 -6
- package/docs/ci-setup.md +53 -0
- package/docs/codex-setup.md +15 -3
- package/docs/components.md +13 -6
- package/docs/events-schema.md +59 -9
- package/docs/install.md +16 -0
- package/docs/persona-panel.md +1 -1
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +135 -14
- package/docs/scope-collision-guard.md +2 -0
- package/docs/session-config-reference.md +106 -11
- package/docs/session-config-template.md +31 -2
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +125 -8
- package/hooks/_lib/subagent-paths.mjs +15 -0
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/_lib/vcs-create-matcher.mjs +217 -62
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +72 -25
- package/hooks/on-session-start.mjs +48 -11
- package/hooks/on-stop.mjs +211 -23
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +6 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +200 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +18 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +119 -28
- package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
- package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-task-scope-disjoint.mjs +385 -5
- package/hooks/skill-invocation-telemetry.mjs +2 -1
- package/hooks/subagent-telemetry.mjs +15 -19
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +2 -1
- package/pi/prompts/autopilot.md +3 -3
- package/pi/prompts/bootstrap.md +3 -3
- package/pi/prompts/brainstorm.md +3 -3
- package/pi/prompts/close.md +2 -2
- package/pi/prompts/debug.md +3 -3
- package/pi/prompts/discovery.md +3 -3
- package/pi/prompts/dispatcher.md +3 -3
- package/pi/prompts/eli5.md +3 -3
- package/pi/prompts/eval.md +3 -3
- package/pi/prompts/evolve.md +3 -3
- package/pi/prompts/go.md +2 -2
- package/pi/prompts/grill.md +3 -3
- package/pi/prompts/harness-audit.md +2 -3
- package/pi/prompts/memory-cleanup.md +3 -3
- package/pi/prompts/persona-panel.md +3 -3
- package/pi/prompts/plan.md +3 -3
- package/pi/prompts/portfolio.md +2 -2
- package/pi/prompts/reconcile.md +3 -3
- package/pi/prompts/release.md +3 -3
- package/pi/prompts/repo-audit.md +3 -4
- package/pi/prompts/session.md +2 -2
- package/pi/prompts/spinout.md +3 -3
- package/pi/prompts/sunset-review.md +3 -3
- package/pi/prompts/templates-ack.md +1 -1
- package/pi/prompts/test.md +3 -3
- package/pi/prompts/ux-grill.md +3 -3
- package/rules/README.md +1 -1
- package/rules/opt-in-domain/prompt-caching.md +1 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +3 -3
- package/rules/opt-in-stack/frontend.md +1 -1
- package/rules/opt-in-stack/security-web.md +3 -3
- package/rules/opt-in-stack/swift.md +1 -1
- package/scripts/archive-closed-prds.mjs +2 -2
- package/scripts/auq-audit.mjs +2 -3
- package/scripts/autopilot.mjs +23 -2
- package/scripts/backfill-abandoned-sessions.mjs +171 -15
- package/scripts/backfill-evidence-digest.mjs +2 -1
- package/scripts/backfill-learnings-from-vault.mjs +2 -2
- package/scripts/check-package-manager.mjs +2 -2
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/ci/assert-vitest-green.mjs +2 -1
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +77 -32
- package/scripts/eval-session.mjs +65 -3
- package/scripts/export-hw-learnings.mjs +2 -1
- package/scripts/express-path.mjs +1 -1
- package/scripts/gc-stale-worktrees.mjs +2 -1
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-codex-skills.mjs +48 -4
- package/scripts/generate-cursor-adapter.mjs +220 -11
- package/scripts/generate-hook-import-set.mjs +12 -27
- package/scripts/generate-pi-prompts.mjs +183 -13
- package/scripts/github-protection-audit.mjs +2 -3
- package/scripts/lib/agent-frontmatter.mjs +23 -1
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/autopilot/flags.mjs +12 -1
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +14 -1
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/claude-md-budget-lint.mjs +2 -5
- package/scripts/lib/command-blocker.mjs +408 -33
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/drift-check.mjs +19 -0
- package/scripts/lib/config/gate.mjs +74 -0
- package/scripts/lib/config/reaper.mjs +162 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/convergence-monitor.mjs +76 -13
- package/scripts/lib/cursor-hook-bridge.mjs +2 -2
- package/scripts/lib/description-surface.mjs +2 -5
- package/scripts/lib/dispatcher/cli.mjs +2 -1
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/ecosystem-wizard.mjs +2 -1
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +524 -5
- package/scripts/lib/fetch-baseline.mjs +3 -8
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +27 -2
- package/scripts/lib/gates/gate-full.mjs +28 -3
- package/scripts/lib/gates/gate-helpers.mjs +243 -21
- package/scripts/lib/gates/gate-incremental.mjs +28 -3
- package/scripts/lib/gates/gate-per-file.mjs +27 -2
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +2 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +2 -1
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +332 -50
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/is-main-module.mjs +82 -0
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- package/scripts/lib/locks/index.mjs +32 -25
- package/scripts/lib/maintenance-due-banner.mjs +122 -91
- package/scripts/lib/orphan-reaper.mjs +1588 -0
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/peer-discovery.mjs +2 -5
- package/scripts/lib/playwright-driver/runner.mjs +2 -1
- package/scripts/lib/process-group.mjs +899 -0
- package/scripts/lib/quality-gate.mjs +107 -28
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +884 -0
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/rule-loader.mjs +41 -12
- package/scripts/lib/rules-sync.mjs +2 -5
- package/scripts/lib/scope-echo.mjs +429 -7
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +91 -12
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-schema/constants.mjs +30 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/sessions-canonical.mjs +23 -0
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/state-md.mjs +84 -3
- package/scripts/lib/sunset/walker.mjs +31 -4
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tests-src-ratio.mjs +2 -6
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +74 -14
- package/scripts/lib/user-invocable-skills.mjs +205 -0
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-banner-parity.mjs +2 -2
- package/scripts/lib/validate/check-cursor-adapter.mjs +3 -2
- package/scripts/lib/validate/check-dead-bridge.mjs +2 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +2 -2
- package/scripts/lib/validate/check-entry-guard.mjs +329 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +2 -2
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +2 -2
- package/scripts/lib/validate/check-learning-provenance.mjs +2 -2
- package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +35 -6
- package/scripts/lib/validate/check-skill-script-paths.mjs +241 -29
- package/scripts/lib/validate/check-test-git-config-target.mjs +26 -36
- package/scripts/lib/validate/check-unicode-safety.mjs +2 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +9 -104
- package/scripts/lib/validate/check-unwired-features.mjs +220 -33
- package/scripts/lib/validate/check-validator-registration.mjs +36 -12
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +2 -2
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +39 -12
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
- package/scripts/lib/vault-yaml.mjs +118 -0
- package/scripts/lib/wave-transcript-tail.mjs +2 -2
- package/scripts/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/lock-reaper.mjs +2 -1
- package/scripts/materialize-wave-scope.mjs +87 -4
- package/scripts/migrate-sessions-jsonl.mjs +2 -1
- package/scripts/migrate-vault-paths.mjs +2 -3
- package/scripts/release-session-lock.mjs +305 -0
- package/scripts/release.mjs +109 -39
- package/scripts/relocate-vault-corpus.mjs +2 -3
- package/scripts/repair-invalid-sessions.mjs +2 -2
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-quality-gate.mjs +156 -17
- package/scripts/session-shape.mjs +2 -2
- package/scripts/site-numbers.mjs +35 -11
- package/scripts/sweep-expired-rules.mjs +227 -0
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-consolidate.mjs +2 -2
- package/scripts/vault-mirror.mjs +11 -4
- package/scripts/wave-scope-binding.mjs +2 -3
- package/skills/_shared/bootstrap-gate.md +1 -1
- package/skills/_shared/monitor-patterns.md +1 -1
- package/skills/_shared/platform-tools.md +23 -11
- package/skills/_shared/research-evidence.md +53 -0
- package/skills/_shared/state-ownership.md +3 -0
- package/skills/autopilot/SKILL.md +80 -11
- package/skills/bootstrap/SKILL.md +51 -1
- package/skills/brainstorm/SKILL.md +16 -0
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +49 -11
- package/{commands/close.md → skills/close/SKILL.md} +9 -3
- package/skills/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/debug/SKILL.md +10 -0
- package/skills/discovery/SKILL.md +24 -1
- package/skills/discovery/probes-session.md +2 -2
- package/skills/dispatcher/SKILL.md +38 -7
- package/skills/eli5/SKILL.md +11 -0
- package/skills/eval/SKILL.md +52 -23
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +9 -2
- package/skills/evolve/references/evolve-dialectic-mode.md +46 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- package/{commands/go.md → skills/go/SKILL.md} +9 -1
- package/skills/grill/SKILL.md +19 -0
- package/{commands/harness-audit.md → skills/harness-audit/SKILL.md} +7 -2
- package/skills/hook-development/SKILL.md +46 -41
- package/skills/memory-cleanup/SKILL.md +7 -0
- package/skills/npm-publish/SKILL.md +2 -2
- package/skills/persona-panel/SKILL.md +56 -1
- package/skills/persona-panel/persona-format.md +1 -1
- package/skills/plan/SKILL.md +28 -1
- package/{commands/portfolio.md → skills/portfolio/SKILL.md} +8 -2
- package/skills/reconcile/SKILL.md +21 -0
- package/{commands/release.md → skills/release/SKILL.md} +16 -2
- package/skills/repo-audit/SKILL.md +7 -0
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +55 -9
- package/skills/session-end/plan-verification.md +2 -2
- package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/session-metrics-write.md +10 -0
- package/skills/session-plan/SKILL.md +18 -6
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- package/skills/session-start/SKILL.md +5 -4
- package/skills/session-start/phase-8-5-express-path.md +6 -6
- package/skills/session-start/references/phase-1-5-session-continuity.md +1 -1
- package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +6 -4
- package/skills/spinout/SKILL.md +12 -1
- package/skills/sunset-review/SKILL.md +13 -0
- package/{commands/test.md → skills/test/SKILL.md} +10 -4
- package/skills/ux-grill/SKILL.md +20 -2
- package/skills/wave-executor/SKILL.md +14 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +18 -4
- package/skills/wave-executor/references/wave-loop-dispatch.md +5 -2
- package/skills/wave-executor/references/wave-loop-review.md +17 -1
- package/commands/autopilot.md +0 -80
- package/commands/bootstrap.md +0 -56
- package/commands/brainstorm.md +0 -48
- package/commands/debug.md +0 -36
- package/commands/discovery.md +0 -32
- package/commands/dispatcher.md +0 -59
- package/commands/eli5.md +0 -33
- package/commands/eval.md +0 -28
- package/commands/evolve.md +0 -10
- package/commands/grill.md +0 -45
- package/commands/memory-cleanup.md +0 -26
- package/commands/persona-panel.md +0 -121
- package/commands/plan.md +0 -15
- package/commands/reconcile.md +0 -23
- package/commands/repo-audit.md +0 -24
- package/commands/spinout.md +0 -15
- package/commands/sunset-review.md +0 -27
- package/commands/ux-grill.md +0 -51
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reconcile/rule-expiry-sweep.mjs — the missing REMOVAL half of the
|
|
3
|
+
* generated-rule TTL (issue #1377).
|
|
4
|
+
*
|
|
5
|
+
* `scripts/lib/rule-loader.mjs` filters an expired machine-generated rule out
|
|
6
|
+
* of injection at READ time. Nothing ever removed one from disk, so an expired
|
|
7
|
+
* consolidated file stays tracked, keeps costing bytes against the
|
|
8
|
+
* `generated-byte-ceiling`, and keeps reading as live corpus to every human and
|
|
9
|
+
* every grep — while the loader silently stops shipping it. This module is the
|
|
10
|
+
* mechanical sweep that closes the gap.
|
|
11
|
+
*
|
|
12
|
+
* ## Why removing PROSE is not the same as removing a PAIR
|
|
13
|
+
*
|
|
14
|
+
* `/reconcile` dedupes exclusively on the `## Provenance` bullets —
|
|
15
|
+
* `defaultReadMaterializedProvenance` in `./engine.mjs` scans
|
|
16
|
+
* `.claude/rules/*.md` for `learning-key:` / `learning-id:` markers and treats
|
|
17
|
+
* any match as "already materialized" (#484, #1242). Two consequences fix the
|
|
18
|
+
* design of this file:
|
|
19
|
+
*
|
|
20
|
+
* - Removing an entry's PROSE is invisible to dedupe. Safe.
|
|
21
|
+
* - Removing its PAIR (or the whole FILE) makes `/reconcile` re-propose the
|
|
22
|
+
* learning on the next run. Expensive, and silently so.
|
|
23
|
+
*
|
|
24
|
+
* Therefore: **a pair is NEVER deleted while its file survives.** Sweeping an
|
|
25
|
+
* expired entry deletes its prose block and converts the pair to the
|
|
26
|
+
* `markers only` shape already present in the corpus — a same-line HTML
|
|
27
|
+
* comment appended to the `- learning-id:` bullet. The backticked id stays
|
|
28
|
+
* regex-visible to the dedupe scan, which is the whole point.
|
|
29
|
+
*
|
|
30
|
+
* ## Fail-open, twice
|
|
31
|
+
*
|
|
32
|
+
* Entries carry NO date of their own. A per-entry date is recoverable only via
|
|
33
|
+
* the pair's `learning-id` → `.orchestrator/metrics/learnings.jsonl`
|
|
34
|
+
* `expires_at`. Measured 2026-09-17 @ 9e8146b4: 87 of 92 unique ids in the
|
|
35
|
+
* corpus resolve, 5 do not. So:
|
|
36
|
+
*
|
|
37
|
+
* 1. **Unresolvable id → the entry is KEPT** and counted in
|
|
38
|
+
* `unresolvedPairIds`. A sweep that guessed would delete prose on no
|
|
39
|
+
* evidence, and a file with any unresolved pair is never deleted.
|
|
40
|
+
* 1b. **Unrecognised counter sentence → `action: 'keep'` + a `skipped`
|
|
41
|
+
* record (`no-counter-sentence`), and NO write of any kind** — not a
|
|
42
|
+
* rewrite, not a raise, not a delete. Every write here moves the
|
|
43
|
+
* frontmatter `expires-at` and the body sentence that restates it; when
|
|
44
|
+
* only the header moves, the file ships a header contradicting a body
|
|
45
|
+
* sentence that forbids exactly that correction (GH#70, reported from a
|
|
46
|
+
* German-language consumer corpus 2026-09-20). Recognising a second
|
|
47
|
+
* spelling ({@link COUNTER_FORMS}) is the narrow half of that fix; the
|
|
48
|
+
* refusal is the half that holds for the third language too.
|
|
49
|
+
* 2. **Ambiguous file → `action: 'keep'` + a `skipped` record.** The
|
|
50
|
+
* entry↔pair mapping is POSITIONAL: the k-th `### ` heading belongs to the
|
|
51
|
+
* k-th non-`markers only` pair. That 1:1 mapping holds in only 3 of the 7
|
|
52
|
+
* live files (measured 2026-09-17: measurement-discipline 12/12,
|
|
53
|
+
* process-contracts 6/6, toolchain-and-build 10/10; guard-design 8≠11,
|
|
54
|
+
* identity-and-locks 9≠10, test-hygiene 7≠8,
|
|
55
|
+
* review-and-adapter-contracts 9≠12 — several learnings were merged into
|
|
56
|
+
* one prose entry there). Where it does not hold, this module reports
|
|
57
|
+
* `no-1to1-mapping` and touches nothing. It never guesses which paragraph
|
|
58
|
+
* belongs to which learning.
|
|
59
|
+
*
|
|
60
|
+
* ## Header recompute is ONE-DIRECTIONAL: raised, never lowered
|
|
61
|
+
*
|
|
62
|
+
* A consolidated file's frontmatter `expires-at` plus the body sentence
|
|
63
|
+
* ``**`expires-at` <D> = the EARLIEST of the <N> absorbed dates**`` must not
|
|
64
|
+
* outlive its shortest-lived content. The two directions of a
|
|
65
|
+
* header-vs-content discrepancy are NOT symmetric, so they are handled
|
|
66
|
+
* differently:
|
|
67
|
+
*
|
|
68
|
+
* - **Header EARLIER than the earliest absorbed date → RAISED, and that raise
|
|
69
|
+
* is itself a rewrite trigger** (`action: 'rewrite'`,
|
|
70
|
+
* `reason: 'header-raise'`). Left alone, the cliff has no way back:
|
|
71
|
+
* `scripts/lib/rule-loader.mjs` drops a file whose header has passed out of
|
|
72
|
+
* injection even though not one absorbed entry expired, while the surviving
|
|
73
|
+
* `learning-key`/`learning-id` markers keep `/reconcile` treating those
|
|
74
|
+
* learnings as materialized — so the substance goes dark silently and is
|
|
75
|
+
* never re-proposed. Measured 2026-09-18 (`--json`, live corpus): 3 of 7
|
|
76
|
+
* files are in that state (`identity-and-locks` 2026-10-01 vs 2026-10-02,
|
|
77
|
+
* `process-contracts` 2026-10-04 vs 2026-10-27, `toolchain-and-build`
|
|
78
|
+
* 2026-10-01 vs 2026-10-16), i.e. 31.5 kB of corpus that would fall dark
|
|
79
|
+
* with nothing expired.
|
|
80
|
+
* - **Header LATER than the earliest absorbed date → NEVER lowered**, only
|
|
81
|
+
* reported via the per-plan `advisory` field. Lowering on every run would
|
|
82
|
+
* shorten a healthy file's TTL — killing an entry early — on the very first
|
|
83
|
+
* live invocation, which is the direction that destroys substance rather
|
|
84
|
+
* than preserving it.
|
|
85
|
+
*
|
|
86
|
+
* The raise is MINIMAL: exactly the frontmatter `expires-at:` line and the
|
|
87
|
+
* `COUNTER_RE` body sentence change; every other byte of the file is left
|
|
88
|
+
* untouched (see {@link rewriteHeaderOnly}). It needs no entry↔pair mapping —
|
|
89
|
+
* it compares the frontmatter date against dates read out of `learnings.jsonl`
|
|
90
|
+
* by id — so a `no-1to1-mapping` file gets it too, exactly like the advisory
|
|
91
|
+
* (see {@link headerAdvisory}); that file's `skipped` record refers to the
|
|
92
|
+
* PROSE sweep, which still touches nothing there.
|
|
93
|
+
*
|
|
94
|
+
* `reason` rather than a novel `action` value, deliberately: the CLI
|
|
95
|
+
* (`scripts/sweep-expired-rules.mjs`) aggregates its human summary line and its
|
|
96
|
+
* `applied` counters over the three actions `rewrite`/`delete`/`keep` only, so a
|
|
97
|
+
* fourth action name would make a header raise invisible in exactly the preview
|
|
98
|
+
* an operator reads before `--apply`. A raise IS a write; it is counted as one.
|
|
99
|
+
*
|
|
100
|
+
* Measured 2026-09-18 (`node scripts/sweep-expired-rules.mjs --json`, 7 files
|
|
101
|
+
* scanned, 0 expired, 0 skipped): **3 of the 7 carry a discrepancy**, all three
|
|
102
|
+
* in the header-EARLIER direction and therefore all three raises —
|
|
103
|
+
* `identity-and-locks` 2026-10-01 → 2026-10-02, `process-contracts` 2026-10-04
|
|
104
|
+
* → 2026-10-27, `toolchain-and-build` 2026-10-01 → 2026-10-16. The other four
|
|
105
|
+
* agree with their content. (An earlier reading on 2026-09-17 listed six
|
|
106
|
+
* discrepancies including three header-LATER ones; the corpus has been
|
|
107
|
+
* hand-consolidated since, so re-measure rather than trusting either list.)
|
|
108
|
+
*
|
|
109
|
+
* Before the advisory moved above the skip branch it reached only the 3
|
|
110
|
+
* 1:1-mappable files, which structurally excluded the four merged-prose files —
|
|
111
|
+
* `test-hygiene` among them, i.e. the instrument could not report the very
|
|
112
|
+
* defect its docblock named.
|
|
113
|
+
*
|
|
114
|
+
* Two populations, stated because they are not the same:
|
|
115
|
+
* - `newExpiresAt` (D) = the earliest `expires_at` over remaining resolvable
|
|
116
|
+
* pairs that are NOT expired as of `now`. Excluding the expired ones is
|
|
117
|
+
* what stops a rewritten file from being instantly expired again by a pair
|
|
118
|
+
* whose prose was just swept.
|
|
119
|
+
* - `newAbsorbedCount` (N) = the number of pairs REMAINING IN THE FILE.
|
|
120
|
+
* Measured 2026-09-17: all 7 live sentences carry the TOTAL pair count
|
|
121
|
+
* (15/9/13/17/16/10/12), markers-only pairs included — an absorbed date
|
|
122
|
+
* stays absorbed after its prose is gone. Since this module never removes a
|
|
123
|
+
* pair from a surviving file, N is stable across a rewrite by construction;
|
|
124
|
+
* it is re-emitted from the parse rather than copied so it cannot drift if
|
|
125
|
+
* the pair population ever does change.
|
|
126
|
+
*
|
|
127
|
+
* ## Deleting a whole file
|
|
128
|
+
*
|
|
129
|
+
* A file is deleted only when it has ZERO kept and ZERO unresolved pairs — i.e.
|
|
130
|
+
* every substantive entry expired. Because the delete DOES remove provenance
|
|
131
|
+
* pairs, every pair's `learning_key` is stamped via `markCandidateProcessed`
|
|
132
|
+
* BEFORE the unlink (`docs/rule-authoring.md` § Consolidated rules). Stamping
|
|
133
|
+
* after the delete would leave a window in which `/reconcile` sees neither the
|
|
134
|
+
* file nor a terminal candidate record and re-proposes the whole file's worth
|
|
135
|
+
* of learnings.
|
|
136
|
+
*
|
|
137
|
+
* Dry-run is the default and writes NOTHING. {@link applyRuleExpirySweep} is
|
|
138
|
+
* the only write path.
|
|
139
|
+
*
|
|
140
|
+
* @module scripts/lib/reconcile/rule-expiry-sweep
|
|
141
|
+
*/
|
|
142
|
+
|
|
143
|
+
import { lstatSync, readFileSync, unlinkSync } from 'node:fs';
|
|
144
|
+
import { join, resolve, sep } from 'node:path';
|
|
145
|
+
|
|
146
|
+
import { atomicWriteWithBackup } from '../io.mjs';
|
|
147
|
+
import { readLearnings } from '../learnings/io.mjs';
|
|
148
|
+
import { listMachineGeneratedRules } from '../instruction-budget-guard.mjs';
|
|
149
|
+
import { markCandidateProcessed } from './idempotency.mjs';
|
|
150
|
+
|
|
151
|
+
/** Repo-relative learnings store — the only source of a per-entry date. */
|
|
152
|
+
export const DEFAULT_LEARNINGS_PATH = '.orchestrator/metrics/learnings.jsonl';
|
|
153
|
+
|
|
154
|
+
/** Event emitted on `--apply` only, AFTER the writes succeeded. */
|
|
155
|
+
export const RULE_EXPIRY_SWEEP_EVENT = 'orchestrator.rules.expiry_sweep_applied';
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `outcome` written on the candidate records stamped before a file delete.
|
|
159
|
+
*
|
|
160
|
+
* A new member of an ADDITIVE field: the ReconcileCandidate typedef documents
|
|
161
|
+
* that nothing branches on `outcome` (terminality is `processed_at` alone), so
|
|
162
|
+
* an unknown value round-trips unchanged. It is distinct from `'written'` on
|
|
163
|
+
* purpose — the file WAS written once and is now gone, and a census over the
|
|
164
|
+
* store must be able to tell "materialized" from "materialized then swept".
|
|
165
|
+
*/
|
|
166
|
+
export const SWEPT_OUTCOME = 'expired-swept';
|
|
167
|
+
|
|
168
|
+
const MS_PER_DAY = 86_400_000;
|
|
169
|
+
|
|
170
|
+
const PAIR_KEY_RE = /^- learning-key:\s*`([^`]+)`/;
|
|
171
|
+
const PAIR_ID_RE = /^- learning-id:\s*`([^`]+)`(.*)$/;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The recognised spellings of the body counter sentence, each with the date as
|
|
175
|
+
* capture 1 and the absorbed-pair count as capture 2, in that order.
|
|
176
|
+
*
|
|
177
|
+
* The `d` flag is load-bearing: {@link spliceCounterLine} rewrites the line by
|
|
178
|
+
* replacing exactly the two captured SPANS, so every other byte — the wording,
|
|
179
|
+
* the language, any trailing clause — survives a rewrite untouched. That is why
|
|
180
|
+
* a second language costs one entry here and no re-templating anywhere: a
|
|
181
|
+
* template would silently translate a German sentence into English on the first
|
|
182
|
+
* header raise.
|
|
183
|
+
*
|
|
184
|
+
* English is this repo's own form (all 7 live consolidated files, measured
|
|
185
|
+
* 2026-09-20 @ 9b118cf6: `grep -n 'EARLIEST of the' .claude/rules/*.md` → 7
|
|
186
|
+
* hits, `grep -rn 'FRUEHESTE\|FRÜHESTE' .claude/rules/` → 0). German is the
|
|
187
|
+
* consumer-side form reported from EventDrop.at (GH#70, 2026-09-20), where a
|
|
188
|
+
* raise lifted the frontmatter and left the body sentence claiming the older
|
|
189
|
+
* date — the sentence that literally forbids correcting it upward.
|
|
190
|
+
*
|
|
191
|
+
* @type {readonly RegExp[]}
|
|
192
|
+
*/
|
|
193
|
+
const COUNTER_FORMS = Object.freeze([
|
|
194
|
+
/^\*\*`expires-at` (\S+) = the EARLIEST of the (\d+) absorbed dates\*\*/d,
|
|
195
|
+
/^\*\*`expires-at` (\S+) = das FR(?:UE|Ü)HESTE der (\d+) aufgenommenen Daten\*\*/d,
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
const HEADING_PREFIX = '### ';
|
|
199
|
+
const PROVENANCE_HEADING = '## Provenance';
|
|
200
|
+
const UNTRUSTED_END = '<!-- untrusted-content:end -->';
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Match one line against every recognised counter-sentence form.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} line
|
|
206
|
+
* @returns {{date: string, count: number, match: RegExpExecArray}|null}
|
|
207
|
+
*/
|
|
208
|
+
function matchCounterLine(line) {
|
|
209
|
+
for (const form of COUNTER_FORMS) {
|
|
210
|
+
const m = form.exec(line);
|
|
211
|
+
if (m) return { date: m[1], count: Number(m[2]), match: m };
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Rewrite a counter-sentence line to a new date and count by splicing the two
|
|
218
|
+
* captured spans, leaving every other byte — wording, language, trailing text —
|
|
219
|
+
* exactly as authored.
|
|
220
|
+
*
|
|
221
|
+
* Splices BACK-TO-FRONT (count before date) so the earlier capture's indices
|
|
222
|
+
* stay valid while the later text is replaced.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} line the line as it stands in the file
|
|
225
|
+
* @param {string} date `YYYY-MM-DD` to write
|
|
226
|
+
* @param {number} count the absorbed-pair count to write
|
|
227
|
+
* @returns {string} the rewritten line, or `line` unchanged when no form matches
|
|
228
|
+
*/
|
|
229
|
+
function spliceCounterLine(line, date, count) {
|
|
230
|
+
const hit = matchCounterLine(line);
|
|
231
|
+
if (!hit) return line;
|
|
232
|
+
const [dateStart, dateEnd] = hit.match.indices[1];
|
|
233
|
+
const [countStart, countEnd] = hit.match.indices[2];
|
|
234
|
+
return (
|
|
235
|
+
line.slice(0, dateStart) +
|
|
236
|
+
date +
|
|
237
|
+
line.slice(dateEnd, countStart) +
|
|
238
|
+
String(count) +
|
|
239
|
+
line.slice(countEnd)
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Parse a consolidated machine-generated rule file into its editable parts.
|
|
245
|
+
*
|
|
246
|
+
* PURE — takes and returns data, touches no disk. Line-based on purpose: the
|
|
247
|
+
* rewrite is a line splice, and a round-trip through a Markdown AST would
|
|
248
|
+
* reformat parts of the file this sweep has no business touching.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} content raw file contents
|
|
251
|
+
* @returns {{
|
|
252
|
+
* lines: string[],
|
|
253
|
+
* expiresAtLine: number,
|
|
254
|
+
* expiresAt: string|null,
|
|
255
|
+
* counterLine: number,
|
|
256
|
+
* counterDate: string|null,
|
|
257
|
+
* counterCount: number|null,
|
|
258
|
+
* entries: Array<{heading: string, start: number, end: number}>,
|
|
259
|
+
* provenanceLine: number,
|
|
260
|
+
* pairs: Array<{key: string, id: string, keyLine: number, idLine: number, markersOnly: boolean}>
|
|
261
|
+
* }} `expiresAtLine` / `counterLine` / `provenanceLine` are `-1` when absent.
|
|
262
|
+
*/
|
|
263
|
+
export function parseConsolidatedRule(content) {
|
|
264
|
+
const lines = String(content ?? '').split('\n');
|
|
265
|
+
|
|
266
|
+
let expiresAtLine = -1;
|
|
267
|
+
let expiresAt = null;
|
|
268
|
+
let counterLine = -1;
|
|
269
|
+
let counterDate = null;
|
|
270
|
+
let counterCount = null;
|
|
271
|
+
let provenanceLine = -1;
|
|
272
|
+
let untrustedEndLine = -1;
|
|
273
|
+
|
|
274
|
+
// Frontmatter is the FIRST `---`-delimited block only; an `expires-at:` in
|
|
275
|
+
// the body (a learning quoting one) must never be mistaken for the header.
|
|
276
|
+
let frontmatterEnd = -1;
|
|
277
|
+
if (lines[0] === '---') {
|
|
278
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
279
|
+
if (lines[i] === '---') {
|
|
280
|
+
frontmatterEnd = i;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
for (let i = 1; i < frontmatterEnd; i += 1) {
|
|
286
|
+
const m = /^expires-at:\s*(\S+)\s*$/.exec(lines[i]);
|
|
287
|
+
if (m) {
|
|
288
|
+
expiresAtLine = i;
|
|
289
|
+
expiresAt = m[1];
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const bodyStart = frontmatterEnd >= 0 ? frontmatterEnd + 1 : 0;
|
|
295
|
+
for (let i = bodyStart; i < lines.length; i += 1) {
|
|
296
|
+
if (counterLine === -1) {
|
|
297
|
+
const hit = matchCounterLine(lines[i]);
|
|
298
|
+
if (hit) {
|
|
299
|
+
counterLine = i;
|
|
300
|
+
counterDate = hit.date;
|
|
301
|
+
counterCount = hit.count;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (untrustedEndLine === -1 && lines[i].startsWith(UNTRUSTED_END)) untrustedEndLine = i;
|
|
306
|
+
if (provenanceLine === -1 && lines[i] === PROVENANCE_HEADING) provenanceLine = i;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Prose entries live between the frontmatter and the provenance block. The
|
|
310
|
+
// hard stop is whichever comes first of the untrusted-content sentinel and
|
|
311
|
+
// the `## Provenance` heading, so a `### ` heading inside the provenance
|
|
312
|
+
// block (none today, but nothing forbids one) can never be read as an entry.
|
|
313
|
+
const proseEnd = Math.min(
|
|
314
|
+
...[untrustedEndLine, provenanceLine, lines.length].filter((n) => n >= 0),
|
|
315
|
+
);
|
|
316
|
+
/** @type {Array<{heading: string, start: number, end: number}>} */
|
|
317
|
+
const entries = [];
|
|
318
|
+
for (let i = bodyStart; i < proseEnd; i += 1) {
|
|
319
|
+
if (!lines[i].startsWith(HEADING_PREFIX)) continue;
|
|
320
|
+
if (entries.length > 0) entries[entries.length - 1].end = i;
|
|
321
|
+
entries.push({ heading: lines[i].slice(HEADING_PREFIX.length).trim(), start: i, end: proseEnd });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** @type {Array<{key: string, id: string, keyLine: number, idLine: number, markersOnly: boolean}>} */
|
|
325
|
+
const pairs = [];
|
|
326
|
+
/** @type {{key: string, keyLine: number}|null} */
|
|
327
|
+
let pendingKey = null;
|
|
328
|
+
const pairScanStart = provenanceLine >= 0 ? provenanceLine + 1 : lines.length;
|
|
329
|
+
for (let i = pairScanStart; i < lines.length; i += 1) {
|
|
330
|
+
const keyMatch = PAIR_KEY_RE.exec(lines[i]);
|
|
331
|
+
if (keyMatch) {
|
|
332
|
+
pendingKey = { key: keyMatch[1].trim(), keyLine: i };
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const idMatch = PAIR_ID_RE.exec(lines[i]);
|
|
336
|
+
if (!idMatch || !pendingKey) continue;
|
|
337
|
+
pairs.push({
|
|
338
|
+
key: pendingKey.key,
|
|
339
|
+
id: idMatch[1].trim(),
|
|
340
|
+
keyLine: pendingKey.keyLine,
|
|
341
|
+
idLine: i,
|
|
342
|
+
markersOnly: /markers only/.test(idMatch[2]),
|
|
343
|
+
});
|
|
344
|
+
pendingKey = null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
lines,
|
|
349
|
+
expiresAtLine,
|
|
350
|
+
expiresAt,
|
|
351
|
+
counterLine,
|
|
352
|
+
counterDate,
|
|
353
|
+
counterCount,
|
|
354
|
+
entries,
|
|
355
|
+
provenanceLine,
|
|
356
|
+
pairs,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Read `learnings.jsonl` into `id -> expires_at`, counting malformed lines.
|
|
362
|
+
*
|
|
363
|
+
* Malformed lines are COUNTED, never silently dropped: a leniently-skipping
|
|
364
|
+
* JSONL parser turns a partial read into a clean verdict, and this sweep
|
|
365
|
+
* DELETES prose on the strength of that verdict.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} absPath
|
|
368
|
+
* @returns {Promise<{expiryById: Map<string, string>, malformedLines: number}>}
|
|
369
|
+
*/
|
|
370
|
+
async function loadExpiryIndex(absPath) {
|
|
371
|
+
const expiryById = new Map();
|
|
372
|
+
const { entries, malformed } = await readLearnings(absPath);
|
|
373
|
+
for (const entry of entries) {
|
|
374
|
+
const id = entry?.id;
|
|
375
|
+
const expiresAt = entry?.expires_at;
|
|
376
|
+
if (typeof id === 'string' && id.length > 0 && typeof expiresAt === 'string') {
|
|
377
|
+
expiryById.set(id, expiresAt);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return { expiryById, malformedLines: malformed.length };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** `now` as epoch-ms, accepting a Date, a number, an ISO string, or nothing. */
|
|
384
|
+
function toEpochMs(now) {
|
|
385
|
+
if (now instanceof Date) return now.getTime();
|
|
386
|
+
if (typeof now === 'number' && Number.isFinite(now)) return now;
|
|
387
|
+
if (typeof now === 'string' && Number.isFinite(Date.parse(now))) return Date.parse(now);
|
|
388
|
+
return Date.now();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** ISO date part (`YYYY-MM-DD`) of an `expires_at` value, for the header. */
|
|
392
|
+
function dateOnly(iso) {
|
|
393
|
+
return String(iso).slice(0, 10);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The earliest resolvable absorbed date on the file, as `YYYY-MM-DD`.
|
|
398
|
+
*
|
|
399
|
+
* Population: EVERY provenance pair whose `learning-id` resolves to a parseable
|
|
400
|
+
* `expires_at`, markers-only pairs included — an absorbed date stays absorbed
|
|
401
|
+
* after its prose is gone, which is exactly what the body sentence's `N` counts.
|
|
402
|
+
* Unresolvable ids contribute nothing (they are reported separately as
|
|
403
|
+
* `unresolvedPairIds`), so a file whose ids all fail to resolve yields `null`
|
|
404
|
+
* rather than a guess.
|
|
405
|
+
*
|
|
406
|
+
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
407
|
+
* @param {Map<string, string>} expiryById
|
|
408
|
+
* @returns {string|null}
|
|
409
|
+
*/
|
|
410
|
+
function earliestResolvableDate(parsed, expiryById) {
|
|
411
|
+
const dates = parsed.pairs
|
|
412
|
+
.map((p) => expiryById.get(p.id))
|
|
413
|
+
.filter((v) => typeof v === 'string' && Number.isFinite(Date.parse(v)))
|
|
414
|
+
.sort();
|
|
415
|
+
return dates.length > 0 ? dateOnly(dates[0]) : null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The date to RAISE a too-low header to, or `null` when there is nothing to do.
|
|
420
|
+
*
|
|
421
|
+
* One-directional by contract: returns a date only when the header expires
|
|
422
|
+
* STRICTLY EARLIER than the earliest resolvable absorbed date. A header that
|
|
423
|
+
* outlives its content is never lowered here — see the module header for why
|
|
424
|
+
* the two directions are not symmetric.
|
|
425
|
+
*
|
|
426
|
+
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
427
|
+
* @param {Map<string, string>} expiryById
|
|
428
|
+
* @returns {string|null}
|
|
429
|
+
*/
|
|
430
|
+
function headerRaiseTarget(parsed, expiryById) {
|
|
431
|
+
if (!parsed.expiresAt || parsed.expiresAtLine < 0) return null;
|
|
432
|
+
const headerMs = Date.parse(parsed.expiresAt);
|
|
433
|
+
if (!Number.isFinite(headerMs)) return null;
|
|
434
|
+
const earliest = earliestResolvableDate(parsed, expiryById);
|
|
435
|
+
if (earliest === null) return null;
|
|
436
|
+
const earliestMs = Date.parse(earliest);
|
|
437
|
+
if (!Number.isFinite(earliestMs) || earliestMs <= headerMs) return null;
|
|
438
|
+
return earliest;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The header-vs-content discrepancy, as a one-line advisory string.
|
|
443
|
+
*
|
|
444
|
+
* Population: EVERY provenance pair on the file whose `learning-id` resolves to
|
|
445
|
+
* a parseable `expires_at`, markers-only pairs included — an absorbed date
|
|
446
|
+
* stays absorbed after its prose is gone, which is exactly what the body
|
|
447
|
+
* sentence's `N` counts. Unresolvable ids contribute nothing (they are reported
|
|
448
|
+
* separately as `unresolvedPairIds`), so a file whose ids all fail to resolve
|
|
449
|
+
* gets no advisory rather than a guessed one.
|
|
450
|
+
*
|
|
451
|
+
* Deliberately NOT the `newExpiresAt` population: that one excludes expired
|
|
452
|
+
* pairs so a rewritten file is not instantly expired again. This is a
|
|
453
|
+
* diagnostic about the file AS COMMITTED, so it excludes nothing.
|
|
454
|
+
*
|
|
455
|
+
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
456
|
+
* @param {Map<string, string>} expiryById
|
|
457
|
+
* @returns {string|undefined} undefined when there is nothing to report
|
|
458
|
+
*/
|
|
459
|
+
function headerAdvisory(parsed, expiryById) {
|
|
460
|
+
if (!parsed.expiresAt) return undefined;
|
|
461
|
+
const earliest = earliestResolvableDate(parsed, expiryById);
|
|
462
|
+
if (earliest === null || earliest === parsed.expiresAt) return undefined;
|
|
463
|
+
return `header expires-at ${parsed.expiresAt} != earliest resolvable absorbed date ${earliest}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Rewrite ONLY the two dated header lines — the frontmatter `expires-at:` and
|
|
468
|
+
* the `COUNTER_RE` body sentence. Every other byte of the file is carried over
|
|
469
|
+
* untouched; that byte-identity is the whole safety argument for making a raise
|
|
470
|
+
* a rewrite trigger, and it is pinned by a test.
|
|
471
|
+
*
|
|
472
|
+
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
473
|
+
* @param {string} target `YYYY-MM-DD` to raise the header to
|
|
474
|
+
* @returns {string}
|
|
475
|
+
*/
|
|
476
|
+
function rewriteHeaderOnly(parsed, target) {
|
|
477
|
+
const lines = [...parsed.lines];
|
|
478
|
+
if (parsed.expiresAtLine >= 0) lines[parsed.expiresAtLine] = `expires-at: ${target}`;
|
|
479
|
+
if (parsed.counterLine >= 0) {
|
|
480
|
+
lines[parsed.counterLine] = spliceCounterLine(
|
|
481
|
+
lines[parsed.counterLine],
|
|
482
|
+
target,
|
|
483
|
+
parsed.pairs.length,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
return lines.join('\n');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The plan for a file with NO expired entry: a byte-identical `keep`, or — when
|
|
491
|
+
* the header sits below the earliest absorbed date — a minimal `header-raise`
|
|
492
|
+
* rewrite. Shared by both no-expiry branches (the `no-1to1-mapping` skip and
|
|
493
|
+
* the ordinary nothing-expired case) because the raise needs no entry↔pair
|
|
494
|
+
* mapping.
|
|
495
|
+
*
|
|
496
|
+
* @param {object} base the common plan fields
|
|
497
|
+
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
498
|
+
* @param {string|undefined} advisory
|
|
499
|
+
* @param {string|null} raiseTo
|
|
500
|
+
* @returns {object}
|
|
501
|
+
*/
|
|
502
|
+
function keepOrRaisePlan(base, parsed, advisory, raiseTo) {
|
|
503
|
+
if (raiseTo === null) {
|
|
504
|
+
return {
|
|
505
|
+
...base,
|
|
506
|
+
action: 'keep',
|
|
507
|
+
newExpiresAt: null,
|
|
508
|
+
newAbsorbedCount: parsed.pairs.length,
|
|
509
|
+
bytesAfter: base.bytesBefore,
|
|
510
|
+
...(advisory ? { advisory } : {}),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const nextContent = rewriteHeaderOnly(parsed, raiseTo);
|
|
514
|
+
return {
|
|
515
|
+
...base,
|
|
516
|
+
action: 'rewrite',
|
|
517
|
+
reason: 'header-raise',
|
|
518
|
+
newExpiresAt: raiseTo,
|
|
519
|
+
newAbsorbedCount: parsed.pairs.length,
|
|
520
|
+
bytesAfter: Buffer.byteLength(nextContent, 'utf8'),
|
|
521
|
+
nextContent,
|
|
522
|
+
...(advisory ? { advisory } : {}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Plan the sweep over every machine-generated rule file. Reads only.
|
|
528
|
+
*
|
|
529
|
+
* @param {object} [opts]
|
|
530
|
+
* @param {string} opts.repoRoot absolute repo root (required —
|
|
531
|
+
* no `process.cwd()` fallback, so a caller cannot accidentally sweep the
|
|
532
|
+
* operator's live checkout by omitting it)
|
|
533
|
+
* @param {Date|number|string} [opts.now] injected clock
|
|
534
|
+
* @param {number} [opts.graceDays=0] days past expiry before an entry is
|
|
535
|
+
* swept. Default 0: unlike the learnings sweep, nothing re-stamps a rule
|
|
536
|
+
* file's `expires-at`, so there is no re-stamp window to protect.
|
|
537
|
+
* @param {string} [opts.learningsPath] override the learnings store
|
|
538
|
+
* @returns {Promise<{plans: Array<object>, skipped: Array<{file: string, reason: string}>, malformedLines: number, ok: boolean}>}
|
|
539
|
+
*/
|
|
540
|
+
export async function planRuleExpirySweep(opts = {}) {
|
|
541
|
+
const { repoRoot, now, graceDays = 0, learningsPath } = opts;
|
|
542
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
543
|
+
throw new TypeError('planRuleExpirySweep: repoRoot is required');
|
|
544
|
+
}
|
|
545
|
+
if (!Number.isFinite(graceDays) || graceDays < 0) {
|
|
546
|
+
throw new TypeError(`planRuleExpirySweep: graceDays must be a non-negative number, got ${graceDays}`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const nowMs = toEpochMs(now);
|
|
550
|
+
const cutoffMs = nowMs - graceDays * MS_PER_DAY;
|
|
551
|
+
const { expiryById, malformedLines } = await loadExpiryIndex(
|
|
552
|
+
join(repoRoot, learningsPath ?? DEFAULT_LEARNINGS_PATH),
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
const listed = listMachineGeneratedRules({ repoRoot });
|
|
556
|
+
const rulesDir = join(repoRoot, '.claude', 'rules');
|
|
557
|
+
/** @type {Array<object>} */
|
|
558
|
+
const plans = [];
|
|
559
|
+
/** @type {Array<{file: string, reason: string}>} */
|
|
560
|
+
const skipped = [];
|
|
561
|
+
|
|
562
|
+
for (const rule of listed.rules) {
|
|
563
|
+
const abs = join(rulesDir, rule.file);
|
|
564
|
+
let content;
|
|
565
|
+
try {
|
|
566
|
+
content = readFileSync(abs, 'utf8');
|
|
567
|
+
} catch {
|
|
568
|
+
skipped.push({ file: rule.file, reason: 'unreadable' });
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const parsed = parseConsolidatedRule(content);
|
|
573
|
+
const substantive = parsed.pairs.filter((p) => !p.markersOnly);
|
|
574
|
+
|
|
575
|
+
if (parsed.provenanceLine === -1 || parsed.pairs.length === 0) {
|
|
576
|
+
// Not a consolidated file (or no provenance block at all): this sweep has
|
|
577
|
+
// no way to date its content, so it is out of its own remit.
|
|
578
|
+
skipped.push({ file: rule.file, reason: 'no-provenance-block' });
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// The header advisory is computed HERE — over every RESOLVABLE pair on the
|
|
583
|
+
// file, before any early `continue`. It has to be: the discrepancy it
|
|
584
|
+
// reports needs no entry↔pair mapping (it compares the frontmatter date
|
|
585
|
+
// against dates read out of `learnings.jsonl` by id), and while it sat
|
|
586
|
+
// below the `no-1to1-mapping` skip the four merged-prose files — the ones
|
|
587
|
+
// most likely to carry a stale header, `test-hygiene.md` among them — could
|
|
588
|
+
// never receive one. Measured 2026-09-17: the advisory reached 3 of 7 files
|
|
589
|
+
// where 6 of 7 have the discrepancy.
|
|
590
|
+
const advisory = headerAdvisory(parsed, expiryById);
|
|
591
|
+
const raiseTo = headerRaiseTarget(parsed, expiryById);
|
|
592
|
+
|
|
593
|
+
if (parsed.counterLine === -1) {
|
|
594
|
+
// FAIL-CLOSED (GH#70). Every write this module performs moves the
|
|
595
|
+
// frontmatter `expires-at` AND the body sentence that restates it. When
|
|
596
|
+
// the sentence is in a spelling {@link COUNTER_FORMS} does not recognise,
|
|
597
|
+
// the header still moves and the sentence does not — and that sentence
|
|
598
|
+
// literally says the date must not be corrected upward, so the file ships
|
|
599
|
+
// a header contradicting its own body to every agent that loads it
|
|
600
|
+
// (measured on a consumer's corpus: header 2026-11-01 beside a body
|
|
601
|
+
// claiming 2026-10-19). A second regex alone only moves the defect to the
|
|
602
|
+
// next language; refusing to write is what holds for ALL of them.
|
|
603
|
+
//
|
|
604
|
+
// The refusal covers `delete` too, not only the two rewrite triggers: the
|
|
605
|
+
// counter sentence is part of the consolidated-rule contract
|
|
606
|
+
// (`docs/rule-authoring.md` § Consolidated rules point 2), so a file
|
|
607
|
+
// without a recognised one is a shape this sweep does not understand, and
|
|
608
|
+
// `delete` is its most destructive action. Report it, touch nothing.
|
|
609
|
+
skipped.push({ file: rule.file, reason: 'no-counter-sentence' });
|
|
610
|
+
plans.push(
|
|
611
|
+
keepOrRaisePlan(
|
|
612
|
+
{
|
|
613
|
+
file: rule.file,
|
|
614
|
+
expiredPairIds: [],
|
|
615
|
+
keptPairIds: substantive.map((p) => p.id),
|
|
616
|
+
unresolvedPairIds: substantive.filter((p) => !expiryById.has(p.id)).map((p) => p.id),
|
|
617
|
+
bytesBefore: Buffer.byteLength(content, 'utf8'),
|
|
618
|
+
headings: parsed.entries.length,
|
|
619
|
+
substantivePairs: substantive.length,
|
|
620
|
+
},
|
|
621
|
+
parsed,
|
|
622
|
+
advisory,
|
|
623
|
+
null, // never raise: the body sentence cannot be kept in agreement
|
|
624
|
+
),
|
|
625
|
+
);
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (parsed.entries.length !== substantive.length) {
|
|
630
|
+
// FAIL-OPEN (decision 3): report, never guess — the PROSE sweep touches
|
|
631
|
+
// nothing here. A skipped file still carries its advisory, and still gets
|
|
632
|
+
// a too-low header raised: neither needs an entry↔pair mapping.
|
|
633
|
+
skipped.push({ file: rule.file, reason: 'no-1to1-mapping' });
|
|
634
|
+
plans.push(
|
|
635
|
+
keepOrRaisePlan(
|
|
636
|
+
{
|
|
637
|
+
file: rule.file,
|
|
638
|
+
expiredPairIds: [],
|
|
639
|
+
keptPairIds: substantive.map((p) => p.id),
|
|
640
|
+
unresolvedPairIds: substantive.filter((p) => !expiryById.has(p.id)).map((p) => p.id),
|
|
641
|
+
bytesBefore: Buffer.byteLength(content, 'utf8'),
|
|
642
|
+
headings: parsed.entries.length,
|
|
643
|
+
substantivePairs: substantive.length,
|
|
644
|
+
},
|
|
645
|
+
parsed,
|
|
646
|
+
advisory,
|
|
647
|
+
raiseTo,
|
|
648
|
+
),
|
|
649
|
+
);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** @type {Array<{pair: object, entry: object}>} */
|
|
654
|
+
const expired = [];
|
|
655
|
+
const kept = [];
|
|
656
|
+
const unresolved = [];
|
|
657
|
+
substantive.forEach((pair, idx) => {
|
|
658
|
+
const entry = parsed.entries[idx];
|
|
659
|
+
const raw = expiryById.get(pair.id);
|
|
660
|
+
const at = raw !== undefined ? Date.parse(raw) : Number.NaN;
|
|
661
|
+
if (!Number.isFinite(at)) {
|
|
662
|
+
unresolved.push({ pair, entry }); // FAIL-OPEN (decision 2)
|
|
663
|
+
} else if (at < cutoffMs) {
|
|
664
|
+
expired.push({ pair, entry, expiresAt: raw });
|
|
665
|
+
} else {
|
|
666
|
+
kept.push({ pair, entry, expiresAt: raw });
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// D: earliest date over pairs that remain AND are not expired. Markers-only
|
|
671
|
+
// pairs are excluded here even though they count toward N — an already-swept
|
|
672
|
+
// pair's elapsed date would re-expire the file the moment it was rewritten.
|
|
673
|
+
const remainingDates = kept.map((k) => k.expiresAt).filter(Boolean).sort();
|
|
674
|
+
const earliest = remainingDates.length > 0 ? dateOnly(remainingDates[0]) : null;
|
|
675
|
+
|
|
676
|
+
const base = {
|
|
677
|
+
file: rule.file,
|
|
678
|
+
expiredPairIds: expired.map((e) => e.pair.id),
|
|
679
|
+
keptPairIds: kept.map((k) => k.pair.id),
|
|
680
|
+
unresolvedPairIds: unresolved.map((u) => u.pair.id),
|
|
681
|
+
bytesBefore: Buffer.byteLength(content, 'utf8'),
|
|
682
|
+
headings: parsed.entries.length,
|
|
683
|
+
substantivePairs: substantive.length,
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
if (expired.length === 0) {
|
|
687
|
+
// Byte-identical, UNLESS the header sits below the earliest absorbed date
|
|
688
|
+
// — that one direction is repaired (raised), never merely reported, or the
|
|
689
|
+
// whole file falls out of injection with nothing expired. The opposite
|
|
690
|
+
// direction stays an advisory. See the module header.
|
|
691
|
+
plans.push(keepOrRaisePlan(base, parsed, advisory, raiseTo));
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (kept.length === 0 && unresolved.length === 0) {
|
|
696
|
+
plans.push({
|
|
697
|
+
...base,
|
|
698
|
+
action: 'delete',
|
|
699
|
+
newExpiresAt: null,
|
|
700
|
+
newAbsorbedCount: 0,
|
|
701
|
+
bytesAfter: 0,
|
|
702
|
+
// EVERY pair on the file, not only the substantive ones — the delete
|
|
703
|
+
// removes markers-only pairs too, and each is a dedupe marker.
|
|
704
|
+
stampKeys: parsed.pairs.map((p) => p.key),
|
|
705
|
+
});
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const nextContent = rewriteContent({ parsed, expired, earliest, nowMs });
|
|
710
|
+
plans.push({
|
|
711
|
+
...base,
|
|
712
|
+
action: 'rewrite',
|
|
713
|
+
reason: 'expired-entries',
|
|
714
|
+
newExpiresAt: earliest,
|
|
715
|
+
newAbsorbedCount: parsed.pairs.length,
|
|
716
|
+
bytesAfter: Buffer.byteLength(nextContent, 'utf8'),
|
|
717
|
+
nextContent,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return { plans, skipped, malformedLines, ok: listed.ok };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Build the rewritten file contents: expired prose removed, expired pairs
|
|
726
|
+
* converted to `markers only`, header + counter sentence recomputed.
|
|
727
|
+
*
|
|
728
|
+
* @param {{parsed: object, expired: Array<object>, earliest: string|null, nowMs: number}} args
|
|
729
|
+
* @returns {string}
|
|
730
|
+
*/
|
|
731
|
+
function rewriteContent({ parsed, expired, earliest, nowMs }) {
|
|
732
|
+
const lines = [...parsed.lines];
|
|
733
|
+
const sweptOn = new Date(nowMs).toISOString().slice(0, 10);
|
|
734
|
+
|
|
735
|
+
// Annotate the pairs FIRST (line indices are still the parsed ones), then
|
|
736
|
+
// splice prose out back-to-front so earlier indices stay valid.
|
|
737
|
+
for (const { pair, expiresAt } of expired) {
|
|
738
|
+
lines[pair.idLine] =
|
|
739
|
+
`${lines[pair.idLine]} <!-- markers only (substance: expired ${dateOnly(expiresAt)}, prose swept ${sweptOn}) -->`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (earliest !== null) {
|
|
743
|
+
if (parsed.expiresAtLine >= 0) lines[parsed.expiresAtLine] = `expires-at: ${earliest}`;
|
|
744
|
+
if (parsed.counterLine >= 0) {
|
|
745
|
+
lines[parsed.counterLine] = spliceCounterLine(
|
|
746
|
+
lines[parsed.counterLine],
|
|
747
|
+
earliest,
|
|
748
|
+
parsed.pairs.length,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const blocks = expired.map((e) => e.entry).sort((a, b) => b.start - a.start);
|
|
754
|
+
for (const entry of blocks) lines.splice(entry.start, entry.end - entry.start);
|
|
755
|
+
|
|
756
|
+
return lines.join('\n');
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Refuse to write to (or unlink) anything that is not a regular file sitting
|
|
761
|
+
* directly under the rules directory. Throws; the caller turns that into a
|
|
762
|
+
* per-file `errors[]` entry and moves on to the next plan.
|
|
763
|
+
*
|
|
764
|
+
* Two independent checks, because they fail differently:
|
|
765
|
+
*
|
|
766
|
+
* 1. **`lstatSync` symlink check.** `writeFileSync` FOLLOWS a symlink and
|
|
767
|
+
* rewrites its TARGET, leaving the link intact — so a symlink tracked at
|
|
768
|
+
* `.claude/rules/<n>.md` pointing anywhere on the host turns this sweep
|
|
769
|
+
* into an arbitrary-file writer whose victim's `sha` changes with nothing
|
|
770
|
+
* in the rules directory to show for it. The atomic writer above is not a
|
|
771
|
+
* fix for that: `renameSync` would replace the LINK, silently discarding an
|
|
772
|
+
* operator's deliberate indirection. Same precedent as
|
|
773
|
+
* `.claude/rules/guard-design.md` § "a security fix that follows an
|
|
774
|
+
* unreviewed security fix" (*"`writeFileSync` followed symlinks"*).
|
|
775
|
+
* 2. **Path confinement.** `p.file` arrives from
|
|
776
|
+
* `listMachineGeneratedRules()` today, but this function is exported and a
|
|
777
|
+
* hand-built plan carrying `../../victim.md` would `join()` cleanly out of
|
|
778
|
+
* the directory. `resolve()`-then-prefix is the check that survives a
|
|
779
|
+
* future second caller.
|
|
780
|
+
*
|
|
781
|
+
* @param {string} abs joined absolute path
|
|
782
|
+
* @param {string} rulesDir the `.claude/rules` directory
|
|
783
|
+
* @param {string} file plan-relative name, for the message
|
|
784
|
+
* @returns {void}
|
|
785
|
+
*/
|
|
786
|
+
function assertSweepable(abs, rulesDir, file) {
|
|
787
|
+
const confined = `${resolve(rulesDir)}${sep}`;
|
|
788
|
+
const resolved = resolve(abs);
|
|
789
|
+
if (!resolved.startsWith(confined)) {
|
|
790
|
+
throw new Error(`refusing to touch ${file}: resolves outside ${rulesDir}`);
|
|
791
|
+
}
|
|
792
|
+
let stat;
|
|
793
|
+
try {
|
|
794
|
+
stat = lstatSync(abs);
|
|
795
|
+
} catch (err) {
|
|
796
|
+
throw new Error(`refusing to touch ${file}: ${err?.message ?? String(err)}`, { cause: err });
|
|
797
|
+
}
|
|
798
|
+
if (stat.isSymbolicLink()) {
|
|
799
|
+
throw new Error(
|
|
800
|
+
`refusing to touch ${file}: it is a symlink — a write would follow it to its target`,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
if (!stat.isFile()) {
|
|
804
|
+
throw new Error(`refusing to touch ${file}: not a regular file`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Execute a plan. THE ONLY WRITE PATH.
|
|
810
|
+
*
|
|
811
|
+
* Ordering is load-bearing for `action: 'delete'`: every provenance pair is
|
|
812
|
+
* stamped terminal via `markCandidateProcessed` BEFORE the unlink. A crash
|
|
813
|
+
* between the two leaves a stamped candidate and a live file (harmless — the
|
|
814
|
+
* file still dedupes) rather than an unstamped learning with no file, which
|
|
815
|
+
* `/reconcile` would re-propose.
|
|
816
|
+
*
|
|
817
|
+
* A plan carrying `reason: 'header-raise'` is an ordinary `action: 'rewrite'`
|
|
818
|
+
* here — same guards, same atomic write, counted in `rewritten` — because it IS
|
|
819
|
+
* a write to a tracked rule file; only its `nextContent` is narrower.
|
|
820
|
+
*
|
|
821
|
+
* Every rewrite and every delete passes {@link assertSweepable} first (regular
|
|
822
|
+
* file, inside the rules directory, not a symlink), and every rewrite goes
|
|
823
|
+
* through `atomicWriteWithBackup` rather than `writeFileSync`. A refusal is a
|
|
824
|
+
* per-file `errors[]` entry and never aborts the remaining plans — but it DOES
|
|
825
|
+
* make the CLI exit 2, so a refused write cannot pass as a clean sweep.
|
|
826
|
+
*
|
|
827
|
+
* @param {{plans: Array<object>}} plan the {@link planRuleExpirySweep} result
|
|
828
|
+
* @param {{repoRoot: string, now?: Date|number|string}} ctx
|
|
829
|
+
* @returns {{rewritten: string[], deleted: string[], stamped: number, errors: Array<{file: string, error: string}>}}
|
|
830
|
+
*/
|
|
831
|
+
export function applyRuleExpirySweep(plan, ctx = {}) {
|
|
832
|
+
const { repoRoot, now } = ctx;
|
|
833
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
834
|
+
throw new TypeError('applyRuleExpirySweep: repoRoot is required');
|
|
835
|
+
}
|
|
836
|
+
const plans = Array.isArray(plan?.plans) ? plan.plans : [];
|
|
837
|
+
const rulesDir = join(repoRoot, '.claude', 'rules');
|
|
838
|
+
const processedAt = new Date(toEpochMs(now)).toISOString();
|
|
839
|
+
|
|
840
|
+
const rewritten = [];
|
|
841
|
+
const deleted = [];
|
|
842
|
+
const errors = [];
|
|
843
|
+
let stamped = 0;
|
|
844
|
+
|
|
845
|
+
for (const p of plans) {
|
|
846
|
+
const abs = join(rulesDir, p.file);
|
|
847
|
+
try {
|
|
848
|
+
if (p.action === 'rewrite' || p.action === 'delete') assertSweepable(abs, rulesDir, p.file);
|
|
849
|
+
if (p.action === 'rewrite') {
|
|
850
|
+
if (typeof p.nextContent !== 'string') {
|
|
851
|
+
throw new Error('plan carries action "rewrite" but no nextContent');
|
|
852
|
+
}
|
|
853
|
+
// Atomic tmp+rename, never a bare writeFileSync: a crash mid-write
|
|
854
|
+
// would leave a half-written RULE FILE tracked in git, and a torn
|
|
855
|
+
// `## Provenance` block is a silently re-proposing corpus.
|
|
856
|
+
const res = atomicWriteWithBackup(abs, p.nextContent, {
|
|
857
|
+
tmpPrefix: '.rule-expiry-sweep',
|
|
858
|
+
});
|
|
859
|
+
if (!res.ok) throw new Error(res.error);
|
|
860
|
+
rewritten.push(p.file);
|
|
861
|
+
} else if (p.action === 'delete') {
|
|
862
|
+
for (const key of p.stampKeys ?? []) {
|
|
863
|
+
const res = markCandidateProcessed({
|
|
864
|
+
learningKey: key,
|
|
865
|
+
outcome: SWEPT_OUTCOME,
|
|
866
|
+
processedAt,
|
|
867
|
+
fallbackSlug: p.file.replace(/\.md$/, ''),
|
|
868
|
+
repoRoot,
|
|
869
|
+
});
|
|
870
|
+
if (!res.written) {
|
|
871
|
+
throw new Error(`stamp failed for learning-key ${key} — refusing to delete ${p.file}`);
|
|
872
|
+
}
|
|
873
|
+
stamped += 1;
|
|
874
|
+
}
|
|
875
|
+
unlinkSync(abs);
|
|
876
|
+
deleted.push(p.file);
|
|
877
|
+
}
|
|
878
|
+
} catch (err) {
|
|
879
|
+
errors.push({ file: p.file, error: err?.message ?? String(err) });
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return { rewritten, deleted, stamped, errors };
|
|
884
|
+
}
|