From add882b01f4d1d998c635cd4b98f34d8132b3efa Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sun, 9 Aug 2026 12:55:15 -0300 Subject: [PATCH 1/7] =?UTF-8?q?plugin:=20manifesto=20m=C3=ADnimo=20?= =?UTF-8?q?=E2=80=94=20a=20pasta=20carrega=20como=20plugin=20sem=20deixar?= =?UTF-8?q?=20de=20ser=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/plugin.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .claude-plugin/plugin.json 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"] +} From 5dd05530ebe5a1fc6796ad9820f0fdb0c3465890 Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sun, 9 Aug 2026 13:03:54 -0300 Subject: [PATCH 2/7] =?UTF-8?q?plugin:=20o=20gate=20=C3=A9=20chamado=20por?= =?UTF-8?q?=20caminho=20absoluto=20do=20plugin,=20com=20fallback=20de=20sk?= =?UTF-8?q?ill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instalado como plugin, a pasta é copiada para um diretório de cache cujo caminho ninguém adivinha, e um 'scripts/gate.sh' nu seria lido como relativo ao projeto sendo limpo — onde ele não existe. O :-. mantém a instalação como skill pura funcionando. Um invariante novo cobre as sete formas no protocolo; os READMEs ficam de fora porque lá o caminho nomeia o arquivo, não um passo. --- SKILL.md | 20 ++++++++++++++------ references/phase-2-consolidation.md | 3 ++- references/phase-3-structure.md | 3 ++- scripts/coherence_test.sh | 20 ++++++++++++++++++++ 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/SKILL.md b/SKILL.md index 731f994..1316d0e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -103,9 +103,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 @@ -342,7 +347,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 +559,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 +628,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/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..fe6c5ff 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. From 29ef650318af21c0e76e6626ecf2072c0eedda8c Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sun, 9 Aug 2026 13:16:42 -0300 Subject: [PATCH 3/7] =?UTF-8?q?hooks:=20os=20cinco=20comandos=20que=20o=20?= =?UTF-8?q?protocolo=20pro=C3=ADbe=20passam=20a=20ser=20bloqueados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skill proíbe reset --hard, clean, push, commit na main e staging de árvore inteira — em prosa, que é conselho que um modelo pode perder. Instalada como plugin ela passa a barrar os cinco no PreToolUse, antes de rodarem. O guarda só acorda dentro de uma run (branch cleanup/, ou CLEANUP_PROGRESS.md não rastreado) porque hooks de plugin disparam em toda sessão que o habilita, e esses comandos são trabalho normal em qualquer outro lugar. Log rastreado é limpeza mergeada, não run em curso: sem essa distinção o guarda ficaria ativo na main para sempre depois do primeiro merge. Falha aberta em tudo o que for ambíguo. A skill aborta o pipeline quando um comando do protocolo é bloqueado, então um falso positivo derruba uma run legítima — pior que guarda nenhum. Sai 0 ou 2 e nunca 1: o contrato de hook lê 1 como erro não-fatal e deixa o comando passar. 42 casos novos em guard_test.sh, a metade que importa sendo o que ele deixa passar; e invariantes ligando cada comando bloqueado à tabela dos dois READMEs. --- .github/workflows/ci.yml | 6 +- README.en.md | 54 +++++++++-- README.md | 53 +++++++++-- SKILL.md | 10 ++ hooks/hooks.json | 16 ++++ scripts/coherence_test.sh | 63 ++++++++++++ scripts/guard.sh | 150 +++++++++++++++++++++++++++++ scripts/guard_test.sh | 195 ++++++++++++++++++++++++++++++++++++++ scripts/test.sh | 8 +- 9 files changed, 531 insertions(+), 24 deletions(-) create mode 100644 hooks/hooks.json create mode 100755 scripts/guard.sh create mode 100755 scripts/guard_test.sh 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/README.en.md b/README.en.md index 220549b..2deb4d4 100644 --- a/README.en.md +++ b/README.en.md @@ -64,6 +64,10 @@ codebase-cleanup/ ├── README.md readme in Portuguese ├── README.en.md this file ├── LICENSE MIT +├── .claude-plugin/ +│ └── plugin.json plugin manifest (name, version, license) +├── 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 +77,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 +90,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 @@ -253,6 +260,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..1cd3a6c 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ codebase-cleanup/ ├── README.md este arquivo ├── README.en.md versão em inglês ├── LICENSE MIT +├── .claude-plugin/ +│ └── plugin.json manifesto do plugin (nome, versão, licença) +├── 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 +75,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 +88,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 @@ -247,6 +254,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 1316d0e..7633dda 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 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/scripts/coherence_test.sh b/scripts/coherence_test.sh index fe6c5ff..561b4aa 100755 --- a/scripts/coherence_test.sh +++ b/scripts/coherence_test.sh @@ -780,6 +780,69 @@ 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 + +# 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