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.
Files changed (298) hide show
  1. package/.agents/skills/autopilot/SKILL.md +1 -0
  2. package/.agents/skills/bootstrap/SKILL.md +2 -0
  3. package/.agents/skills/brainstorm/SKILL.md +3 -0
  4. package/.agents/skills/close/SKILL.md +17 -0
  5. package/.agents/skills/debug/SKILL.md +2 -0
  6. package/.agents/skills/discovery/SKILL.md +2 -1
  7. package/.agents/skills/dispatcher/SKILL.md +2 -0
  8. package/.agents/skills/eli5/SKILL.md +2 -0
  9. package/.agents/skills/eval/SKILL.md +1 -0
  10. package/.agents/skills/evolve/SKILL.md +2 -1
  11. package/.agents/skills/go/SKILL.md +18 -0
  12. package/.agents/skills/grill/SKILL.md +2 -0
  13. package/.agents/skills/harness-audit/SKILL.md +16 -0
  14. package/.agents/skills/memory-cleanup/SKILL.md +1 -0
  15. package/.agents/skills/persona-panel/SKILL.md +1 -0
  16. package/.agents/skills/plan/SKILL.md +3 -1
  17. package/.agents/skills/portfolio/SKILL.md +17 -0
  18. package/.agents/skills/reconcile/SKILL.md +1 -0
  19. package/.agents/skills/release/SKILL.md +18 -0
  20. package/.agents/skills/repo-audit/SKILL.md +1 -0
  21. package/.agents/skills/spinout/SKILL.md +1 -0
  22. package/.agents/skills/sunset-review/SKILL.md +2 -0
  23. package/.agents/skills/test/SKILL.md +17 -0
  24. package/.agents/skills/ux-grill/SKILL.md +2 -0
  25. package/.claude-plugin/marketplace.json +3 -3
  26. package/.claude-plugin/plugin.json +2 -2
  27. package/.codex-plugin/plugin.json +2 -2
  28. package/.codex-plugin/skills/autopilot/SKILL.md +5 -4
  29. package/.codex-plugin/skills/bootstrap/SKILL.md +8 -4
  30. package/.codex-plugin/skills/brainstorm/SKILL.md +11 -4
  31. package/.codex-plugin/skills/close/SKILL.md +3 -3
  32. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +2 -0
  33. package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +5 -0
  34. package/.codex-plugin/skills/debug/SKILL.md +11 -4
  35. package/.codex-plugin/skills/discovery/SKILL.md +8 -4
  36. package/.codex-plugin/skills/dispatcher/SKILL.md +4 -4
  37. package/.codex-plugin/skills/eli5/SKILL.md +9 -4
  38. package/.codex-plugin/skills/eval/SKILL.md +9 -4
  39. package/.codex-plugin/skills/evolve/SKILL.md +9 -4
  40. package/.codex-plugin/skills/go/SKILL.md +3 -3
  41. package/.codex-plugin/skills/grill/SKILL.md +11 -4
  42. package/.codex-plugin/skills/harness-audit/SKILL.md +4 -3
  43. package/.codex-plugin/skills/memory-cleanup/SKILL.md +9 -4
  44. package/.codex-plugin/skills/npm-publish/SKILL.md +2 -0
  45. package/.codex-plugin/skills/npm-publish/agents/openai.yaml +5 -0
  46. package/.codex-plugin/skills/persona-panel/SKILL.md +5 -5
  47. package/.codex-plugin/skills/plan/SKILL.md +8 -4
  48. package/.codex-plugin/skills/portfolio/SKILL.md +3 -3
  49. package/.codex-plugin/skills/reconcile/SKILL.md +9 -4
  50. package/.codex-plugin/skills/release/SKILL.md +3 -3
  51. package/.codex-plugin/skills/repo-audit/SKILL.md +6 -4
  52. package/.codex-plugin/skills/spinout/SKILL.md +4 -4
  53. package/.codex-plugin/skills/sunset-review/SKILL.md +5 -4
  54. package/.codex-plugin/skills/test/SKILL.md +3 -3
  55. package/.codex-plugin/skills/ux-grill/SKILL.md +11 -4
  56. package/.cursor/commands/autopilot.md +4 -4
  57. package/.cursor/commands/bootstrap.md +5 -4
  58. package/.cursor/commands/brainstorm.md +5 -4
  59. package/.cursor/commands/close.md +4 -3
  60. package/.cursor/commands/convergence-monitoring.md +13 -0
  61. package/.cursor/commands/debug.md +4 -4
  62. package/.cursor/commands/discovery.md +4 -4
  63. package/.cursor/commands/dispatcher.md +4 -4
  64. package/.cursor/commands/eli5.md +4 -4
  65. package/.cursor/commands/eval.md +4 -4
  66. package/.cursor/commands/evolve.md +4 -4
  67. package/.cursor/commands/go.md +4 -3
  68. package/.cursor/commands/grill.md +4 -4
  69. package/.cursor/commands/harness-audit.md +3 -3
  70. package/.cursor/commands/memory-cleanup.md +4 -4
  71. package/.cursor/commands/npm-publish.md +13 -0
  72. package/.cursor/commands/persona-panel.md +4 -4
  73. package/.cursor/commands/plan.md +5 -4
  74. package/.cursor/commands/portfolio.md +3 -3
  75. package/.cursor/commands/reconcile.md +4 -4
  76. package/.cursor/commands/release.md +4 -3
  77. package/.cursor/commands/repo-audit.md +4 -4
  78. package/.cursor/commands/spinout.md +4 -4
  79. package/.cursor/commands/sunset-review.md +4 -4
  80. package/.cursor/commands/test.md +3 -3
  81. package/.cursor/commands/ux-grill.md +4 -4
  82. package/.cursor/rules/010-session-workflow.mdc +2 -2
  83. package/.cursor/skills/bootstrap/SKILL.md +1 -0
  84. package/.cursor/skills/close/SKILL.md +13 -0
  85. package/.cursor/skills/debug/SKILL.md +0 -1
  86. package/.cursor/skills/discovery/SKILL.md +0 -1
  87. package/.cursor/skills/dispatcher/SKILL.md +0 -1
  88. package/.cursor/skills/eli5/SKILL.md +0 -1
  89. package/.cursor/skills/evolve/SKILL.md +0 -1
  90. package/.cursor/skills/go/SKILL.md +13 -0
  91. package/.cursor/skills/grill/SKILL.md +0 -1
  92. package/.cursor/skills/harness-audit/SKILL.md +12 -0
  93. package/.cursor/skills/portfolio/SKILL.md +12 -0
  94. package/.cursor/skills/release/SKILL.md +13 -0
  95. package/.cursor/skills/repo-audit/SKILL.md +0 -1
  96. package/.cursor/skills/sunset-review/SKILL.md +0 -1
  97. package/.cursor/skills/test/SKILL.md +12 -0
  98. package/.cursor/skills/ux-grill/SKILL.md +0 -1
  99. package/.cursor-plugin/plugin.json +2 -2
  100. package/.orchestrator/policy/blocked-commands.json +10 -0
  101. package/AGENTS.md +1 -1
  102. package/CHANGELOG.md +80 -0
  103. package/README.md +74 -235
  104. package/commands/session.md +10 -0
  105. package/docs/USER-GUIDE.md +24 -0
  106. package/docs/ci-setup.md +53 -0
  107. package/docs/codex-setup.md +1 -1
  108. package/docs/components.md +12 -5
  109. package/docs/events-schema.md +5 -1
  110. package/docs/install.md +128 -0
  111. package/docs/persona-panel.md +1 -1
  112. package/docs/pi-setup.md +1 -1
  113. package/docs/rule-authoring.md +83 -14
  114. package/docs/scope-collision-guard.md +2 -0
  115. package/docs/session-config-reference.md +6 -4
  116. package/docs/session-config-template.md +38 -0
  117. package/docs/telemetry.md +15 -0
  118. package/hooks/_lib/hook-import-set.json +46 -6
  119. package/hooks/_lib/subagent-paths.mjs +15 -0
  120. package/hooks/_lib/vcs-create-matcher.mjs +217 -62
  121. package/hooks/enforce-scope.mjs +42 -1
  122. package/hooks/hooks-codex.json +1 -1
  123. package/hooks/hooks.json +1 -1
  124. package/hooks/on-session-end.mjs +14 -2
  125. package/hooks/on-stop.mjs +43 -1
  126. package/hooks/post-bash-write-verify.mjs +3 -0
  127. package/hooks/pre-auq-clarity.mjs +3 -0
  128. package/hooks/pre-bash-issue-budget.mjs +103 -17
  129. package/hooks/pre-task-scope-disjoint.mjs +152 -3
  130. package/hooks/skill-invocation-telemetry.mjs +2 -1
  131. package/package.json +3 -2
  132. package/pi/prompts/autopilot.md +3 -3
  133. package/pi/prompts/bootstrap.md +3 -3
  134. package/pi/prompts/brainstorm.md +3 -3
  135. package/pi/prompts/close.md +2 -2
  136. package/pi/prompts/convergence-monitoring.md +11 -0
  137. package/pi/prompts/debug.md +3 -3
  138. package/pi/prompts/discovery.md +3 -3
  139. package/pi/prompts/dispatcher.md +3 -3
  140. package/pi/prompts/eli5.md +3 -3
  141. package/pi/prompts/eval.md +3 -3
  142. package/pi/prompts/evolve.md +3 -3
  143. package/pi/prompts/go.md +2 -2
  144. package/pi/prompts/grill.md +3 -3
  145. package/pi/prompts/harness-audit.md +2 -3
  146. package/pi/prompts/memory-cleanup.md +3 -3
  147. package/pi/prompts/npm-publish.md +11 -0
  148. package/pi/prompts/persona-panel.md +3 -3
  149. package/pi/prompts/plan.md +3 -3
  150. package/pi/prompts/portfolio.md +2 -2
  151. package/pi/prompts/reconcile.md +3 -3
  152. package/pi/prompts/release.md +3 -3
  153. package/pi/prompts/repo-audit.md +3 -4
  154. package/pi/prompts/session.md +1 -1
  155. package/pi/prompts/spinout.md +3 -3
  156. package/pi/prompts/sunset-review.md +3 -3
  157. package/pi/prompts/templates-ack.md +1 -1
  158. package/pi/prompts/test.md +3 -3
  159. package/pi/prompts/ux-grill.md +3 -3
  160. package/scripts/archive-closed-prds.mjs +2 -2
  161. package/scripts/auq-audit.mjs +2 -3
  162. package/scripts/backfill-abandoned-sessions.mjs +57 -3
  163. package/scripts/backfill-evidence-digest.mjs +2 -1
  164. package/scripts/backfill-learnings-from-vault.mjs +2 -2
  165. package/scripts/check-package-manager.mjs +2 -2
  166. package/scripts/ci/assert-vitest-green.mjs +2 -1
  167. package/scripts/emit-session.mjs +2 -3
  168. package/scripts/export-hw-learnings.mjs +2 -1
  169. package/scripts/express-path.mjs +1 -1
  170. package/scripts/gc-stale-worktrees.mjs +2 -1
  171. package/scripts/generate-codex-skills.mjs +48 -4
  172. package/scripts/generate-cursor-adapter.mjs +173 -9
  173. package/scripts/generate-hook-import-set.mjs +12 -27
  174. package/scripts/generate-pi-prompts.mjs +183 -13
  175. package/scripts/github-protection-audit.mjs +2 -3
  176. package/scripts/lib/agent-frontmatter.mjs +23 -1
  177. package/scripts/lib/claude-md-budget-lint.mjs +2 -5
  178. package/scripts/lib/command-blocker.mjs +209 -9
  179. package/scripts/lib/config/drift-check.mjs +19 -0
  180. package/scripts/lib/convergence-monitor.mjs +2 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +2 -2
  182. package/scripts/lib/description-surface.mjs +2 -5
  183. package/scripts/lib/dispatcher/cli.mjs +2 -1
  184. package/scripts/lib/ecosystem-wizard.mjs +2 -1
  185. package/scripts/lib/fetch-baseline.mjs +3 -8
  186. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +2 -1
  187. package/scripts/lib/gitlab-portfolio/cli.mjs +2 -1
  188. package/scripts/lib/instruction-budget-guard.mjs +186 -46
  189. package/scripts/lib/is-main-module.mjs +82 -0
  190. package/scripts/lib/locks/index.mjs +32 -25
  191. package/scripts/lib/maintenance-due-banner.mjs +69 -3
  192. package/scripts/lib/peer-discovery.mjs +2 -5
  193. package/scripts/lib/playwright-driver/runner.mjs +63 -2
  194. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +642 -0
  195. package/scripts/lib/rules-sync.mjs +2 -5
  196. package/scripts/lib/scope-echo.mjs +392 -7
  197. package/scripts/lib/session-close-backfill.mjs +58 -6
  198. package/scripts/lib/state-md.mjs +84 -3
  199. package/scripts/lib/sunset/walker.mjs +31 -4
  200. package/scripts/lib/tests-src-ratio.mjs +2 -6
  201. package/scripts/lib/tmux-layout/telemetry-stats.mjs +2 -1
  202. package/scripts/lib/user-invocable-skills.mjs +185 -0
  203. package/scripts/lib/validate/check-banner-parity.mjs +2 -2
  204. package/scripts/lib/validate/check-cursor-adapter.mjs +2 -2
  205. package/scripts/lib/validate/check-dead-bridge.mjs +2 -2
  206. package/scripts/lib/validate/check-doc-cli-commands.mjs +2 -2
  207. package/scripts/lib/validate/check-entry-guard.mjs +366 -0
  208. package/scripts/lib/validate/check-guard-requires-parity.mjs +2 -2
  209. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +2 -2
  210. package/scripts/lib/validate/check-learning-provenance.mjs +2 -2
  211. package/scripts/lib/validate/check-skill-links.mjs +27 -6
  212. package/scripts/lib/validate/check-skill-script-paths.mjs +2 -2
  213. package/scripts/lib/validate/check-test-git-config-target.mjs +2 -2
  214. package/scripts/lib/validate/check-unicode-safety.mjs +2 -2
  215. package/scripts/lib/validate/check-untracked-test-deps.mjs +2 -2
  216. package/scripts/lib/validate/check-unwired-features.mjs +266 -11
  217. package/scripts/lib/validate/check-validator-registration.mjs +2 -2
  218. package/scripts/lib/validate/check-vcs-repo-flag.mjs +2 -2
  219. package/scripts/lib/validate-vendored-rules.mjs +35 -9
  220. package/scripts/lib/wave-transcript-tail.mjs +2 -2
  221. package/scripts/lock-reaper.mjs +2 -1
  222. package/scripts/materialize-wave-scope.mjs +87 -4
  223. package/scripts/migrate-sessions-jsonl.mjs +2 -1
  224. package/scripts/migrate-vault-paths.mjs +2 -3
  225. package/scripts/release.mjs +124 -35
  226. package/scripts/relocate-vault-corpus.mjs +2 -3
  227. package/scripts/repair-invalid-sessions.mjs +2 -2
  228. package/scripts/session-shape.mjs +2 -2
  229. package/scripts/site-numbers.mjs +35 -11
  230. package/scripts/sweep-expired-rules.mjs +216 -0
  231. package/scripts/validate-plugin.mjs +9 -0
  232. package/scripts/vault-consolidate.mjs +2 -2
  233. package/scripts/vault-mirror.mjs +2 -3
  234. package/scripts/wave-scope-binding.mjs +2 -3
  235. package/skills/_shared/bootstrap-gate.md +1 -1
  236. package/skills/_shared/monitor-patterns.md +1 -1
  237. package/skills/_shared/research-evidence.md +53 -0
  238. package/skills/_shared/state-ownership.md +3 -0
  239. package/skills/autopilot/SKILL.md +58 -4
  240. package/skills/bootstrap/SKILL.md +51 -1
  241. package/skills/brainstorm/SKILL.md +16 -0
  242. package/skills/claude-md-drift-check/checker.mjs +49 -11
  243. package/{commands/close.md → skills/close/SKILL.md} +9 -3
  244. package/skills/debug/SKILL.md +10 -0
  245. package/skills/discovery/SKILL.md +24 -1
  246. package/skills/discovery/probes-session.md +2 -2
  247. package/skills/dispatcher/SKILL.md +38 -7
  248. package/skills/eli5/SKILL.md +11 -0
  249. package/skills/eval/SKILL.md +14 -0
  250. package/skills/evolve/SKILL.md +8 -1
  251. package/skills/evolve/references/evolve-dialectic-mode.md +6 -2
  252. package/{commands/go.md → skills/go/SKILL.md} +9 -1
  253. package/skills/grill/SKILL.md +19 -0
  254. package/{commands/harness-audit.md → skills/harness-audit/SKILL.md} +7 -2
  255. package/skills/hook-development/SKILL.md +46 -41
  256. package/skills/memory-cleanup/SKILL.md +7 -0
  257. package/skills/npm-publish/SKILL.md +1 -1
  258. package/skills/persona-panel/SKILL.md +56 -1
  259. package/skills/persona-panel/persona-format.md +1 -1
  260. package/skills/plan/SKILL.md +28 -1
  261. package/skills/playwright-driver/SKILL.md +7 -10
  262. package/{commands/portfolio.md → skills/portfolio/SKILL.md} +8 -2
  263. package/skills/reconcile/SKILL.md +10 -0
  264. package/{commands/release.md → skills/release/SKILL.md} +16 -2
  265. package/skills/repo-audit/SKILL.md +7 -0
  266. package/skills/session-end/plan-verification.md +2 -2
  267. package/skills/session-plan/SKILL.md +1 -1
  268. package/skills/session-start/SKILL.md +5 -4
  269. package/skills/session-start/phase-8-5-express-path.md +6 -6
  270. package/skills/session-start/references/phase-1-5-session-continuity.md +1 -1
  271. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +1 -1
  272. package/skills/session-start/references/phase-4-ssot-environment-check.md +4 -3
  273. package/skills/spinout/SKILL.md +12 -1
  274. package/skills/sunset-review/SKILL.md +13 -0
  275. package/{commands/test.md → skills/test/SKILL.md} +10 -4
  276. package/skills/ux-grill/SKILL.md +19 -1
  277. package/skills/wave-executor/SKILL.md +7 -4
  278. package/skills/wave-executor/references/wave-executor-state-init.md +13 -1
  279. package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
  280. package/skills/wave-executor/references/wave-loop-review.md +17 -1
  281. package/commands/autopilot.md +0 -80
  282. package/commands/bootstrap.md +0 -56
  283. package/commands/brainstorm.md +0 -48
  284. package/commands/debug.md +0 -36
  285. package/commands/discovery.md +0 -32
  286. package/commands/dispatcher.md +0 -59
  287. package/commands/eli5.md +0 -33
  288. package/commands/eval.md +0 -28
  289. package/commands/evolve.md +0 -10
  290. package/commands/grill.md +0 -45
  291. package/commands/memory-cleanup.md +0 -26
  292. package/commands/persona-panel.md +0 -121
  293. package/commands/plan.md +0 -15
  294. package/commands/reconcile.md +0 -23
  295. package/commands/repo-audit.md +0 -24
  296. package/commands/spinout.md +0 -15
  297. package/commands/sunset-review.md +0 -27
  298. 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);