session-orchestrator 5.1.0 → 5.3.0

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