diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..f754c0c --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,26 @@ +{ + "name": "codebase-cleanup", + "owner": { + "name": "CRangelP", + "url": "https://github.com/CRangelP" + }, + "metadata": { + "description": "Cleanup tooling for Claude Code." + }, + "plugins": [ + { + "name": "codebase-cleanup", + "source": ".", + "category": "code-quality", + "description": "Three-phase codebase cleanup with a green gate before every commit.", + "keywords": [ + "cleanup", + "dead-code", + "duplication", + "refactor", + "knip", + "tech-debt" + ] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..fbbe515 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "codebase-cleanup", + "version": "0.1.0", + "description": "Three-phase codebase cleanup: dead code, module consolidation and folder structure. Dedicated branch, atomic commits per category, a green gate before every one of them.", + "author": { "name": "CRangelP" }, + "homepage": "https://github.com/CRangelP/codebase-cleanup", + "repository": "https://github.com/CRangelP/codebase-cleanup", + "license": "MIT", + "keywords": ["cleanup", "dead-code", "duplication", "refactor", "knip", "tech-debt"] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dae9efa..3bd9f52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ -# The three suites on both target platforms. Linux exercises the GNU timeout +# The four suites on both target platforms. Linux exercises the GNU timeout # backend (with -k) and procps; macOS proves bash 3.2 compatibility with the -# stock /bin/bash — never the Homebrew bash the runner also ships. +# stock /bin/bash — never the Homebrew bash the runner also ships. The guard +# suite runs alongside them: it builds its own throwaway repositories, so it +# needs nothing from the runner beyond bash, git and perl. name: ci on: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..14dc514 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), +versionamento em [SemVer](https://semver.org/lang/pt-BR/). + +A versão aqui e a de `.claude-plugin/plugin.json` são a mesma string, e +`scripts/coherence_test.sh` reprova quando deixam de ser. Não é zelo: a versão +do manifesto é a chave de cache que decide se uma instalação enxerga +atualização, e esquecer o bump falha em silêncio dos dois lados — ninguém +recebe erro, a correção só nunca chega. + +## [0.1.0] — 2026-08-09 + +Primeira versão publicada como plugin. A skill continua sendo uma pasta que +você pode copiar; o que muda é que agora ela também é instalável, versionada e +capaz de trazer garantias que uma skill sozinha não tem. + +### Adicionado + +- **Manifesto de plugin** (`.claude-plugin/plugin.json`). Com `SKILL.md` na + raiz, a pasta carrega como plugin de skill única; copiada para + `~/.claude/skills/`, carrega como plugin de diretório de skills. +- **Marketplace no próprio repo** (`.claude-plugin/marketplace.json`), então + `/plugin marketplace add CRangelP/codebase-cleanup` e `/plugin update` + passam a existir. Instalação por cópia continua funcionando. +- **Guardas de protocolo** (`hooks/hooks.json` → `scripts/guard.sh`). Cinco + comandos que a skill proibia em prosa passam a ser bloqueados no + `PreToolUse`: `git reset --hard`, `git clean`, `git push`, `git commit` na + `main` e staging de árvore inteira. O guarda só acorda dentro de uma run — + branch `cleanup/`, ou `CLEANUP_PROGRESS.md` não rastreado — e falha aberta + em qualquer ambiguidade. +- **Cinco subagentes declarados** (`agents/`), um por fase, com survey e + implementação separados nas fases 2 e 3. Os dois de survey declaram + `disallowedTools: Write, Edit`, o que torna o checkpoint mecânico: a + pergunta chega ao usuário antes de qualquer mudança. +- **Quarta suíte** (`scripts/guard_test.sh`), 42 casos. A metade que mais + importa é o que o guarda deixa passar: rollback canônico, staging por + pathspec, `revert`, `mv`, `stash push -u`. + +### Alterado + +- O gate é invocado por `"${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh"` em todo o + protocolo. Instalado como plugin a pasta vive num diretório de cache, e um + caminho relativo seria lido como relativo ao projeto sendo limpo — onde o + script não existe. O `:-.` mantém a instalação por cópia intacta. +- Instalado como plugin, a skill é invocada como + `/codebase-cleanup:codebase-cleanup`: skills de plugin levam sempre o nome + do plugin na frente. +- Invariantes de coerência: 151 → 248. diff --git a/README.en.md b/README.en.md index 220549b..49e5dc0 100644 --- a/README.en.md +++ b/README.en.md @@ -39,7 +39,32 @@ answer. ## Installation -The skill is a folder. Installing means copying it into the skills directory: +As a plugin, which is the recommended route — it brings versioning, updates +and the `PreToolUse` guards: + +```bash +/plugin marketplace add CRangelP/codebase-cleanup +/plugin install codebase-cleanup@codebase-cleanup +``` + +Updating later is `/plugin update codebase-cleanup@codebase-cleanup`. For a +team, declare it in the repository's `.claude/settings.json`: + +```json +{ + "extraKnownMarketplaces": { + "codebase-cleanup": { + "source": { "source": "github", "repo": "CRangelP/codebase-cleanup" } + } + }, + "enabledPlugins": { "codebase-cleanup@codebase-cleanup": true } +} +``` + +That installs nothing on anyone's machine: each person is asked once whether +they trust it and want it installed. + +Copying the folder still works, and it is still a skill: ```bash # global (applies to every project) @@ -49,8 +74,10 @@ cp -R codebase-cleanup ~/.claude/skills/ cp -R codebase-cleanup .claude/skills/ ``` -If you have the `codebase-cleanup.skill` package (a zip), unpack it straight -into the destination: +Copied that way it loads as a plugin too, because `.claude-plugin/` travels +with it — what changes is only where updates come from. If you have the +`codebase-cleanup.skill` package (a zip), unpack it straight into the +destination: ```bash unzip codebase-cleanup.skill -d ~/.claude/skills/ @@ -64,6 +91,18 @@ codebase-cleanup/ ├── README.md readme in Portuguese ├── README.en.md this file ├── LICENSE MIT +├── CHANGELOG.md what changed in each version +├── .claude-plugin/ +│ ├── plugin.json plugin manifest (name, version, license) +│ └── marketplace.json catalogue, for /plugin install +├── agents/ +│ ├── cleanup-phase-1.md phases 1 and 1.5 +│ ├── cleanup-phase-2-survey.md consolidation candidates (read-only) +│ ├── cleanup-phase-2-impl.md implements the chosen candidate +│ ├── cleanup-phase-3-survey.md structure plan (read-only) +│ └── cleanup-phase-3-impl.md executes the approved moves +├── hooks/ +│ └── hooks.json registers the guard on the PreToolUse event ├── references/ │ ├── audit.md phase 1.4 audit protocol │ ├── knip-config.md knip configuration without pitfalls @@ -73,8 +112,10 @@ codebase-cleanup/ │ └── other-stacks.md Python, Go, Rust, JVM, Ruby, .NET └── scripts/ ├── gate.sh multi-stack typecheck + tests, exit 0/1/2/3/4 - ├── test.sh runs the three suites in sequence + ├── guard.sh blocks the five commands the protocol forbids + ├── test.sh runs the four suites in sequence ├── gate_test.sh gate contract tests (toolchain stubs) + ├── guard_test.sh what the guard blocks and what it lets through ├── rollback_test.sh executable proof of the rollback protocol └── coherence_test.sh coherence invariants between docs and code ``` @@ -84,25 +125,26 @@ see whether `codebase-cleanup` shows up in the list of available skills. ### Tests -Three suites, with nothing to install beyond `bash` and `git`: +Four suites, with nothing to install beyond `bash` and `git`: ```bash -bash scripts/test.sh # runs all three, stopping at the first failure +bash scripts/test.sh # runs all four, stopping at the first failure bash scripts/gate_test.sh # gate contract: exit codes, the checks= line, PARTIAL +bash scripts/guard_test.sh # what the guard blocks, and what it lets through bash scripts/rollback_test.sh # what `git restore` brings back and what it destroys bash scripts/coherence_test.sh # docs and code saying the same thing ``` Each exits 0 when everything passed and prints the failing case when it does -not; `test.sh` only chains the three and stops at the first red. None of them +not; `test.sh` only chains the four and stops at the first red. None of them touches the repository you run it from: the gate suite uses -toolchain stubs, the rollback suite builds throwaway repositories inside a -`mktemp -d`, with `HOME` redirected and the commit identity passed via `-c` — -your git config is never read nor written —, and the coherence suite only -reads files. +toolchain stubs, the guard and rollback suites build throwaway repositories +inside a `mktemp -d`, with `HOME` redirected and the commit identity passed via +`-c` — your git config is never read nor written —, and the coherence suite +only reads files. -CI runs the three suites on every push and PR: ubuntu (real GNU `timeout`, +CI runs the four suites on every push and PR: ubuntu (real GNU `timeout`, procps) and macOS with the stock `/bin/bash` 3.2. The suites also run outside macOS. In a Linux container the hang case @@ -111,7 +153,8 @@ exercises the real GNU `timeout` instead of the perl backend: ```bash docker run --rm -v "$PWD":/repo:ro node:22-bookworm bash -c \ 'apt-get update -qq && apt-get install -y -qq procps && cd /repo && bash scripts/test.sh' -# validated 2026-08: 127/127 cases, 5/5 properties, 151/151 invariants +# validated 2026-08: 127/127 cases, 42/42 guard cases, 5/5 properties, +# 248/248 invariants ``` The .NET heuristic was validated against the real SDK @@ -140,7 +183,9 @@ are sources, not dependencies. There is no mandatory command. The skill triggers when the request sounds like cleanup: "clean this project up", "there's stuff here nobody uses", "remove the dead dependencies", "reorganize these folders". You can also -invoke it directly with `/codebase-cleanup`. +invoke it directly: `/codebase-cleanup:codebase-cleanup` when installed as a +plugin (plugin skills are always namespaced by the plugin name), or +`/codebase-cleanup` on a copied install. Partial requests work — "remove only the unused dependencies" runs the requested category and records the rest as out of scope. @@ -227,8 +272,13 @@ Between phases the skill asks for `/clear` — context accumulated from one phase degrades the judgment of the next. Progress lives in `CLEANUP_PROGRESS.md` at the repo root, so the next session resumes where it stopped without you re-explaining anything. In environments with subagents, -the skill can run as an orchestrator and dispatch each phase to a disposable -context; the protocol is in Step 0.2 of SKILL.md. +the skill runs as an orchestrator and dispatches each phase to a disposable +context. Installed as a plugin, those subagents come declared in `agents/`: +`cleanup-phase-1` (phases 1 and 1.5), plus a survey and an implementation +agent for each of phases 2 and 3. The two survey agents cannot write — that is +how the checkpoint stops depending on good intentions: the question reaches you +before anything changed, and the implementation only starts after your answer. +The protocol is in Step 0.2 of SKILL.md. ### How to revert @@ -253,6 +303,35 @@ rollback discards is what the skill itself created. Pathspec staging (instead of `git add -A`) keeps drafts and local `.env` files out of the category commit. +### The guards + +Installed as a plugin, those prohibitions stop being text the model can +forget. `hooks/hooks.json` registers `scripts/guard.sh` on the `PreToolUse` +event, and it stops five commands before they run: + +| Command | Why | +|---|---| +| `git reset --hard` | would take work that was in the tree before the cleanup started | +| `git clean` | would wipe untracked files that must survive: tool output, caches, local `.env` files | +| `git push` | the skill never publishes; merging is your decision | +| `git commit` on `main` | all the work lives on the cleanup branch | +| `git add -A` | whole-tree staging swallows into the commit what does not belong to the category | + +The guard is only awake inside a run: HEAD on a `cleanup/` branch, or an +**untracked** `CLEANUP_PROGRESS.md` — which is what a RED run leaves behind, +and what exists before the first commit. A tracked log on a normal branch is a +cleanup that got merged, not a run in flight, so there it goes back to sleep; +without that distinction it would stay awake on main forever after the first +merge. Outside a run these commands are ordinary work, and it says nothing. + +When in doubt it lets through. No repo, no JSON, no command: it exits 0 and +stays quiet. A guard that blocks by accident is worse than no guard, because +the skill **aborts** the pipeline when a command of the protocol is blocked — +a false positive kills a legitimate run. What it blocks and what it lets +through lives in `scripts/guard_test.sh`, including +`git restore --staged --worktree .`, `git revert`, `git mv`, +`git stash push -u` and pathspec `git add --`. + ## Known limits - Knip only covers JS/TS. In other stacks the confidence of automatic deletion diff --git a/README.md b/README.md index ab4b050..1124db3 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,32 @@ commitar o que está pendente ou abortar, e nada acontece antes da sua resposta. ## Instalação -A skill é uma pasta. Instalar é copiá-la para o diretório de skills: +Como plugin, que é o caminho recomendado — dá versão, atualização e os guardas +do `PreToolUse`: + +```bash +/plugin marketplace add CRangelP/codebase-cleanup +/plugin install codebase-cleanup@codebase-cleanup +``` + +Atualizar depois é `/plugin update codebase-cleanup@codebase-cleanup`. Para um +time, declare no `.claude/settings.json` do repositório: + +```json +{ + "extraKnownMarketplaces": { + "codebase-cleanup": { + "source": { "source": "github", "repo": "CRangelP/codebase-cleanup" } + } + }, + "enabledPlugins": { "codebase-cleanup@codebase-cleanup": true } +} +``` + +Isso não instala nada na máquina de ninguém: cada pessoa é perguntada uma vez +se confia e instala. + +Copiar a pasta continua funcionando, e continua sendo uma skill: ```bash # global (vale para todos os projetos) @@ -47,8 +72,9 @@ cp -R codebase-cleanup ~/.claude/skills/ cp -R codebase-cleanup .claude/skills/ ``` -Se você tem o pacote `codebase-cleanup.skill` (um zip), descompacte direto no -destino: +Copiada assim ela também carrega como plugin, porque `.claude-plugin/` viaja +junto — o que muda é só de onde vem a atualização. Se você tem o pacote +`codebase-cleanup.skill` (um zip), descompacte direto no destino: ```bash unzip codebase-cleanup.skill -d ~/.claude/skills/ @@ -62,6 +88,18 @@ codebase-cleanup/ ├── README.md este arquivo ├── README.en.md versão em inglês ├── LICENSE MIT +├── CHANGELOG.md o que mudou em cada versão +├── .claude-plugin/ +│ ├── plugin.json manifesto do plugin (nome, versão, licença) +│ └── marketplace.json catálogo, para instalar por /plugin install +├── agents/ +│ ├── cleanup-phase-1.md fases 1 e 1.5 +│ ├── cleanup-phase-2-survey.md candidatos de consolidação (só leitura) +│ ├── cleanup-phase-2-impl.md implementa o candidato escolhido +│ ├── cleanup-phase-3-survey.md plano de estrutura (só leitura) +│ └── cleanup-phase-3-impl.md executa os movimentos aprovados +├── hooks/ +│ └── hooks.json registra o guarda no evento PreToolUse ├── references/ │ ├── audit.md protocolo de auditoria da fase 1.4 │ ├── knip-config.md configuração do knip sem armadilhas @@ -71,8 +109,10 @@ codebase-cleanup/ │ └── other-stacks.md Python, Go, Rust, JVM, Ruby, .NET └── scripts/ ├── gate.sh typecheck + testes multi-stack, exit 0/1/2/3/4 - ├── test.sh roda as três suítes em sequência + ├── guard.sh bloqueia os cinco comandos que o protocolo proíbe + ├── test.sh roda as quatro suítes em sequência ├── gate_test.sh testes de contrato do gate (stubs de toolchain) + ├── guard_test.sh o que o guarda bloqueia e o que ele deixa passar ├── rollback_test.sh prova executável do protocolo de rollback └── coherence_test.sh invariantes de coerência entre doc e código ``` @@ -82,24 +122,25 @@ veja se `codebase-cleanup` aparece na lista de skills disponíveis. ### Testes -Três suítes, sem dependência além de `bash` e `git`: +Quatro suítes, sem dependência além de `bash` e `git`: ```bash -bash scripts/test.sh # roda as três, para na primeira que falhar +bash scripts/test.sh # roda as quatro, para na primeira que falhar bash scripts/gate_test.sh # contrato do gate: exit codes, linha checks=, PARTIAL +bash scripts/guard_test.sh # o que o guarda bloqueia, e o que ele deixa passar bash scripts/rollback_test.sh # o que `git restore` recupera e o que ele destrói bash scripts/coherence_test.sh # doc e código dizendo a mesma coisa ``` Cada uma sai 0 quando tudo passou e imprime o caso que falhou quando não; o -`test.sh` só encadeia as três e para na primeira vermelha. -Nenhuma das três toca o repositório em que você a rodou: o gate usa stubs de -toolchain, o rollback cria repositórios descartáveis dentro de um `mktemp -d`, -com `HOME` redirecionado e identidade de commit passada por `-c` — sua config -do git não é lida nem escrita —, e a de coerência só lê arquivos. +`test.sh` só encadeia as quatro e para na primeira vermelha. +Nenhuma das quatro toca o repositório em que você a rodou: o gate usa stubs de +toolchain, o guarda e o rollback criam repositórios descartáveis dentro de um +`mktemp -d`, com `HOME` redirecionado e identidade de commit passada por `-c` +— sua config do git não é lida nem escrita —, e a de coerência só lê arquivos. -A CI roda as três suítes a cada push e PR: ubuntu (GNU `timeout` real, +A CI roda as quatro suítes a cada push e PR: ubuntu (GNU `timeout` real, procps) e macOS com o `/bin/bash` 3.2 de fábrica. As suítes também rodam fora do macOS. Num container Linux, o caso de hang @@ -108,7 +149,8 @@ exercita o GNU `timeout` real em vez do backend perl: ```bash docker run --rm -v "$PWD":/repo:ro node:22-bookworm bash -c \ 'apt-get update -qq && apt-get install -y -qq procps && cd /repo && bash scripts/test.sh' -# validado em 08/2026: 127/127 casos, 5/5 propriedades, 151/151 invariantes +# validado em 08/2026: 127/127 casos, 42/42 casos do guarda, 5/5 propriedades, +# 248/248 invariantes ``` A heurística .NET foi validada contra o SDK real (`mcr.microsoft.com/dotnet/sdk:8.0` @@ -137,7 +179,9 @@ dependência. Não existe comando obrigatório. A skill dispara quando o pedido soa como limpeza: "dá uma faxina nesse projeto", "dá uma limpada", "tem coisa aqui que ninguém usa", "remove as dependências mortas", "reorganiza essas pastas". -Também dá para invocar direto com `/codebase-cleanup`. +Também dá para invocar direto: `/codebase-cleanup:codebase-cleanup` instalada +como plugin (skills de plugin sempre levam o nome do plugin na frente), ou +`/codebase-cleanup` na instalação por cópia. Pedidos parciais funcionam — "remove só as dependências não usadas" executa a categoria pedida e registra o resto como fora de escopo. @@ -221,9 +265,14 @@ Com o nível anunciado, ela cria a branch de limpeza e segue: Entre as fases a skill pede `/clear` — contexto acumulado de uma fase piora o julgamento da seguinte. O progresso fica em `CLEANUP_PROGRESS.md` na raiz do repo, então a sessão seguinte retoma de onde parou sem você reexplicar nada. -Em ambientes com subagentes, a skill pode rodar como orquestrador e despachar -cada fase para um contexto descartável; o protocolo está na seção "Step 0.2" -da SKILL.md. +Em ambientes com subagentes, a skill roda como orquestrador e despacha cada +fase para um contexto descartável. Instalada como plugin, esses subagentes +vêm declarados em `agents/`: `cleanup-phase-1` (fases 1 e 1.5), e mais um par +de survey e implementação para cada uma das fases 2 e 3. Os dois de survey não +conseguem escrever — é assim que o checkpoint deixa de depender de boa +vontade: a pergunta chega até você antes de qualquer mudança, e a +implementação só começa depois da sua resposta. O protocolo está na seção +"Step 0.2" da SKILL.md. ### Como reverter @@ -247,6 +296,34 @@ interrompe para perguntar quando não está — com a árvore limpa, o que o rollback joga fora foi ela mesma que criou. O stage por pathspec (em vez de `git add -A`) evita engolir rascunhos e `.env` locais no commit da categoria. +### Os guardas + +Instalada como plugin, essas proibições deixam de ser texto que o modelo pode +esquecer. `hooks/hooks.json` registra `scripts/guard.sh` no evento +`PreToolUse`, e ele barra cinco comandos antes de rodarem: + +| Comando | Por quê | +|---|---| +| `git reset --hard` | levaria junto trabalho que estava na árvore antes da limpeza | +| `git clean` | apagaria arquivos não rastreados que precisam sobreviver: saída de ferramenta, caches, `.env` locais | +| `git push` | a skill nunca publica; o merge é decisão sua | +| `git commit` na `main` | todo o trabalho vive na branch de limpeza | +| `git add -A` | staging de árvore inteira engole no commit o que não é da categoria | + +O guarda só acorda dentro de uma run: HEAD numa branch `cleanup/`, ou um +`CLEANUP_PROGRESS.md` **não rastreado** — que é o que uma run RED deixa, e o +que existe antes do primeiro commit. Log rastreado numa branch normal é +limpeza já mergeada, não run em curso, e aí ele volta a dormir; sem isso ele +ficaria ativo na main para sempre depois do primeiro merge. Fora disso esses +comandos são trabalho normal, e ele não diz nada. + +Na dúvida ele libera. Sem repo, sem JSON, sem comando: sai 0 e cala. Um guarda +que bloqueia por engano é pior que guarda nenhum, porque a skill **aborta** o +pipeline quando um comando do protocolo é barrado — um falso positivo derruba +uma run legítima. O que ele bloqueia e o que ele deixa passar está em +`scripts/guard_test.sh`, incluindo `git restore --staged --worktree .`, +`git revert`, `git mv`, `git stash push -u` e o `git add --` por pathspec. + ## Limites conhecidos - O knip só cobre JS/TS. Nos outros stacks a confiança da deleção automática diff --git a/SKILL.md b/SKILL.md index 731f994..cc2c8f0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -57,6 +57,16 @@ it. Branch on which command was blocked: A guard is environment policy, not an obstacle. +Installed as a plugin, this skill ships guards of its own (`hooks/hooks.json` +→ `scripts/guard.sh`) for exactly the five commands the rules above forbid: +`git reset --hard`, `git clean`, push, commit on `main`, and whole-tree +staging. They are awake only inside a run — a `cleanup/` branch, or an +untracked `CLEANUP_PROGRESS.md` — and they fail open. Hitting one of them +means the step was about to break the protocol, so the answer is never to +rephrase the command until it slips through: re-read the rule the guard names +and follow it. Everything the protocol actually runs is allowed, the rollback +and pathspec staging included. + There are **two scheduled checkpoints** in the pipeline (phase 2, choosing the consolidation candidate; phase 3 on GREEN, confirming the folder plan before any `git mv`) and **one conditional stop** at Step 0, when the working tree is @@ -103,9 +113,14 @@ normally — it will branch off that commit, which is exactly what you want. Not in the final report that the work started from a detached HEAD, so the user knows where the branch came from. -With a clean tree and a repo, run the baseline gate with `scripts/gate.sh` -(path relative to this skill's directory; it accepts the project directory as -an argument) and classify. The +With a clean tree and a repo, run the baseline gate with +`"${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh"` and classify. Installed as a +plugin, `CLAUDE_PLUGIN_ROOT` holds the absolute path of this plugin's +directory, so the call resolves from whatever directory the run happens to be +in; installed as a plain skill the variable is unset and `:-.` falls back to +the path relative to this file, which is what it always was. Either way the +script accepts the project directory as an argument and defaults to the +current one. The script detects the stack from the root manifest — `package.json`, `go.mod`, `Cargo.toml`, `pyproject.toml`/`setup.cfg`, `pom.xml`/`build.gradle`, `Gemfile`, `sln`/`csproj`/`fsproj` — and runs typecheck and tests for each one @@ -251,7 +266,23 @@ context, which is the same effect as `/clear` without depending on the user remembering. Single-phase request or small repo: single session, no orchestration. -The contract of each delegation: +Installed as a plugin, the delegations are declared and you call them by +name instead of composing them each time: + +| Agent | Scope | +|---|---| +| `codebase-cleanup:cleanup-phase-1` | phases 1 and 1.5 | +| `codebase-cleanup:cleanup-phase-2-survey` | the candidates, read-only | +| `codebase-cleanup:cleanup-phase-2-impl` | the consolidation the user chose | +| `codebase-cleanup:cleanup-phase-3-survey` | the structure plan, read-only | +| `codebase-cleanup:cleanup-phase-3-impl` | the approved moves | + +The split into survey and implementation is where the checkpoint lives: the +survey agents cannot write at all, so the question reaches the user before +anything changed, and the implementation agents only start after the answer. + +Without plugin agents the same contract is composed by hand, and it is the +same four points: - the path to this skill (the subagent reads SKILL.md and follows it, with references/ and scripts/ alongside) and the path to the repo; @@ -342,7 +373,8 @@ Never exclude tests with `ignore` to get the same effect. **Default scope.** Run all three without asking (GREEN level) or the first two (YELLOW). Each one is: delete → (deps only: install / re-resolve) → stage pathspecs → gate → commit → regenerate the report. For the gate, use -`scripts/gate.sh` (it detects the stack and the package manager and runs +`"${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh"` (it detects the stack and the +package manager and runs typecheck + tests in the right order); if it exits with code 3, find the stack's own check commands — the `package.json` scripts, the tox env, the Makefile target, whatever this repo uses — and run them by hand. @@ -553,7 +585,8 @@ with a simple interface is exactly what you *want*). ## Implementation After the choice, run on your own: one module at a time, one commit per -consolidation, and `scripts/gate.sh` once — after staging pathspecs of what +consolidation, and `"${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh"` once — after +staging pathspecs of what this consolidation touched, right before the commit. Do not gate between the intermediate steps: with the new interface in place and the callers not migrated yet, the build is red by construction, and a gate you expect to fail @@ -621,7 +654,8 @@ The move, the import or alias update and the `CLAUDE.md` update go in the history, and there is no gate that a half-done move can pass. Stage with pathspecs of what this folder move touched (`git add -- …`), never -`git add -A`, and then `scripts/gate.sh` at the end of each folder — +`git add -A`, and then `"${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh"` at the end +of each folder — typecheck alone misses what a move actually breaks (config paths, dynamic imports; the "Do not forget" list in `references/phase-3-structure.md` has the rest). Failed: `git restore --staged --worktree .`, record it, next diff --git a/agents/cleanup-phase-1.md b/agents/cleanup-phase-1.md new file mode 100644 index 0000000..1e4cdbd --- /dev/null +++ b/agents/cleanup-phase-1.md @@ -0,0 +1,45 @@ +--- +name: cleanup-phase-1 +description: Runs phase 1 (dead code) and phase 1.5 (duplicate functions) of a codebase cleanup that is already under way. Delegated by the codebase-cleanup orchestrator once the level and the branch exist. Deletes in atomic commits, one per category, each behind a green gate. Not for starting a cleanup: the level, the branch and the clean tree are Step 0's job. +--- + +You are the phase 1 implementation of a cleanup already in flight. Another +session measured the level, created the `cleanup/` branch and wrote the log. +You do the deleting. + +Read, in this order, before anything else: + +1. `CLEANUP_PROGRESS.md` at the root of the repo — the level, the branch and + what has already been done are there, and they are authoritative. If it + contradicts what the delegation told you, the file wins. +2. `${CLAUDE_PLUGIN_ROOT}/SKILL.md`, the sections `PHASE 1` through `1.5`, + plus `Operating principle` and `Rules that apply to the whole pipeline`. + The references it points at (`references/knip-config.md`, + `references/other-stacks.md`, `references/audit.md`, + `references/duplication.md`) are next to it and you read them when it + says to. + +Your scope is phase 1 and phase 1.5, and nothing else. Phase 1.5 is a report: +it finds candidates for phase 2 and deletes nothing. When it is done, you are +done — do not start consolidating modules or moving folders, even if the +opportunity is obvious. The boundary between the phases is where the context +gets dropped, and that is the point of delegating. + +The level in the log decides how much you may delete without asking. You have +nobody to ask: you do not talk to the user. A category that needs a human +answer — anything the level or a stack cap sends to a checkpoint — is not +yours to decide. Record it in `CLEANUP_PROGRESS.md` under what is pending and +leave it undone. + +Every rule of the protocol applies to you as written, and three of them are +the ones that get lost in delegation: + +- one commit per category, staged by pathspec, never a whole-tree add; +- no commit without a green gate, and a red or timed-out gate means rollback, + not repair; +- if a security hook blocks a command, follow the hook rule at the top of + SKILL.md — the rollback being blocked aborts the pipeline. + +Finish by updating `CLEANUP_PROGRESS.md` — it is the canonical state, not your +reply — and returning a short summary: what each category removed, what failed +and why, and what is waiting on a human. diff --git a/agents/cleanup-phase-2-impl.md b/agents/cleanup-phase-2-impl.md new file mode 100644 index 0000000..daf517c --- /dev/null +++ b/agents/cleanup-phase-2-impl.md @@ -0,0 +1,25 @@ +--- +name: cleanup-phase-2-impl +description: Implements one consolidation the user already approved in phase 2 of a codebase cleanup. One module at a time, one commit, gate green before it. Only after the checkpoint: the choice of candidate is never this agent's to make. +--- + +The user has chosen. You implement that one consolidation and stop. + +Read `CLEANUP_PROGRESS.md` first — the branch, the level and the chosen +candidate are recorded there. If the log does not name a chosen candidate, +something went wrong upstream: say so and do nothing. Choosing is not your +job, and a consolidation nobody approved is exactly the kind of surprise this +pipeline exists to avoid. + +Then read `${CLAUDE_PLUGIN_ROOT}/SKILL.md`, section `PHASE 2` (the +`Implementation` part), and +`${CLAUDE_PLUGIN_ROOT}/references/phase-2-consolidation.md`. + +One candidate, one commit. Stage the pathspecs of what the consolidation +touched, run the gate once at the end — not between the intermediate steps, +where the build is red by construction and a failure teaches nothing — and +commit on green. Red or timed out: roll back with the canonical restore, +record it, and stop. Do not stack a second consolidation onto this one. + +Finish by updating `CLEANUP_PROGRESS.md` and returning a short summary: what +was consolidated into what, the gate result, and anything left undone. diff --git a/agents/cleanup-phase-2-survey.md b/agents/cleanup-phase-2-survey.md new file mode 100644 index 0000000..e028aa5 --- /dev/null +++ b/agents/cleanup-phase-2-survey.md @@ -0,0 +1,29 @@ +--- +name: cleanup-phase-2-survey +description: Surveys consolidation candidates for phase 2 of a codebase cleanup — shallow modules, clusters, the duplicate pairs phase 1.5 found — and returns up to five with a recommendation. Read-only by design: it decides nothing and changes no code, because the module boundary is the user's call. +disallowedTools: Write, Edit +--- + +You survey, you do not consolidate. The output of this delegation is a list +and a recommendation; the decision belongs to the user, and the orchestrator +is the one who asks them. + +Read `CLEANUP_PROGRESS.md` first — the branch, the level and the duplicate +pairs phase 1.5 left behind are there. Then read +`${CLAUDE_PLUGIN_ROOT}/SKILL.md`, section `PHASE 2`, and +`${CLAUDE_PLUGIN_ROOT}/references/phase-2-consolidation.md` in full: the +survey protocol, the vocabulary (module, interface, implementation, depth, +seam, adapter, locality) and the deletion test are all there. + +Start from the pairs phase 1.5 reported. A pair that changes together in git +history is duplication that already proved itself; it outranks anything you +find by reading the tree. + +Return at most five candidates. For each one: the modules involved, why they +are shallow or duplicated, what the consolidation would look like, and what it +would cost. Then recommend exactly one, and say plainly why it is the one. +A survey that recommends nothing is a valid answer — say so instead of +promoting the least bad option. + +You cannot write files and you should not want to: nothing you learn here is +worth committing before the user has chosen. Everything goes in your reply. diff --git a/agents/cleanup-phase-3-impl.md b/agents/cleanup-phase-3-impl.md new file mode 100644 index 0000000..c73c87e --- /dev/null +++ b/agents/cleanup-phase-3-impl.md @@ -0,0 +1,23 @@ +--- +name: cleanup-phase-3-impl +description: Executes an already approved folder plan in phase 3 of a codebase cleanup — one folder per commit, with the references updated in the same commit and the gate green before each one. Only after the user approved the plan. +--- + +The plan is approved. You execute it, one folder at a time. + +Read `CLEANUP_PROGRESS.md` first — the branch and the approved plan are there. +No approved plan in the log means you stop and say so. Then read +`${CLAUDE_PLUGIN_ROOT}/SKILL.md`, section `PHASE 3`, and +`${CLAUDE_PLUGIN_ROOT}/references/phase-3-structure.md`, including its +"do not forget" list — most of what a move breaks only breaks at runtime. + +One folder per commit, and the move plus every reference updated for it go in +the **same** commit: split apart, the history gets a commit that does not +build, and no gate can pass a half-done move. Create the destination directory +before moving into it. Stage by pathspec, gate at the end of each folder, +commit on green. Failed: roll back with the canonical restore, record it, move +to the next folder — unless the restore itself is blocked, and then the +pipeline aborts. + +Finish by updating `CLEANUP_PROGRESS.md` and returning a summary: which +folders moved, which commits carry them, what failed, and what was skipped. diff --git a/agents/cleanup-phase-3-survey.md b/agents/cleanup-phase-3-survey.md new file mode 100644 index 0000000..eb3f0cd --- /dev/null +++ b/agents/cleanup-phase-3-survey.md @@ -0,0 +1,20 @@ +--- +name: cleanup-phase-3-survey +description: Diagnoses the folder structure in phase 3 of a codebase cleanup and produces the move plan for the user to approve. Read-only: it moves nothing, because reorganizing the tree decides where the project lives from then on. +disallowedTools: Write, Edit +--- + +You produce the plan. You do not move anything, and no folder gets touched +before the user has said yes to what you wrote. + +Read `CLEANUP_PROGRESS.md` first, then +`${CLAUDE_PLUGIN_ROOT}/SKILL.md`, section `PHASE 3`, and +`${CLAUDE_PLUGIN_ROOT}/references/phase-3-structure.md` — the organization +patterns and the diagnosis it asks for are there. + +Diagnosis before moves, in that order. The plan says which folders move where, +in what order, what each move breaks that has to be updated with it, and which +cycles it resolves. A move whose only argument is taste is not in the plan. + +Everything goes in your reply: you cannot write files, and the orchestrator is +the one who takes the plan to the user. diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..92fd84c --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(git *)", + "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/guard.sh\"" + } + ] + } + ] + } +} diff --git a/references/phase-2-consolidation.md b/references/phase-2-consolidation.md index d25e6e5..2dcfc59 100644 --- a/references/phase-2-consolidation.md +++ b/references/phase-2-consolidation.md @@ -94,7 +94,8 @@ One candidate per session. Per consolidation: 3. Remove the old modules 4. `git add -- ` — pathspecs only, never `git add -A` / `git add .` -5. `scripts/gate.sh` — once, here, not between the steps above +5. `"${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh"` — once, here, not between the + steps above 6. Commit `refactor: consolidate X into Y` Steps 1–3 are red by construction: the new interface exists and its callers do diff --git a/references/phase-3-structure.md b/references/phase-3-structure.md index e68deab..f3b41c9 100644 --- a/references/phase-3-structure.md +++ b/references/phase-3-structure.md @@ -102,7 +102,8 @@ the merge, `git revert ` takes that folder back with its imports and its docs, in one shot. `git add -- ` (pathspecs only — never -`git add -A` / `git add .`) and then `scripts/gate.sh` at the end of each +`git add -A` / `git add .`) and then +`"${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh"` at the end of each folder — staged first, so that the rollback also undoes files created during the move. Typecheck alone is not enough here: the "Do not forget" list below is mostly made of things that only break at runtime, and the test suite is diff --git a/scripts/coherence_test.sh b/scripts/coherence_test.sh index d3e81a1..5972fad 100755 --- a/scripts/coherence_test.sh +++ b/scripts/coherence_test.sh @@ -221,6 +221,26 @@ check "gate.sh only exits documented codes" \ "$([[ -z $undocumented ]] && echo 0 || echo 1)" \ "exit code(s) outside the documented 0/1/2/3/4:$undocumented" +# Every invocation of the gate in the protocol resolves through the plugin +# root. Installed as a plugin the skill is copied into a cache directory whose +# path nobody can guess, and a bare `scripts/gate.sh` would then be read as +# relative to the *project* being cleaned, where it does not exist — the run +# would lose the one script that decides its level. The `:-.` keeps the plain +# skill install working, so the canonical form is the whole form, braces and +# fallback included. The READMEs are exempt on purpose: there the path names +# the file on disk (a file tree, a requirements list), it is not a step anyone +# runs. +GATE_CALL='"${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh"' +for f in SKILL.md references/*.md; do + [[ -f $f ]] || continue + loose=$(grep -o -F -- 'scripts/gate.sh' "$f" 2>/dev/null | wc -l) + canon=$(grep -o -F -- "$GATE_CALL" "$f" 2>/dev/null | wc -l) + check "the gate is called through the plugin root in $f" \ + "$([[ $((loose)) -eq $((canon)) ]] && echo 0 || echo 1)" \ + "$((loose)) mentions of scripts/gate.sh, $((canon)) of them canonical +the canonical form is $GATE_CALL" +done + # 3. Strings the docs stopped meaning are gone from the repo. ----------------- # Each one described a protocol that no longer exists; a copy left behind # contradicts the current one. @@ -339,12 +359,17 @@ for readme in README.md README.en.md; do fail "$required of the tree of $readme exists on disk" "listed but missing on disk" fi done - for path in references/*.md; do + # references/ and agents/ get the same treatment: both ship inside the + # plugin, both are read by name at runtime, and a file missing from the tree + # is a file the reader does not know is there. + for path in references/*.md agents/*.md; do + [[ -f $path ]] || continue name=${path##*/} + dir=${path%%/*} if printf '%s\n' "$docs" | grep -qx -F -- "$name"; then - pass "references/$name listed in the tree of $readme" + pass "$dir/$name listed in the tree of $readme" else - fail "references/$name listed in the tree of $readme" "on disk but absent from the tree" + fail "$dir/$name listed in the tree of $readme" "on disk but absent from the tree" fi done old_ifs=$IFS @@ -352,7 +377,7 @@ for readme in README.md README.en.md; do for name in $docs; do [[ $name == *.md ]] || continue case $name in - SKILL.md|README.md|README.en.md) + SKILL.md|README.md|README.en.md|CHANGELOG.md) if [[ -f $name ]]; then pass "$name of the tree of $readme exists on disk" else @@ -360,11 +385,16 @@ for readme in README.md README.en.md; do fi ;; *) + # A doc in the tree lives in references/ or in agents/. Naming the two + # directories beats accepting either silently: a file that is in + # neither is listed and shipped by nobody. if [[ -f references/$name ]]; then pass "references/$name of the tree of $readme exists on disk" + elif [[ -f agents/$name ]]; then + pass "agents/$name of the tree of $readme exists on disk" else - fail "references/$name of the tree of $readme exists on disk" \ - "listed but there is no references/$name" + fail "$name of the tree of $readme exists on disk" \ + "listed but there is no references/$name nor agents/$name" fi ;; esac @@ -760,6 +790,172 @@ check "SKILL.md description stays at or under 1000 characters" \ "$([[ -n $desc && $desc_len -le 1000 ]] && echo 0 || echo 1)" \ "description is ${desc_len} characters (limit 1000)" +# 11. The guard blocks what the READMEs say it blocks. ----------------------- +# The guard is the plugin half of five rules the skill states in prose, and a +# table that drifts from the script is worse than no table: a reader plans +# around a command that is not actually stopped, or fights one that is. So each +# command is one string in three places — the executable proof, and both +# READMEs — and the invariant is that the three agree. The proof is the anchor +# and not guard.sh itself on purpose: the script matches `-A` inside a case arm +# and never spells the whole command out, while guard_test.sh has to run it +# verbatim to test it. +GUARD_SECTION_PT='### Os guardas' +GUARD_SECTION_EN='### The guards' + +guard_section() { # guard_section — the section's body + awk -v h="$2" ' + $0 == h { inside = 1; next } + inside && /^## / { exit } + inside { print } + ' "$1" +} + +for f in scripts/guard.sh scripts/guard_test.sh hooks/hooks.json; do + check "$f exists" "$([[ -f $f ]] && echo 0 || echo 1)" "the guard ships incomplete without it" +done + +check "hooks.json runs the guard through the plugin root" \ + "$(grep -q -F -- '${CLAUDE_PLUGIN_ROOT}/scripts/guard.sh' hooks/hooks.json 2>/dev/null && echo 0 || echo 1)" \ + "the hook command has to resolve from the plugin directory, not the cleaned project" + +check "hooks.json registers the guard on PreToolUse" \ + "$(grep -q -F -- 'PreToolUse' hooks/hooks.json 2>/dev/null && echo 0 || echo 1)" \ + "any later event fires after the command already ran" + +check "test.sh chains guard_test.sh" \ + "$(grep -q -F -- 'guard_test.sh' scripts/test.sh 2>/dev/null && echo 0 || echo 1)" \ + "a suite nobody runs is a suite that rots" + +while IFS= read -r cmd; do + [[ -n $cmd ]] || continue + check "guard_test.sh exercises '$cmd'" \ + "$(grep -q -F -- "$cmd" scripts/guard_test.sh 2>/dev/null && echo 0 || echo 1)" \ + "the READMEs promise it is blocked and no case runs it" + check "the guard table of README.md names '$cmd'" \ + "$(guard_section README.md "$GUARD_SECTION_PT" | grep -q -F -- "$cmd" && echo 0 || echo 1)" \ + "blocked by the guard and absent from the table" + check "the guard table of README.en.md names '$cmd'" \ + "$(guard_section README.en.md "$GUARD_SECTION_EN" | grep -q -F -- "$cmd" && echo 0 || echo 1)" \ + "blocked by the guard and absent from the table" +done <<'GUARDED' +git reset --hard +git clean +git push +git commit +git add -A +GUARDED + +# 12. The delegated phases exist as agents, and stay inside their limits. ---- +# Step 0.2 hands each phase to an agent by name. A name that resolves to no +# file is a delegation that dies at call time, and the two read-only ones carry +# the checkpoint: a survey agent that can write is a survey that can decide, +# which is the one thing the checkpoint exists to prevent. Plugin agents also +# may not declare hooks, mcpServers or permissionMode — Claude Code refuses the +# agent outright — so the ban is checked here rather than discovered on a user's +# machine. +agent_frontmatter() { # agent_frontmatter — the YAML block, or empty + awk '/^---[[:space:]]*$/ { fm++; next } fm == 1 { print } fm >= 2 { exit }' "$1" +} + +for a in cleanup-phase-1 cleanup-phase-2-survey cleanup-phase-2-impl \ + cleanup-phase-3-survey cleanup-phase-3-impl; do + f="agents/$a.md" + check "agents/$a.md exists" \ + "$([[ -f $f ]] && echo 0 || echo 1)" \ + "Step 0.2 delegates to an agent with no file behind it" + [[ -f $f ]] || continue + + check "$a declares its own name in the frontmatter" \ + "$(agent_frontmatter "$f" | grep -q -E "^name:[[:space:]]*$a\$" && echo 0 || echo 1)" \ + "the frontmatter name is what the @-mention resolves; a mismatch with the +filename is a delegation nobody can call" + + check "$a declares a description" \ + "$(agent_frontmatter "$f" | grep -q -E '^description:[[:space:]]*[^[:space:]]' && echo 0 || echo 1)" \ + "the description is what decides when the agent is invoked" + + check "$a reads CLEANUP_PROGRESS.md first" \ + "$(grep -q -F -- 'CLEANUP_PROGRESS.md' "$f" && echo 0 || echo 1)" \ + "the log is the canonical state a delegation resumes from" + + banned=$(agent_frontmatter "$f" | grep -E '^(hooks|mcpServers|permissionMode):' | tr '\n' ' ') + check "$a declares no field a plugin agent may not have" \ + "$([[ -z $banned ]] && echo 0 || echo 1)" \ + "plugin agents support none of hooks, mcpServers, permissionMode: $banned" + + check "Step 0.2 names $a" \ + "$(grep -q -F -- "codebase-cleanup:$a" SKILL.md && echo 0 || echo 1)" \ + "an agent nobody delegates to is an agent that rots" +done + +# The two surveys are the checkpoint. They run before the user has answered, so +# the guarantee has to be mechanical: no Write, no Edit, no way to change the +# repository while deciding what to propose. +for a in cleanup-phase-2-survey cleanup-phase-3-survey; do + f="agents/$a.md" + [[ -f $f ]] || continue + line=$(agent_frontmatter "$f" | grep -E '^disallowedTools:') + ok=1 + case $line in + *Write*) case $line in *Edit*) ok=0 ;; esac ;; + esac + check "$a cannot write" "$ok" \ + "a survey runs before the user answered; got '${line:-no disallowedTools}'" +done + +# 13. The plugin manifests agree with each other and with the docs. ---------- +# The version in plugin.json is the cache key that decides whether an install +# sees an update at all: pinned and never bumped, a user stays on the version +# they first installed no matter how many commits land. That failure is silent +# on both ends — nobody gets an error, the fix just never arrives — so the +# pieces that have to agree are checked here instead of being remembered. +plugin_field() { # plugin_field — the string value, or empty + perl -0777 -ne 'print $1 if /"'"$2"'"\s*:\s*"([^"]*)"/' "$1" 2>/dev/null +} + +plugin_name=$(plugin_field .claude-plugin/plugin.json name) +plugin_version=$(plugin_field .claude-plugin/plugin.json version) +market_name=$(plugin_field .claude-plugin/marketplace.json name) + +check "plugin.json declares a name" \ + "$([[ -n $plugin_name ]] && echo 0 || echo 1)" \ + "the name is the namespace of every skill in the plugin" + +# The changelog's top entry and the manifest's version are one string. The +# manifest alone would be a number nobody can read a meaning into, and a +# changelog alone would be a story about a version that never shipped: the +# release is the pair, so drift between them is caught here. +changelog_version=$(sed -n 's|^## \[\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)\].*|\1|p' CHANGELOG.md 2>/dev/null | head -1) +check "CHANGELOG.md opens with a semver entry" \ + "$([[ -n $changelog_version ]] && echo 0 || echo 1)" \ + "no '## [X.Y.Z]' heading found; the top entry is what a release note quotes" +check "CHANGELOG.md and plugin.json agree on the version" \ + "$([[ -n $changelog_version && $changelog_version == "$plugin_version" ]] && echo 0 || echo 1)" \ + "CHANGELOG.md says '${changelog_version:-none}', plugin.json says '${plugin_version:-none}'" + +check "plugin.json declares an explicit version" \ + "$([[ $plugin_version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo 0 || echo 1)" \ + "got '${plugin_version:-none}'; without semver here the version falls back to the +commit SHA and every push becomes an update" + +# The install line the READMEs publish is plugin@marketplace. Both halves come +# from the manifests, so a rename on either side turns the documented command +# into one that resolves to nothing. +install_id="$plugin_name@$market_name" +for f in README.md README.en.md; do + check "$f publishes the install id $install_id" \ + "$(grep -q -F -- "$install_id" "$f" 2>/dev/null && echo 0 || echo 1)" \ + "the documented /plugin install does not match the manifests" +done + +# The guard's own exit codes. Exit 1 does not block — the hook contract reads +# it as a non-fatal error and lets the command through — so a guard that ever +# exits 1 fails silently, which is the one failure mode nobody would notice. +guard_codes=$(exit_codes < scripts/guard.sh | sort -u | tr '\n' ' ') +check "guard.sh only exits 0 or 2" \ + "$([[ $(printf '%s' "$guard_codes" | tr -d ' ') == "02" ]] && echo 0 || echo 1)" \ + "exit codes found: ${guard_codes:-none} (1 would not block)" + echo "----" echo "$((total-failures))/$total invariants held" [[ $failures -eq 0 ]] diff --git a/scripts/guard.sh b/scripts/guard.sh new file mode 100755 index 0000000..c1aa5df --- /dev/null +++ b/scripts/guard.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# PreToolUse/Bash guard for the cleanup protocol. +# +# The skill states five prohibitions in prose — no `git reset --hard`, no +# `git clean`, no push, no commit on main, no whole-tree staging. Prose is +# advice a model can miss; this script is the same five rules as mechanism. +# It is the plugin half of the rule the skill already has for the other +# direction: a blocked command is environment policy, not an obstacle. +# +# Contract (docs.claude.com/en/docs/claude-code/hooks): the hook JSON arrives on +# stdin, the command under judgement is .tool_input.command, exit 2 blocks the +# call and hands stderr back to the model. Exit 1 would NOT block — the hook +# contract reads it as a non-fatal error and lets the command through — so the +# only two exits here are 0 and 2. +# +# Fail open, always. A guard that blocks by accident is worse than no guard: +# the skill aborts the pipeline on some blocked commands, so a false positive +# kills a legitimate run. Anything unclear — no repo, no JSON, no command, +# no git — exits 0 and says nothing. +# +# Usage: guard.sh (stdin: hook JSON · exit 0 = no decision · 2 = blocked) +set -uo pipefail + +# --- When the guard is awake ------------------------------------------------- +# Only inside a cleanup run, because these commands are perfectly normal +# everywhere else and a plugin's hooks run for every session that enables it. +# Two pieces of evidence, both created by the skill itself: +# +# * HEAD is a cleanup/ branch — the run is in flight; +# * an *untracked* CLEANUP_PROGRESS.md — a RED run, or a run that has not +# committed its log yet, both of which happen before the branch exists. +# +# Untracked is the whole point of the second one. A tracked log on a normal +# branch is a finished cleanup that got merged, not a live run: keying on mere +# existence would leave the guard awake on main forever after the first merge. +inside_run() { + local branch + branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || return 1 + case $branch in cleanup/*) return 0 ;; esac + git rev-parse --show-toplevel >/dev/null 2>&1 || return 1 + local root; root=$(git rev-parse --show-toplevel 2>/dev/null) || return 1 + [[ -f $root/CLEANUP_PROGRESS.md ]] || return 1 + # tracked? then it is history, not a run. + git -C "$root" ls-files --error-unmatch -- CLEANUP_PROGRESS.md >/dev/null 2>&1 && return 1 + return 0 +} + +on_trunk() { # is HEAD on the branch nobody may commit to? + local b; b=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || return 1 + case $b in main|master) return 0 ;; *) return 1 ;; esac +} + +# --- Reading the command out of the hook JSON -------------------------------- +# perl and not jq: the docs' examples use jq, this repo cannot assume it (the +# suites run on a stock macOS with bash 3.2, and gate.sh already reaches for +# perl for its watchdog). Two passes on purpose — the first lifts the raw +# string out of tool_input, the second unescapes it. Doing both at once is how +# a \\" inside the command turns into a broken quote. +extract_command() { + perl -0777 -ne ' + exit 0 unless /"tool_input"\s*:\s*\{(.*?)\}\s*[,}]/s; + my $t = $1; + exit 0 unless $t =~ /"command"\s*:\s*"((?:[^"\\]|\\.)*)"/s; + my $c = $1; + $c =~ s/\\n/\n/g; $c =~ s/\\t/\t/g; $c =~ s/\\r//g; + $c =~ s/\\(.)/$1/g; + print $c; + ' 2>/dev/null +} + +deny() { # deny + printf '[codebase-cleanup] blocked: %s\n%s\n' "$1" "$2" >&2 + exit 2 +} + +inside_run || exit 0 + +cmd=$(extract_command) +[[ -n ${cmd:-} ]] || exit 0 + +# --- One segment at a time --------------------------------------------------- +# A single Bash call carries a whole line: `git add -A && git commit -m x`. +# Judging the string as a whole would miss the second half, so the line is cut +# on the shell separators first and every piece is read on its own. +segments=$(printf '%s' "$cmd" | tr '\n;|&' '\012\012\012\012') + +while IFS= read -r seg; do + # shellcheck disable=SC2086 + set -- $seg + [[ $# -gt 0 ]] || continue + [[ $1 == git ]] || continue + shift + + # Skip git's own options so `git -C dir reset --hard` reads like `git reset`. + while [[ $# -gt 0 ]]; do + case $1 in + -C|-c|--git-dir|--work-tree|--namespace|--exec-path) + shift; [[ $# -gt 0 ]] && shift ;; + --git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|-p|--paginate|--no-pager|--literal-pathspecs|--no-optional-locks) + shift ;; + -*) shift ;; + *) break ;; + esac + done + [[ $# -gt 0 ]] || continue + + sub=$1; shift + case $sub in + reset) + for a in "$@"; do + [[ $a == --hard ]] && deny "git reset --hard" \ + "The rollback of this protocol is \`git restore --staged --worktree .\`, which +leaves untracked files alone. A hard reset would take work that was in the +tree before the cleanup started." + done + ;; + clean) + deny "git clean" \ + "git clean wipes untracked files that must survive the run — tool output, +caches, local env files that never belonged to this cleanup. Nothing in the +protocol needs it." + ;; + push) + deny "git push" \ + "This skill never pushes. The cleanup branch is handed over locally and +merging it is the user's decision, on their own schedule." + ;; + commit) + on_trunk && deny "git commit on $(git rev-parse --abbrev-ref HEAD 2>/dev/null)" \ + "All the work belongs on the cleanup/ branch. Create it (or switch back to +it) and commit there." + ;; + add) + for a in "$@"; do + case $a in + -A|--all|--no-ignore-removal|.) + deny "git add $a" \ + "Stage by pathspec: \`git add -- \`. Whole-tree +staging is what swallows a draft of the user's into the category's commit, and +what makes the commit stop being a description of one category." + ;; + esac + done + ;; + esac +done < — git confined to , throwaway identity + local repo=$1; shift + git -C "$repo" -c user.name=t -c user.email=t@example.invalid \ + -c init.defaultBranch=main "$@" +} + +new_repo() { # new_repo [branch] — repo with one commit, echoes the path + local repo="$TMP/$1" + mkdir -p "$repo" + g "$repo" init -q + printf 'x\n' > "$repo/a.txt" + g "$repo" add -- a.txt + g "$repo" commit -qm baseline + [[ ${2:-} ]] && g "$repo" checkout -q -b "$2" + printf '%s' "$repo" +} + +# json — the hook payload Claude Code puts on the guard's stdin. +# The command goes through a JSON escaper so a case can carry quotes and +# backslashes without hand-rolling the encoding. +json() { + printf '%s' "$1" | perl -0777 -ne ' + s/\\/\\\\/g; s/"/\\"/g; s/\n/\\n/g; s/\t/\\t/g; + print "{\"session_id\":\"s\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"$_\",\"description\":\"d\"}}"; + ' +} + +# run_guard — sets RC and STDERR. Deliberately not a command +# substitution: $( ) runs the function in a subshell, where an assignment to +# STDERR dies with it, and every "blocked" case would then read an empty +# stderr and pass or fail for the wrong reason. +run_guard() { + local repo=$1 cmd=$2 out + out="$TMP/stderr.$$" + ( cd "$repo" && json "$cmd" | "${BASH:-bash}" "$GUARD" 2>"$out" >/dev/null ) + RC=$? + STDERR=$(cat "$out") +} + +blocks() { # blocks