session-orchestrator 5.0.0 → 5.2.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/autopilot/SKILL.md +1 -0
- package/.agents/skills/bootstrap/SKILL.md +2 -0
- package/.agents/skills/brainstorm/SKILL.md +3 -0
- package/.agents/skills/close/SKILL.md +17 -0
- package/.agents/skills/debug/SKILL.md +2 -0
- package/.agents/skills/discovery/SKILL.md +2 -1
- package/.agents/skills/dispatcher/SKILL.md +2 -0
- package/.agents/skills/eli5/SKILL.md +2 -0
- package/.agents/skills/eval/SKILL.md +1 -0
- package/.agents/skills/evolve/SKILL.md +2 -1
- package/.agents/skills/go/SKILL.md +18 -0
- package/.agents/skills/grill/SKILL.md +2 -0
- package/.agents/skills/harness-audit/SKILL.md +16 -0
- package/.agents/skills/memory-cleanup/SKILL.md +1 -0
- package/.agents/skills/persona-panel/SKILL.md +1 -0
- package/.agents/skills/plan/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +17 -0
- package/.agents/skills/reconcile/SKILL.md +1 -0
- package/.agents/skills/release/SKILL.md +18 -0
- package/.agents/skills/repo-audit/SKILL.md +1 -0
- package/.agents/skills/spinout/SKILL.md +1 -0
- package/.agents/skills/sunset-review/SKILL.md +2 -0
- package/.agents/skills/test/SKILL.md +17 -0
- package/.agents/skills/ux-grill/SKILL.md +2 -0
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- 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 +2 -0
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +5 -0
- 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 +2 -0
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +5 -0
- 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/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/convergence-monitoring.md +13 -0
- 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/npm-publish.md +13 -0
- 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/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/010-session-workflow.mdc +2 -2
- package/.cursor/skills/bootstrap/SKILL.md +1 -0
- package/.cursor/skills/close/SKILL.md +13 -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/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/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 +2 -2
- package/.orchestrator/policy/blocked-commands.json +10 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +74 -235
- package/commands/session.md +10 -0
- package/docs/USER-GUIDE.md +24 -0
- package/docs/ci-setup.md +53 -0
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +12 -5
- package/docs/events-schema.md +5 -1
- package/docs/install.md +128 -0
- package/docs/persona-panel.md +1 -1
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +83 -14
- package/docs/scope-collision-guard.md +2 -0
- package/docs/session-config-reference.md +6 -4
- package/docs/session-config-template.md +38 -0
- package/docs/telemetry.md +15 -0
- package/hooks/_lib/hook-import-set.json +46 -6
- package/hooks/_lib/subagent-paths.mjs +15 -0
- package/hooks/_lib/vcs-create-matcher.mjs +217 -62
- package/hooks/enforce-scope.mjs +42 -1
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +14 -2
- package/hooks/on-stop.mjs +43 -1
- package/hooks/post-bash-write-verify.mjs +3 -0
- package/hooks/pre-auq-clarity.mjs +3 -0
- package/hooks/pre-bash-issue-budget.mjs +103 -17
- package/hooks/pre-task-scope-disjoint.mjs +152 -3
- package/hooks/skill-invocation-telemetry.mjs +2 -1
- package/package.json +3 -2
- 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/convergence-monitoring.md +11 -0
- 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/npm-publish.md +11 -0
- 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 +1 -1
- 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/scripts/archive-closed-prds.mjs +2 -2
- package/scripts/auq-audit.mjs +2 -3
- package/scripts/backfill-abandoned-sessions.mjs +57 -3
- 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/ci/assert-vitest-green.mjs +2 -1
- package/scripts/emit-session.mjs +2 -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-codex-skills.mjs +48 -4
- package/scripts/generate-cursor-adapter.mjs +173 -9
- 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/claude-md-budget-lint.mjs +2 -5
- package/scripts/lib/command-blocker.mjs +209 -9
- package/scripts/lib/config/drift-check.mjs +19 -0
- package/scripts/lib/convergence-monitor.mjs +2 -2
- 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-wizard.mjs +2 -1
- package/scripts/lib/fetch-baseline.mjs +3 -8
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +2 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +2 -1
- package/scripts/lib/instruction-budget-guard.mjs +186 -46
- package/scripts/lib/is-main-module.mjs +82 -0
- package/scripts/lib/locks/index.mjs +32 -25
- package/scripts/lib/maintenance-due-banner.mjs +69 -3
- package/scripts/lib/peer-discovery.mjs +2 -5
- package/scripts/lib/playwright-driver/runner.mjs +63 -2
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +642 -0
- package/scripts/lib/rules-sync.mjs +2 -5
- package/scripts/lib/scope-echo.mjs +392 -7
- package/scripts/lib/session-close-backfill.mjs +58 -6
- package/scripts/lib/state-md.mjs +84 -3
- package/scripts/lib/sunset/walker.mjs +31 -4
- package/scripts/lib/tests-src-ratio.mjs +2 -6
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +2 -1
- package/scripts/lib/user-invocable-skills.mjs +185 -0
- package/scripts/lib/validate/check-banner-parity.mjs +2 -2
- package/scripts/lib/validate/check-cursor-adapter.mjs +2 -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 +366 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +2 -2
- 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-skill-links.mjs +27 -6
- package/scripts/lib/validate/check-skill-script-paths.mjs +2 -2
- package/scripts/lib/validate/check-test-git-config-target.mjs +2 -2
- package/scripts/lib/validate/check-unicode-safety.mjs +2 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +2 -2
- package/scripts/lib/validate/check-unwired-features.mjs +266 -11
- package/scripts/lib/validate/check-validator-registration.mjs +2 -2
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +2 -2
- package/scripts/lib/validate-vendored-rules.mjs +35 -9
- package/scripts/lib/wave-transcript-tail.mjs +2 -2
- 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.mjs +124 -35
- package/scripts/relocate-vault-corpus.mjs +2 -3
- package/scripts/repair-invalid-sessions.mjs +2 -2
- package/scripts/session-shape.mjs +2 -2
- package/scripts/site-numbers.mjs +35 -11
- package/scripts/sweep-expired-rules.mjs +216 -0
- package/scripts/validate-plugin.mjs +9 -0
- package/scripts/vault-consolidate.mjs +2 -2
- package/scripts/vault-mirror.mjs +2 -3
- 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/research-evidence.md +53 -0
- package/skills/_shared/state-ownership.md +3 -0
- package/skills/autopilot/SKILL.md +58 -4
- package/skills/bootstrap/SKILL.md +51 -1
- package/skills/brainstorm/SKILL.md +16 -0
- package/skills/claude-md-drift-check/checker.mjs +49 -11
- package/{commands/close.md → skills/close/SKILL.md} +9 -3
- 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 +14 -0
- package/skills/evolve/SKILL.md +8 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +6 -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 +1 -1
- 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/skills/playwright-driver/SKILL.md +7 -10
- package/{commands/portfolio.md → skills/portfolio/SKILL.md} +8 -2
- package/skills/reconcile/SKILL.md +10 -0
- package/{commands/release.md → skills/release/SKILL.md} +16 -2
- package/skills/repo-audit/SKILL.md +7 -0
- package/skills/session-end/plan-verification.md +2 -2
- package/skills/session-plan/SKILL.md +1 -1
- 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 +4 -3
- 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 +19 -1
- package/skills/wave-executor/SKILL.md +7 -4
- package/skills/wave-executor/references/wave-executor-state-init.md +13 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
- 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,642 @@
|
|
|
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
|
+
* 2. **Ambiguous file → `action: 'keep'` + a `skipped` record.** The
|
|
41
|
+
* entry↔pair mapping is POSITIONAL: the k-th `### ` heading belongs to the
|
|
42
|
+
* k-th non-`markers only` pair. That 1:1 mapping holds in only 3 of the 7
|
|
43
|
+
* live files (measured 2026-09-17: measurement-discipline 12/12,
|
|
44
|
+
* process-contracts 6/6, toolchain-and-build 10/10; guard-design 8≠11,
|
|
45
|
+
* identity-and-locks 9≠10, test-hygiene 7≠8,
|
|
46
|
+
* review-and-adapter-contracts 9≠12 — several learnings were merged into
|
|
47
|
+
* one prose entry there). Where it does not hold, this module reports
|
|
48
|
+
* `no-1to1-mapping` and touches nothing. It never guesses which paragraph
|
|
49
|
+
* belongs to which learning.
|
|
50
|
+
*
|
|
51
|
+
* ## Header recompute happens ONLY on a file this sweep rewrites
|
|
52
|
+
*
|
|
53
|
+
* A consolidated file's frontmatter `expires-at` plus the body sentence
|
|
54
|
+
* ``**`expires-at` <D> = the EARLIEST of the <N> absorbed dates**`` must not
|
|
55
|
+
* outlive its shortest-lived content. Both are recomputed — but only for a file
|
|
56
|
+
* the sweep actually rewrites. A file with nothing expired is left
|
|
57
|
+
* BYTE-IDENTICAL, deliberately, because a recompute-on-every-run would silently
|
|
58
|
+
* shorten a healthy file's TTL on the very first live invocation. The
|
|
59
|
+
* discrepancy is REPORTED instead, as the per-plan `advisory` field — see
|
|
60
|
+
* {@link headerAdvisory}, which is computed BEFORE the `no-1to1-mapping` skip
|
|
61
|
+
* so a skipped file still gets one.
|
|
62
|
+
*
|
|
63
|
+
* Measured 2026-09-17 (`node scripts/sweep-expired-rules.mjs --json`, 7 files
|
|
64
|
+
* scanned, 0 expired): **6 of the 7 carry a discrepancy** — `identity-and-locks`
|
|
65
|
+
* 2026-10-01 vs 2026-10-02, `measurement-discipline` 2026-10-04 vs 2026-10-02,
|
|
66
|
+
* `process-contracts` 2026-10-04 vs 2026-10-27, `review-and-adapter-contracts`
|
|
67
|
+
* 2026-10-04 vs 2026-10-02, `test-hygiene` 2026-10-20 vs 2026-10-07,
|
|
68
|
+
* `toolchain-and-build` 2026-10-01 vs 2026-10-16; only `guard-design` agrees
|
|
69
|
+
* with its content. Three of the six are the harmful direction — a header
|
|
70
|
+
* OUTLIVING its content: `measurement-discipline`, `review-and-adapter-contracts`
|
|
71
|
+
* and `test-hygiene`. The other three expire EARLIER than they need to, which
|
|
72
|
+
* costs injection but loses nothing.
|
|
73
|
+
*
|
|
74
|
+
* Before the advisory moved above the skip branch it reached only the 3
|
|
75
|
+
* 1:1-mappable files, which structurally excluded the four merged-prose files —
|
|
76
|
+
* `test-hygiene` among them, i.e. the instrument could not report the very
|
|
77
|
+
* defect its docblock named.
|
|
78
|
+
*
|
|
79
|
+
* Two populations, stated because they are not the same:
|
|
80
|
+
* - `newExpiresAt` (D) = the earliest `expires_at` over remaining resolvable
|
|
81
|
+
* pairs that are NOT expired as of `now`. Excluding the expired ones is
|
|
82
|
+
* what stops a rewritten file from being instantly expired again by a pair
|
|
83
|
+
* whose prose was just swept.
|
|
84
|
+
* - `newAbsorbedCount` (N) = the number of pairs REMAINING IN THE FILE.
|
|
85
|
+
* Measured 2026-09-17: all 7 live sentences carry the TOTAL pair count
|
|
86
|
+
* (15/9/13/17/16/10/12), markers-only pairs included — an absorbed date
|
|
87
|
+
* stays absorbed after its prose is gone. Since this module never removes a
|
|
88
|
+
* pair from a surviving file, N is stable across a rewrite by construction;
|
|
89
|
+
* it is re-emitted from the parse rather than copied so it cannot drift if
|
|
90
|
+
* the pair population ever does change.
|
|
91
|
+
*
|
|
92
|
+
* ## Deleting a whole file
|
|
93
|
+
*
|
|
94
|
+
* A file is deleted only when it has ZERO kept and ZERO unresolved pairs — i.e.
|
|
95
|
+
* every substantive entry expired. Because the delete DOES remove provenance
|
|
96
|
+
* pairs, every pair's `learning_key` is stamped via `markCandidateProcessed`
|
|
97
|
+
* BEFORE the unlink (`docs/rule-authoring.md` § Consolidated rules). Stamping
|
|
98
|
+
* after the delete would leave a window in which `/reconcile` sees neither the
|
|
99
|
+
* file nor a terminal candidate record and re-proposes the whole file's worth
|
|
100
|
+
* of learnings.
|
|
101
|
+
*
|
|
102
|
+
* Dry-run is the default and writes NOTHING. {@link applyRuleExpirySweep} is
|
|
103
|
+
* the only write path.
|
|
104
|
+
*
|
|
105
|
+
* @module scripts/lib/reconcile/rule-expiry-sweep
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
import { lstatSync, readFileSync, unlinkSync } from 'node:fs';
|
|
109
|
+
import { join, resolve, sep } from 'node:path';
|
|
110
|
+
|
|
111
|
+
import { atomicWriteWithBackup } from '../io.mjs';
|
|
112
|
+
import { readLearnings } from '../learnings/io.mjs';
|
|
113
|
+
import { listMachineGeneratedRules } from '../instruction-budget-guard.mjs';
|
|
114
|
+
import { markCandidateProcessed } from './idempotency.mjs';
|
|
115
|
+
|
|
116
|
+
/** Repo-relative learnings store — the only source of a per-entry date. */
|
|
117
|
+
export const DEFAULT_LEARNINGS_PATH = '.orchestrator/metrics/learnings.jsonl';
|
|
118
|
+
|
|
119
|
+
/** Event emitted on `--apply` only, AFTER the writes succeeded. */
|
|
120
|
+
export const RULE_EXPIRY_SWEEP_EVENT = 'orchestrator.rules.expiry_sweep_applied';
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `outcome` written on the candidate records stamped before a file delete.
|
|
124
|
+
*
|
|
125
|
+
* A new member of an ADDITIVE field: the ReconcileCandidate typedef documents
|
|
126
|
+
* that nothing branches on `outcome` (terminality is `processed_at` alone), so
|
|
127
|
+
* an unknown value round-trips unchanged. It is distinct from `'written'` on
|
|
128
|
+
* purpose — the file WAS written once and is now gone, and a census over the
|
|
129
|
+
* store must be able to tell "materialized" from "materialized then swept".
|
|
130
|
+
*/
|
|
131
|
+
export const SWEPT_OUTCOME = 'expired-swept';
|
|
132
|
+
|
|
133
|
+
const MS_PER_DAY = 86_400_000;
|
|
134
|
+
|
|
135
|
+
const PAIR_KEY_RE = /^- learning-key:\s*`([^`]+)`/;
|
|
136
|
+
const PAIR_ID_RE = /^- learning-id:\s*`([^`]+)`(.*)$/;
|
|
137
|
+
const COUNTER_RE = /^\*\*`expires-at` (\S+) = the EARLIEST of the (\d+) absorbed dates\*\*/;
|
|
138
|
+
const HEADING_PREFIX = '### ';
|
|
139
|
+
const PROVENANCE_HEADING = '## Provenance';
|
|
140
|
+
const UNTRUSTED_END = '<!-- untrusted-content:end -->';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse a consolidated machine-generated rule file into its editable parts.
|
|
144
|
+
*
|
|
145
|
+
* PURE — takes and returns data, touches no disk. Line-based on purpose: the
|
|
146
|
+
* rewrite is a line splice, and a round-trip through a Markdown AST would
|
|
147
|
+
* reformat parts of the file this sweep has no business touching.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} content raw file contents
|
|
150
|
+
* @returns {{
|
|
151
|
+
* lines: string[],
|
|
152
|
+
* expiresAtLine: number,
|
|
153
|
+
* expiresAt: string|null,
|
|
154
|
+
* counterLine: number,
|
|
155
|
+
* counterDate: string|null,
|
|
156
|
+
* counterCount: number|null,
|
|
157
|
+
* entries: Array<{heading: string, start: number, end: number}>,
|
|
158
|
+
* provenanceLine: number,
|
|
159
|
+
* pairs: Array<{key: string, id: string, keyLine: number, idLine: number, markersOnly: boolean}>
|
|
160
|
+
* }} `expiresAtLine` / `counterLine` / `provenanceLine` are `-1` when absent.
|
|
161
|
+
*/
|
|
162
|
+
export function parseConsolidatedRule(content) {
|
|
163
|
+
const lines = String(content ?? '').split('\n');
|
|
164
|
+
|
|
165
|
+
let expiresAtLine = -1;
|
|
166
|
+
let expiresAt = null;
|
|
167
|
+
let counterLine = -1;
|
|
168
|
+
let counterDate = null;
|
|
169
|
+
let counterCount = null;
|
|
170
|
+
let provenanceLine = -1;
|
|
171
|
+
let untrustedEndLine = -1;
|
|
172
|
+
|
|
173
|
+
// Frontmatter is the FIRST `---`-delimited block only; an `expires-at:` in
|
|
174
|
+
// the body (a learning quoting one) must never be mistaken for the header.
|
|
175
|
+
let frontmatterEnd = -1;
|
|
176
|
+
if (lines[0] === '---') {
|
|
177
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
178
|
+
if (lines[i] === '---') {
|
|
179
|
+
frontmatterEnd = i;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (let i = 1; i < frontmatterEnd; i += 1) {
|
|
185
|
+
const m = /^expires-at:\s*(\S+)\s*$/.exec(lines[i]);
|
|
186
|
+
if (m) {
|
|
187
|
+
expiresAtLine = i;
|
|
188
|
+
expiresAt = m[1];
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const bodyStart = frontmatterEnd >= 0 ? frontmatterEnd + 1 : 0;
|
|
194
|
+
for (let i = bodyStart; i < lines.length; i += 1) {
|
|
195
|
+
if (counterLine === -1) {
|
|
196
|
+
const m = COUNTER_RE.exec(lines[i]);
|
|
197
|
+
if (m) {
|
|
198
|
+
counterLine = i;
|
|
199
|
+
counterDate = m[1];
|
|
200
|
+
counterCount = Number(m[2]);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (untrustedEndLine === -1 && lines[i].startsWith(UNTRUSTED_END)) untrustedEndLine = i;
|
|
205
|
+
if (provenanceLine === -1 && lines[i] === PROVENANCE_HEADING) provenanceLine = i;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Prose entries live between the frontmatter and the provenance block. The
|
|
209
|
+
// hard stop is whichever comes first of the untrusted-content sentinel and
|
|
210
|
+
// the `## Provenance` heading, so a `### ` heading inside the provenance
|
|
211
|
+
// block (none today, but nothing forbids one) can never be read as an entry.
|
|
212
|
+
const proseEnd = Math.min(
|
|
213
|
+
...[untrustedEndLine, provenanceLine, lines.length].filter((n) => n >= 0),
|
|
214
|
+
);
|
|
215
|
+
/** @type {Array<{heading: string, start: number, end: number}>} */
|
|
216
|
+
const entries = [];
|
|
217
|
+
for (let i = bodyStart; i < proseEnd; i += 1) {
|
|
218
|
+
if (!lines[i].startsWith(HEADING_PREFIX)) continue;
|
|
219
|
+
if (entries.length > 0) entries[entries.length - 1].end = i;
|
|
220
|
+
entries.push({ heading: lines[i].slice(HEADING_PREFIX.length).trim(), start: i, end: proseEnd });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** @type {Array<{key: string, id: string, keyLine: number, idLine: number, markersOnly: boolean}>} */
|
|
224
|
+
const pairs = [];
|
|
225
|
+
/** @type {{key: string, keyLine: number}|null} */
|
|
226
|
+
let pendingKey = null;
|
|
227
|
+
const pairScanStart = provenanceLine >= 0 ? provenanceLine + 1 : lines.length;
|
|
228
|
+
for (let i = pairScanStart; i < lines.length; i += 1) {
|
|
229
|
+
const keyMatch = PAIR_KEY_RE.exec(lines[i]);
|
|
230
|
+
if (keyMatch) {
|
|
231
|
+
pendingKey = { key: keyMatch[1].trim(), keyLine: i };
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const idMatch = PAIR_ID_RE.exec(lines[i]);
|
|
235
|
+
if (!idMatch || !pendingKey) continue;
|
|
236
|
+
pairs.push({
|
|
237
|
+
key: pendingKey.key,
|
|
238
|
+
id: idMatch[1].trim(),
|
|
239
|
+
keyLine: pendingKey.keyLine,
|
|
240
|
+
idLine: i,
|
|
241
|
+
markersOnly: /markers only/.test(idMatch[2]),
|
|
242
|
+
});
|
|
243
|
+
pendingKey = null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
lines,
|
|
248
|
+
expiresAtLine,
|
|
249
|
+
expiresAt,
|
|
250
|
+
counterLine,
|
|
251
|
+
counterDate,
|
|
252
|
+
counterCount,
|
|
253
|
+
entries,
|
|
254
|
+
provenanceLine,
|
|
255
|
+
pairs,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Read `learnings.jsonl` into `id -> expires_at`, counting malformed lines.
|
|
261
|
+
*
|
|
262
|
+
* Malformed lines are COUNTED, never silently dropped: a leniently-skipping
|
|
263
|
+
* JSONL parser turns a partial read into a clean verdict, and this sweep
|
|
264
|
+
* DELETES prose on the strength of that verdict.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} absPath
|
|
267
|
+
* @returns {Promise<{expiryById: Map<string, string>, malformedLines: number}>}
|
|
268
|
+
*/
|
|
269
|
+
async function loadExpiryIndex(absPath) {
|
|
270
|
+
const expiryById = new Map();
|
|
271
|
+
const { entries, malformed } = await readLearnings(absPath);
|
|
272
|
+
for (const entry of entries) {
|
|
273
|
+
const id = entry?.id;
|
|
274
|
+
const expiresAt = entry?.expires_at;
|
|
275
|
+
if (typeof id === 'string' && id.length > 0 && typeof expiresAt === 'string') {
|
|
276
|
+
expiryById.set(id, expiresAt);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { expiryById, malformedLines: malformed.length };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `now` as epoch-ms, accepting a Date, a number, an ISO string, or nothing. */
|
|
283
|
+
function toEpochMs(now) {
|
|
284
|
+
if (now instanceof Date) return now.getTime();
|
|
285
|
+
if (typeof now === 'number' && Number.isFinite(now)) return now;
|
|
286
|
+
if (typeof now === 'string' && Number.isFinite(Date.parse(now))) return Date.parse(now);
|
|
287
|
+
return Date.now();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** ISO date part (`YYYY-MM-DD`) of an `expires_at` value, for the header. */
|
|
291
|
+
function dateOnly(iso) {
|
|
292
|
+
return String(iso).slice(0, 10);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The header-vs-content discrepancy, as a one-line advisory string.
|
|
297
|
+
*
|
|
298
|
+
* Population: EVERY provenance pair on the file whose `learning-id` resolves to
|
|
299
|
+
* a parseable `expires_at`, markers-only pairs included — an absorbed date
|
|
300
|
+
* stays absorbed after its prose is gone, which is exactly what the body
|
|
301
|
+
* sentence's `N` counts. Unresolvable ids contribute nothing (they are reported
|
|
302
|
+
* separately as `unresolvedPairIds`), so a file whose ids all fail to resolve
|
|
303
|
+
* gets no advisory rather than a guessed one.
|
|
304
|
+
*
|
|
305
|
+
* Deliberately NOT the `newExpiresAt` population: that one excludes expired
|
|
306
|
+
* pairs so a rewritten file is not instantly expired again. This is a
|
|
307
|
+
* diagnostic about the file AS COMMITTED, so it excludes nothing.
|
|
308
|
+
*
|
|
309
|
+
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
310
|
+
* @param {Map<string, string>} expiryById
|
|
311
|
+
* @returns {string|undefined} undefined when there is nothing to report
|
|
312
|
+
*/
|
|
313
|
+
function headerAdvisory(parsed, expiryById) {
|
|
314
|
+
if (!parsed.expiresAt) return undefined;
|
|
315
|
+
const dates = parsed.pairs
|
|
316
|
+
.map((p) => expiryById.get(p.id))
|
|
317
|
+
.filter((v) => typeof v === 'string' && Number.isFinite(Date.parse(v)))
|
|
318
|
+
.sort();
|
|
319
|
+
if (dates.length === 0) return undefined;
|
|
320
|
+
const earliest = dateOnly(dates[0]);
|
|
321
|
+
if (earliest === parsed.expiresAt) return undefined;
|
|
322
|
+
return `header expires-at ${parsed.expiresAt} != earliest resolvable absorbed date ${earliest}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Plan the sweep over every machine-generated rule file. Reads only.
|
|
327
|
+
*
|
|
328
|
+
* @param {object} [opts]
|
|
329
|
+
* @param {string} opts.repoRoot absolute repo root (required —
|
|
330
|
+
* no `process.cwd()` fallback, so a caller cannot accidentally sweep the
|
|
331
|
+
* operator's live checkout by omitting it)
|
|
332
|
+
* @param {Date|number|string} [opts.now] injected clock
|
|
333
|
+
* @param {number} [opts.graceDays=0] days past expiry before an entry is
|
|
334
|
+
* swept. Default 0: unlike the learnings sweep, nothing re-stamps a rule
|
|
335
|
+
* file's `expires-at`, so there is no re-stamp window to protect.
|
|
336
|
+
* @param {string} [opts.learningsPath] override the learnings store
|
|
337
|
+
* @returns {Promise<{plans: Array<object>, skipped: Array<{file: string, reason: string}>, malformedLines: number, ok: boolean}>}
|
|
338
|
+
*/
|
|
339
|
+
export async function planRuleExpirySweep(opts = {}) {
|
|
340
|
+
const { repoRoot, now, graceDays = 0, learningsPath } = opts;
|
|
341
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
342
|
+
throw new TypeError('planRuleExpirySweep: repoRoot is required');
|
|
343
|
+
}
|
|
344
|
+
if (!Number.isFinite(graceDays) || graceDays < 0) {
|
|
345
|
+
throw new TypeError(`planRuleExpirySweep: graceDays must be a non-negative number, got ${graceDays}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const nowMs = toEpochMs(now);
|
|
349
|
+
const cutoffMs = nowMs - graceDays * MS_PER_DAY;
|
|
350
|
+
const { expiryById, malformedLines } = await loadExpiryIndex(
|
|
351
|
+
join(repoRoot, learningsPath ?? DEFAULT_LEARNINGS_PATH),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const listed = listMachineGeneratedRules({ repoRoot });
|
|
355
|
+
const rulesDir = join(repoRoot, '.claude', 'rules');
|
|
356
|
+
/** @type {Array<object>} */
|
|
357
|
+
const plans = [];
|
|
358
|
+
/** @type {Array<{file: string, reason: string}>} */
|
|
359
|
+
const skipped = [];
|
|
360
|
+
|
|
361
|
+
for (const rule of listed.rules) {
|
|
362
|
+
const abs = join(rulesDir, rule.file);
|
|
363
|
+
let content;
|
|
364
|
+
try {
|
|
365
|
+
content = readFileSync(abs, 'utf8');
|
|
366
|
+
} catch {
|
|
367
|
+
skipped.push({ file: rule.file, reason: 'unreadable' });
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const parsed = parseConsolidatedRule(content);
|
|
372
|
+
const substantive = parsed.pairs.filter((p) => !p.markersOnly);
|
|
373
|
+
|
|
374
|
+
if (parsed.provenanceLine === -1 || parsed.pairs.length === 0) {
|
|
375
|
+
// Not a consolidated file (or no provenance block at all): this sweep has
|
|
376
|
+
// no way to date its content, so it is out of its own remit.
|
|
377
|
+
skipped.push({ file: rule.file, reason: 'no-provenance-block' });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// The header advisory is computed HERE — over every RESOLVABLE pair on the
|
|
382
|
+
// file, before any early `continue`. It has to be: the discrepancy it
|
|
383
|
+
// reports needs no entry↔pair mapping (it compares the frontmatter date
|
|
384
|
+
// against dates read out of `learnings.jsonl` by id), and while it sat
|
|
385
|
+
// below the `no-1to1-mapping` skip the four merged-prose files — the ones
|
|
386
|
+
// most likely to carry a stale header, `test-hygiene.md` among them — could
|
|
387
|
+
// never receive one. Measured 2026-09-17: the advisory reached 3 of 7 files
|
|
388
|
+
// where 6 of 7 have the discrepancy.
|
|
389
|
+
const advisory = headerAdvisory(parsed, expiryById);
|
|
390
|
+
|
|
391
|
+
if (parsed.entries.length !== substantive.length) {
|
|
392
|
+
// FAIL-OPEN (decision 3): report, never guess. A skipped file still
|
|
393
|
+
// carries its advisory — reporting is all this branch can do.
|
|
394
|
+
skipped.push({ file: rule.file, reason: 'no-1to1-mapping' });
|
|
395
|
+
plans.push({
|
|
396
|
+
file: rule.file,
|
|
397
|
+
action: 'keep',
|
|
398
|
+
expiredPairIds: [],
|
|
399
|
+
keptPairIds: substantive.map((p) => p.id),
|
|
400
|
+
unresolvedPairIds: substantive.filter((p) => !expiryById.has(p.id)).map((p) => p.id),
|
|
401
|
+
newExpiresAt: null,
|
|
402
|
+
newAbsorbedCount: parsed.pairs.length,
|
|
403
|
+
bytesBefore: Buffer.byteLength(content, 'utf8'),
|
|
404
|
+
bytesAfter: Buffer.byteLength(content, 'utf8'),
|
|
405
|
+
headings: parsed.entries.length,
|
|
406
|
+
substantivePairs: substantive.length,
|
|
407
|
+
...(advisory ? { advisory } : {}),
|
|
408
|
+
});
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** @type {Array<{pair: object, entry: object}>} */
|
|
413
|
+
const expired = [];
|
|
414
|
+
const kept = [];
|
|
415
|
+
const unresolved = [];
|
|
416
|
+
substantive.forEach((pair, idx) => {
|
|
417
|
+
const entry = parsed.entries[idx];
|
|
418
|
+
const raw = expiryById.get(pair.id);
|
|
419
|
+
const at = raw !== undefined ? Date.parse(raw) : Number.NaN;
|
|
420
|
+
if (!Number.isFinite(at)) {
|
|
421
|
+
unresolved.push({ pair, entry }); // FAIL-OPEN (decision 2)
|
|
422
|
+
} else if (at < cutoffMs) {
|
|
423
|
+
expired.push({ pair, entry, expiresAt: raw });
|
|
424
|
+
} else {
|
|
425
|
+
kept.push({ pair, entry, expiresAt: raw });
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// D: earliest date over pairs that remain AND are not expired. Markers-only
|
|
430
|
+
// pairs are excluded here even though they count toward N — an already-swept
|
|
431
|
+
// pair's elapsed date would re-expire the file the moment it was rewritten.
|
|
432
|
+
const remainingDates = kept.map((k) => k.expiresAt).filter(Boolean).sort();
|
|
433
|
+
const earliest = remainingDates.length > 0 ? dateOnly(remainingDates[0]) : null;
|
|
434
|
+
|
|
435
|
+
const base = {
|
|
436
|
+
file: rule.file,
|
|
437
|
+
expiredPairIds: expired.map((e) => e.pair.id),
|
|
438
|
+
keptPairIds: kept.map((k) => k.pair.id),
|
|
439
|
+
unresolvedPairIds: unresolved.map((u) => u.pair.id),
|
|
440
|
+
bytesBefore: Buffer.byteLength(content, 'utf8'),
|
|
441
|
+
headings: parsed.entries.length,
|
|
442
|
+
substantivePairs: substantive.length,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
if (expired.length === 0) {
|
|
446
|
+
// Byte-identical. The header/earliest discrepancy is REPORTED, never
|
|
447
|
+
// silently repaired — see the module header.
|
|
448
|
+
plans.push({
|
|
449
|
+
...base,
|
|
450
|
+
action: 'keep',
|
|
451
|
+
newExpiresAt: null,
|
|
452
|
+
newAbsorbedCount: parsed.pairs.length,
|
|
453
|
+
bytesAfter: base.bytesBefore,
|
|
454
|
+
...(advisory ? { advisory } : {}),
|
|
455
|
+
});
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (kept.length === 0 && unresolved.length === 0) {
|
|
460
|
+
plans.push({
|
|
461
|
+
...base,
|
|
462
|
+
action: 'delete',
|
|
463
|
+
newExpiresAt: null,
|
|
464
|
+
newAbsorbedCount: 0,
|
|
465
|
+
bytesAfter: 0,
|
|
466
|
+
// EVERY pair on the file, not only the substantive ones — the delete
|
|
467
|
+
// removes markers-only pairs too, and each is a dedupe marker.
|
|
468
|
+
stampKeys: parsed.pairs.map((p) => p.key),
|
|
469
|
+
});
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const nextContent = rewriteContent({ parsed, expired, earliest, nowMs });
|
|
474
|
+
plans.push({
|
|
475
|
+
...base,
|
|
476
|
+
action: 'rewrite',
|
|
477
|
+
newExpiresAt: earliest,
|
|
478
|
+
newAbsorbedCount: parsed.pairs.length,
|
|
479
|
+
bytesAfter: Buffer.byteLength(nextContent, 'utf8'),
|
|
480
|
+
nextContent,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return { plans, skipped, malformedLines, ok: listed.ok };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Build the rewritten file contents: expired prose removed, expired pairs
|
|
489
|
+
* converted to `markers only`, header + counter sentence recomputed.
|
|
490
|
+
*
|
|
491
|
+
* @param {{parsed: object, expired: Array<object>, earliest: string|null, nowMs: number}} args
|
|
492
|
+
* @returns {string}
|
|
493
|
+
*/
|
|
494
|
+
function rewriteContent({ parsed, expired, earliest, nowMs }) {
|
|
495
|
+
const lines = [...parsed.lines];
|
|
496
|
+
const sweptOn = new Date(nowMs).toISOString().slice(0, 10);
|
|
497
|
+
|
|
498
|
+
// Annotate the pairs FIRST (line indices are still the parsed ones), then
|
|
499
|
+
// splice prose out back-to-front so earlier indices stay valid.
|
|
500
|
+
for (const { pair, expiresAt } of expired) {
|
|
501
|
+
lines[pair.idLine] =
|
|
502
|
+
`${lines[pair.idLine]} <!-- markers only (substance: expired ${dateOnly(expiresAt)}, prose swept ${sweptOn}) -->`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (earliest !== null) {
|
|
506
|
+
if (parsed.expiresAtLine >= 0) lines[parsed.expiresAtLine] = `expires-at: ${earliest}`;
|
|
507
|
+
if (parsed.counterLine >= 0) {
|
|
508
|
+
lines[parsed.counterLine] = lines[parsed.counterLine].replace(
|
|
509
|
+
COUNTER_RE,
|
|
510
|
+
`**\`expires-at\` ${earliest} = the EARLIEST of the ${parsed.pairs.length} absorbed dates**`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const blocks = expired.map((e) => e.entry).sort((a, b) => b.start - a.start);
|
|
516
|
+
for (const entry of blocks) lines.splice(entry.start, entry.end - entry.start);
|
|
517
|
+
|
|
518
|
+
return lines.join('\n');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Refuse to write to (or unlink) anything that is not a regular file sitting
|
|
523
|
+
* directly under the rules directory. Throws; the caller turns that into a
|
|
524
|
+
* per-file `errors[]` entry and moves on to the next plan.
|
|
525
|
+
*
|
|
526
|
+
* Two independent checks, because they fail differently:
|
|
527
|
+
*
|
|
528
|
+
* 1. **`lstatSync` symlink check.** `writeFileSync` FOLLOWS a symlink and
|
|
529
|
+
* rewrites its TARGET, leaving the link intact — so a symlink tracked at
|
|
530
|
+
* `.claude/rules/<n>.md` pointing anywhere on the host turns this sweep
|
|
531
|
+
* into an arbitrary-file writer whose victim's `sha` changes with nothing
|
|
532
|
+
* in the rules directory to show for it. The atomic writer above is not a
|
|
533
|
+
* fix for that: `renameSync` would replace the LINK, silently discarding an
|
|
534
|
+
* operator's deliberate indirection. Same precedent as
|
|
535
|
+
* `.claude/rules/guard-design.md` § "a security fix that follows an
|
|
536
|
+
* unreviewed security fix" (*"`writeFileSync` followed symlinks"*).
|
|
537
|
+
* 2. **Path confinement.** `p.file` arrives from
|
|
538
|
+
* `listMachineGeneratedRules()` today, but this function is exported and a
|
|
539
|
+
* hand-built plan carrying `../../victim.md` would `join()` cleanly out of
|
|
540
|
+
* the directory. `resolve()`-then-prefix is the check that survives a
|
|
541
|
+
* future second caller.
|
|
542
|
+
*
|
|
543
|
+
* @param {string} abs joined absolute path
|
|
544
|
+
* @param {string} rulesDir the `.claude/rules` directory
|
|
545
|
+
* @param {string} file plan-relative name, for the message
|
|
546
|
+
* @returns {void}
|
|
547
|
+
*/
|
|
548
|
+
function assertSweepable(abs, rulesDir, file) {
|
|
549
|
+
const confined = `${resolve(rulesDir)}${sep}`;
|
|
550
|
+
const resolved = resolve(abs);
|
|
551
|
+
if (!resolved.startsWith(confined)) {
|
|
552
|
+
throw new Error(`refusing to touch ${file}: resolves outside ${rulesDir}`);
|
|
553
|
+
}
|
|
554
|
+
let stat;
|
|
555
|
+
try {
|
|
556
|
+
stat = lstatSync(abs);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
throw new Error(`refusing to touch ${file}: ${err?.message ?? String(err)}`, { cause: err });
|
|
559
|
+
}
|
|
560
|
+
if (stat.isSymbolicLink()) {
|
|
561
|
+
throw new Error(
|
|
562
|
+
`refusing to touch ${file}: it is a symlink — a write would follow it to its target`,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
if (!stat.isFile()) {
|
|
566
|
+
throw new Error(`refusing to touch ${file}: not a regular file`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Execute a plan. THE ONLY WRITE PATH.
|
|
572
|
+
*
|
|
573
|
+
* Ordering is load-bearing for `action: 'delete'`: every provenance pair is
|
|
574
|
+
* stamped terminal via `markCandidateProcessed` BEFORE the unlink. A crash
|
|
575
|
+
* between the two leaves a stamped candidate and a live file (harmless — the
|
|
576
|
+
* file still dedupes) rather than an unstamped learning with no file, which
|
|
577
|
+
* `/reconcile` would re-propose.
|
|
578
|
+
*
|
|
579
|
+
* Every rewrite and every delete passes {@link assertSweepable} first (regular
|
|
580
|
+
* file, inside the rules directory, not a symlink), and every rewrite goes
|
|
581
|
+
* through `atomicWriteWithBackup` rather than `writeFileSync`. A refusal is a
|
|
582
|
+
* per-file `errors[]` entry and never aborts the remaining plans — but it DOES
|
|
583
|
+
* make the CLI exit 2, so a refused write cannot pass as a clean sweep.
|
|
584
|
+
*
|
|
585
|
+
* @param {{plans: Array<object>}} plan the {@link planRuleExpirySweep} result
|
|
586
|
+
* @param {{repoRoot: string, now?: Date|number|string}} ctx
|
|
587
|
+
* @returns {{rewritten: string[], deleted: string[], stamped: number, errors: Array<{file: string, error: string}>}}
|
|
588
|
+
*/
|
|
589
|
+
export function applyRuleExpirySweep(plan, ctx = {}) {
|
|
590
|
+
const { repoRoot, now } = ctx;
|
|
591
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
592
|
+
throw new TypeError('applyRuleExpirySweep: repoRoot is required');
|
|
593
|
+
}
|
|
594
|
+
const plans = Array.isArray(plan?.plans) ? plan.plans : [];
|
|
595
|
+
const rulesDir = join(repoRoot, '.claude', 'rules');
|
|
596
|
+
const processedAt = new Date(toEpochMs(now)).toISOString();
|
|
597
|
+
|
|
598
|
+
const rewritten = [];
|
|
599
|
+
const deleted = [];
|
|
600
|
+
const errors = [];
|
|
601
|
+
let stamped = 0;
|
|
602
|
+
|
|
603
|
+
for (const p of plans) {
|
|
604
|
+
const abs = join(rulesDir, p.file);
|
|
605
|
+
try {
|
|
606
|
+
if (p.action === 'rewrite' || p.action === 'delete') assertSweepable(abs, rulesDir, p.file);
|
|
607
|
+
if (p.action === 'rewrite') {
|
|
608
|
+
if (typeof p.nextContent !== 'string') {
|
|
609
|
+
throw new Error('plan carries action "rewrite" but no nextContent');
|
|
610
|
+
}
|
|
611
|
+
// Atomic tmp+rename, never a bare writeFileSync: a crash mid-write
|
|
612
|
+
// would leave a half-written RULE FILE tracked in git, and a torn
|
|
613
|
+
// `## Provenance` block is a silently re-proposing corpus.
|
|
614
|
+
const res = atomicWriteWithBackup(abs, p.nextContent, {
|
|
615
|
+
tmpPrefix: '.rule-expiry-sweep',
|
|
616
|
+
});
|
|
617
|
+
if (!res.ok) throw new Error(res.error);
|
|
618
|
+
rewritten.push(p.file);
|
|
619
|
+
} else if (p.action === 'delete') {
|
|
620
|
+
for (const key of p.stampKeys ?? []) {
|
|
621
|
+
const res = markCandidateProcessed({
|
|
622
|
+
learningKey: key,
|
|
623
|
+
outcome: SWEPT_OUTCOME,
|
|
624
|
+
processedAt,
|
|
625
|
+
fallbackSlug: p.file.replace(/\.md$/, ''),
|
|
626
|
+
repoRoot,
|
|
627
|
+
});
|
|
628
|
+
if (!res.written) {
|
|
629
|
+
throw new Error(`stamp failed for learning-key ${key} — refusing to delete ${p.file}`);
|
|
630
|
+
}
|
|
631
|
+
stamped += 1;
|
|
632
|
+
}
|
|
633
|
+
unlinkSync(abs);
|
|
634
|
+
deleted.push(p.file);
|
|
635
|
+
}
|
|
636
|
+
} catch (err) {
|
|
637
|
+
errors.push({ file: p.file, error: err?.message ?? String(err) });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return { rewritten, deleted, stamped, errors };
|
|
642
|
+
}
|
|
@@ -10,6 +10,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
10
10
|
import { join, basename, dirname, resolve } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { validateRuleContent, scanVendoringLeaks } from './validate-vendored-rules.mjs';
|
|
13
|
+
import { isMainModule } from './is-main-module.mjs';
|
|
13
14
|
|
|
14
15
|
// The vendoring sanitizer (issue #1098) lives in validate-vendored-rules.mjs —
|
|
15
16
|
// it has that module's shape ("judge one rule file → findings") and its
|
|
@@ -451,11 +452,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
451
452
|
const __dirname = dirname(__filename);
|
|
452
453
|
|
|
453
454
|
// Detect direct execution: node scripts/lib/rules-sync.mjs
|
|
454
|
-
const isMain =
|
|
455
|
-
typeof process !== 'undefined' &&
|
|
456
|
-
process.argv[1] !== null &&
|
|
457
|
-
process.argv[1] !== undefined &&
|
|
458
|
-
resolve(process.argv[1]) === resolve(__filename);
|
|
455
|
+
const isMain =isMainModule(import.meta.url);
|
|
459
456
|
|
|
460
457
|
if (isMain) {
|
|
461
458
|
const args = process.argv.slice(2);
|