<!--
  canonical-url: https://www.avonture.be/fr/blog/claude-code-workflow
  published:     2026-08-28
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Claude Code - Des slash commands à un workflow en couches

> Comment le dossier .claude/ de ce blog est passé d'une poignée de slash commands à un ensemble de commands, hooks, skills, agents et rules qui travaillent ensemble — un parcours d'apprentissage concret, et les cas où un script déterministe bat une question posée à l'IA.

---
![Claude Code - From Slash Commands to a Layered Workflow](/img/v2/vibe_coding_claude.webp)

**TL;DR**

Le dossier `.claude/` de ce blog est passé, en quelques mois, d'une poignée de slash commands à un
système à cinq couches : des commands que vous tapez, des hooks qui bloquent ou approuvent
automatiquement de façon déterministe, des skills qui encodent ce que « bon » veut dire, des agents
qui le vérifient dans un contexte isolé, et des rules qui imposent la version courte
automatiquement. Cet article suit l'ordre dans lequel tout a réellement été construit, à quoi sert
chaque couche, un exemple réel de chacune, et pourquoi un simple script Bash — sans la moindre IA —
est parfois la meilleure brique possible.

Comme tout débutant, mes premières sessions Claude Code sur ce blog commençaient toutes de la même
façon, jour après jour : je réexpliquais, dans le chat, à quoi ressemble un bon script Bash ici, ce
qu'un Dockerfile ne doit jamais faire, ou comment numéroter un nouveau fichier TODO sans entrer en
collision avec un existant. Claude faisait le travail, mais c'était moi qui portais le règlement —
retapé, session après session.

Mon dossier `.claude/` ne ressemble plus du tout à ce qu'il était. Aujourd'hui, il contient 15 slash
commands, 4 agents reviewers en lecture seule, 10 skills, 3 rules toujours actives, et quelques
scripts Bash qui ne touchent jamais à l'IA. Rien n'est arrivé d'un coup — chaque couche est apparue
pour résoudre un problème que la précédente ne savait pas traiter.

## À quoi ressemblent ces cinq couches en pratique {#what-five-layers-look-like-in-practice}

Cinq ingrédients rendent tout cela possible, et chacun a sa propre section plus bas — voici juste la
version courte, pour que la démo qui suit ne ressemble pas à une soupe d'acronymes :

**Cinq mots à garder en tête**

* **Command** — ce que vous tapez vous-même (`/quelquechose`).

* **Skill** — une checklist que Claude lit de lui-même, sans qu'on la lui demande.

* **Agent** — exécute cette checklist dans sa propre fenêtre séparée et restreinte, loin de votre conversation principale.

* **Rule** — se charge automatiquement dès qu'un fichier correspondant est ouvert.

* **Hook** — peut bloquer une action net, sans négociation possible.

Deux de ces cinq n'attendent jamais un humain : une **rule** se charge à l'instant où un fichier
correspondant s'ouvre, et un **hook** se déclenche à chaque appel d'outil correspondant — rien à
taper, et Claude n'a pas voix au chapitre. Les trois autres se déclenchent à la main, et
s'enchaînent comme le montre la démo ci-dessous : vous tapez une **command**, la command appelle un
**agent**, et l'agent applique une **skill** pour rendre le verdict — sachant, comme expliqué plus
loin, qu'une skill peut aussi se charger toute seule quand sa description colle à la tâche en cours,
sans aucune command dans la boucle.

Voici à quoi ressemble aujourd'hui une lecture critique d'un article de blog, une fois les cinq
couches en place, avant même sa publication :

> */review:reader git-precommit*

On n'a plus besoin de réexpliquer à Claude ce que « bon » veut dire pour un article de ce blog — la
skill `reader-first-docs` le détaille déjà, et `/review:reader` l'applique directement : quelle part
de l'article se trouve entre `<!-- truncate -->` et la première vraie preuve — une sortie de
commande réelle, pas seulement une étape d'installation ?

> `git-precommit` — sortie réelle dix lignes après `<!-- truncate -->`, soit environ 7 % du corps
> de l'article. Largement sous le seuil vert de 15 %, rien d'installé ni d'expliqué avant.
> Verdict : OK, rien à restructurer.

Cette checklist — mettre la valeur en avant, marquer les approfondissements comme optionnels, garder
l'installation hors de la première moitié — n'a jamais eu à être retapée, des mois après avoir été
écrite une première fois.

Et un détail à souligner avant tout le reste : toutes les commands de ce workflow ne reposent pas
sur Claude. `/todos:add`, la command qui ajoute une nouvelle entrée au backlog, a d'abord besoin du
prochain ID libre — un pur problème de comptage. Plutôt que de demander au modèle de parcourir
`.todos/` et de compter, elle appelle un simple script :

```bash
$ .claude/scripts/todo_next_id.sh .todos
0105
```

> **Bonne pratique : un script vaut mieux qu'un prompt:**
>
> Dès qu'une étape relève de la pure logique — compter des fichiers, incrémenter un ID, parser une
> date, vérifier un verrou — écrivez-la comme un simple script et faites-le appeler par la command.
> C'est déterministe (la même entrée donne toujours la même sortie), ça coûte zéro token, et ça se
> teste tout seul, sans modèle dans la boucle. Gardez l'IA pour les étapes qui demandent vraiment du
> jugement.

## Pourquoi une organisation en couches fonctionne {#why-a-layered-setup-works}

* **Une seule couche se tape.** Les slash commands sont le seul élément auto-découvrable —
  l'autocomplétion les trouve. Skills, rules, agents et hooks restent invisibles jusqu'à ce que
  quelque chose les déclenche ; ils façonnent *la manière* dont Claude travaille sans alourdir ce
  que vous devez penser à invoquer.
* **Chaque couche répond à une question différente.** Une skill décide *ce que* « bon » veut dire.
  Un agent décide *qui* le vérifie, et dans quel isolement. Une rule décide *quand* la version
  courte s'applique automatiquement. Un hook décide *ce qui est tout simplement interdit* — aucun
  jugement, aucune façon de faire changer le modèle d'avis.
* **Les agents en lecture seule gardent le bruit de review hors de la conversation qui compte.**
  C'est une économie de tokens qui vient de l'architecture, pas de la discipline de prompting — un
  bon complément aux astuces de gestion de session de [une
  précédente série de conseils pour économiser des tokens](/blog/claude-ia-spare-tokens), centrée sur `/clear`, `/compact`
  et la taille de `CLAUDE.md`.
* **Une rule n'est volontairement pas une explication complète.** C'est un extrait compressé
  DO/DON'T de la skill sœur, chargé uniquement quand un fichier correspondant est réellement ouvert
  — le raisonnement complet reste à un clic, pas dans chaque fenêtre de contexte.
* **La couche la moins chère de toutes n'appelle pas le modèle.** Un simple script, comme
  ci-dessus, est le socle sur lequel tout le reste se construit.

## Commencez ici : votre première slash command {#start-here-your-first-slash-command}

Les slash commands ont été le point d'entrée facile, et elles le restent — un fichier Markdown avec
un petit en-tête frontmatter, détecté automatiquement dès qu'il atterrit dans `.claude/commands/`.
Par exemple, créez simplement le fichier `.claude/commands/pr-description.md` dans votre projet avec
ce contenu :

**.claude/commands/pr-description.md**

```markdown
---
description: Draft a pull-request description from the current branch's diff against main
argument-hint: "[base-branch] (optional — defaults to main)"
allowed-tools: Bash(git diff*), Bash(git log*), Read
---

# PR description

Look at `git diff $ARGUMENTS...HEAD` and `git log $ARGUMENTS...HEAD --oneline`. Draft a pull-request description: a one-paragraph summary of the "why", then a bulleted "what changed". Do not include a test plan unless the diff touches test files.
```

Tapez maintenant `/pr-description` dans une session Claude Code, et ce prompt s'exécute avec le diff
courant déjà sous les yeux — rien à retenir, rien à réexpliquer. Deux détails du fichier ci-dessus
comptent plus qu'ils n'y paraissent :

* **`argument-hint`** est ce que l'autocomplétion affiche avant même que vous ayez fini de taper la
  command — de la documentation à bas prix pour votre futur vous.
* **`allowed-tools`** limite le rayon d'action avant que la command ne s'exécute. Celle-ci peut lire
  l'historique Git et des fichiers, et rien d'autre — elle ne peut rien modifier, même par erreur.

Trois commands réelles issues du dossier `.claude/commands/` de ce blog montrent la même forme à
différentes tailles. `/review:bash` est à peine plus qu'un pointeur vers l'agent traité plus loin
dans cet article :

**.claude/commands/review/bash.md**

```markdown
---
description: Audit this project's Bash scripts against the bash-best-practices skill (read-only)
argument-hint: "[path] (optional — defaults to the whole project)"
allowed-tools: Agent
---

# Bash review

Invoke the `bash-best-practices-reviewer` agent to audit **$ARGUMENTS** (or the whole
project if no argument is given) against the `bash-best-practices` skill, and against
this project's own bash-conventions rule/skill too, if it ships one.

Read-only: the agent reports violations with file:line, it never edits.
```

`/links` maintient la convention de maillage interne de ce blog — 2 à 4 liens inline par article,
plus un lien réciproque dans l'article plus ancien vers lequel il pointe :

**.claude/commands/links.md**

````markdown
---
description: Add missing internal links to an article (inline, in the prose) and the reciprocal link in the older post.
argument-hint: "[path or slug of an article — omit to use the articles changed in the working tree]"
allowed-tools: Read, Glob, Grep, Bash, Edit
---

# Add internal links to an article

The blog's cross-linking rule lives in `AGENTS.md` ("Blog Content Guidelines" → Internal links):
every article carries **2 to 4 internal links, inline in the prose**, plus a reciprocal link in the
older article when it makes sense. The `internal-links` job of `.github/workflows/quality.yml`
flags articles that link nowhere; this command is the fix.

## 1. Decide which articles to work on

`$ARGUMENTS` may be an article folder (`blog/2026/07/27/reactions`), an `index.md`, a slug
(`reactions`), or a `.unpublished/` draft. A slug has to be resolved first:

```bash
grep -rl "^slug: $ARGUMENTS$" blog .unpublished --include=index.md --include=index.mdx
```

If `$ARGUMENTS` is empty, work on the articles currently modified or added in the working tree:

```bash
git status --porcelain -- 'blog/**/index.md*' '.unpublished/**/index.md*' | awk '{print $NF}'
```

If that is empty too, fall back to the last commit
(`git diff --name-only --diff-filter=ACM HEAD~1 HEAD -- 'blog/**/index.md*' '.unpublished/**/index.md*'`)
and, before editing anything, tell the user which articles you picked.

## 2. Run the check

```bash
yarn links:check <path>
```

It prints the internal links already present, marks with `!!` any link pointing to no published
article (a wrong slug — fix those), and lists candidate targets ranked by score.

When the local AnythingLLM instance answers, the output carries a second signal, **meaning**:

- each word-based candidate gets a `meaning: close (0.xx)` or `meaning: not close` line, and the
  close ones are listed first — a `not close` candidate matched on words only ("command line",
  "file") and is usually noise;
- a final list, *Close in meaning, not linked*, names articles about the same topic that the
  word-based pass missed, with the section of this article they relate to — the place a link
  would go.

If that block is missing, AnythingLLM is off: the lexical list alone is still valid. A stale index
(`ai-index` not run since the latest posts) can miss recent articles as targets.

## 3. Choose the links — judgment, not the score

**The candidate list is a hint, not a spec.** It ranks on shared words and tags, so it happily
proposes four unrelated posts that merely say "docker image", while missing the one link the prose
is begging for. The meaning signal narrows that gap; it does not close it. So:

- Read the article. Look for the moments where it *names* something covered elsewhere: a tool, a
  prerequisite, a concept explained in an earlier post, a natural follow-up.
- Cross-check the memory blog map (`/home/node/.claude/projects/-opt-docusaurus/memory/project_blog_map.md`)
  for same-`mainTag` and same-series posts the script may have scored too low.
- Aim for 2 to 4 links. Prefer few and relevant over many and weak — a link the reader does not
  need is noise, and this rule exists to help readers, not to satisfy a counter.
- If the article genuinely has no relevant neighbour, say so instead of forcing a link.

## 4. Insert them

- Inline, **at the first place the term appears in the prose**. Never a "See also" list at the end:
  the `RelatedPosts` grid already covers the bottom of the page.
- Use the globally registered component, no import needed:
  `<Link to="/blog/slug">the natural words already in the sentence</Link>`.
- Do not rewrite the sentence around the link, and do not add a sentence just to host one. If no
  sentence can host it naturally, that link was not the right one.
- Never link inside a fenced code block, a heading, or frontmatter.
- Add the **reciprocal link** in the older article when it fits its prose — that is what keeps old
  posts alive. Same rules apply there.

## 5. Verify

```bash
yarn links:check <path>          # for each article touched, including the reciprocal ones
```

Every link must point to an existing slug (no `!!` lines). If several articles were edited, or if
any doubt remains on a slug, run `npx docusaurus build` — it fails on broken internal links.

## 6. Report

For each article: the links added, with the anchor text and the target slug, and where the
reciprocal link went. Mention explicitly any article you deliberately left with fewer than two
links, and why.
````

Et `/todos:add` est la command derrière le script `todo_next_id.sh` vu plus haut — sa ligne
`allowed-tools` nomme explicitement le script, si bien que la command peut l'appeler mais rien
d'autre :

**.claude/commands/todos/add.md**

````markdown
---
description: Capture a new task into the .todos/ backlog as one well-formed NNNN-slug.md file
argument-hint: <short task description>
allowed-tools: Bash(ls*), Bash(find*), Bash(.claude/scripts/todo_next_id.sh*), Read, Write
---

# Add a TODO to the backlog

Capture the task described in **$ARGUMENTS** as a single, well-formed backlog file.
Do NOT implement anything — this only creates the entry. The header bullets below follow the
**`todo-authoring`** skill's contract — load it if any field's meaning is unclear (e.g. what counts
as a valid `Depends` value).

## Procedure

1. **Resolve the description.**
   - If `$ARGUMENTS` is empty, ask the user for a one-line task description and stop
     until you have it.
   - Derive a concise Title (imperative, ≤ ~10 words) and a kebab-case `slug`
     (lowercase, alphanumerics + dashes, no leading article, ≤ ~6 words).

2. **Locate the backlog.**
   - Use `.todos/` at the repository root. If it doesn't exist, create it.

3. **Dedupe (cheap guard).**
   - List existing `.todos/*.md` and read their Titles (first `# ` heading).
   - If the new task clearly duplicates an existing entry, STOP and tell the user
     which file already covers it — don't create a second one.

4. **Compute the next ID.**
   - Run `.claude/scripts/todo_next_id.sh .todos` — it scans every `.todos/*.md`
     file (including status subfolders and any numeric-prefixed name like
     `000_...`) and prints `max + 1`, zero-padded to 4 digits (`NNNN`). Never
     compute this by hand, and never reuse an existing ID. (Legacy 3-digit IDs
     from before this width change stay as they are — never rename them.)

5. **Write `.todos/NNNN-<slug>.md`** with the skeleton below. The filename is an
   **identity**: never encode priority, status, or batch in it.

   ```markdown
   # NNNN — <Title>

   - **Priority**: medium
   - **Batch**: unassigned
   - **Depends**: —
   - **Files**: TBD

   ## Context

   <1–3 sentences: what and why, drawn from the description and the current conversation.>

   ## Acceptance

   - [ ] <concrete, verifiable outcome>
   ```

   Fill Context/Acceptance from `$ARGUMENTS` and the conversation. Leave
   `Priority` / `Batch` / `Depends` / `Files` at their defaults for the user to
   adjust — these header bullets are what `/todos:plan` parses.

6. **Report and hand off.**
   - Print the created path and the Title.
   - Remind the user that `.todos/plan.md` is a generated projection — run
     `/todos:plan` to regenerate it. Offer to run it now.
````

## Tracer une limite infranchissable : les hooks {#draw-a-hard-line-hooks}

Le prompt d'une slash command reste une instruction que Claude lit et dont il peut, dans une session
suffisamment longue, discrètement s'écarter — comme une personne oublie une consigne donnée une
heure et de nombreux messages plus tôt. Un **hook** fonctionne autrement : c'est une commande shell
que le harness Claude Code (le programme qui exécute la session, distinct de Claude lui-même) lance
automatiquement à un point fixe — avant que Claude lise un fichier ou exécute une commande (ce que
Claude Code appelle un *tool call*), juste après, ou à la fin de la session — et son code de sortie
décide de la suite. Claude n'a aucun mot à dire dans cette décision. C'est la même idée qu'un hook
Git `pre-commit` dans [un article précédent](/blog/git-precommit), une couche plus
bas : celui-là intercepte un commit, celui-ci intercepte un seul tool call.

Le fichier `.claude/settings.json` de ce blog n'en contient encore aucun — il ne porte qu'une liste
`permissions.allow`. Les hooks ont gagné leur place sur un autre projet, en Python, qui lance `ruff`
et `pytest` en permanence pendant une session. Réduit à la mécanique de base :

**.claude/settings.json (illustrative — Python/Bash project)**

```json
{
  "permissions": {
    "deny": ["Read(./.env)", "Read(./**/*.key)", "Read(./secrets/**)"]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "cmd=$(jq -r '.tool_input.command'); if [[ \"$cmd\" == pytest* ]]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\"}}'; fi"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "f=$(jq -r '.tool_input.file_path'); if [[ \"$f\" == *.py ]]; then ruff check \"$f\"; fi"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [{ "type": "command", "command": "pytest -q" }]
      }
    ]
  }
}
```

Quatre idées à reprendre, quel que soit le langage du projet :

* **`permissions.deny`** bloque net `Read` sur les secrets — un hook n'est même pas sollicité, le
  tool call n'a jamais lieu.
* **`PreToolUse`** approuve ici automatiquement `pytest` en particulier, pour qu'une session longue
  et soignée ne s'arrête pas sur une demande de confirmation chaque fois que la suite de tests
  tourne.
* **`PostToolUse`** réagit dès qu'un fichier Python est écrit ou modifié : il lance `ruff check` sur
  ce seul fichier, immédiatement, au lieu d'attendre que le lecteur — ou un job de CI — s'en
  aperçoive plus tard.
* **`Stop`** se déclenche une fois, quand Claude considère une tâche terminée et s'apprête à rendre
  la main — le dernier point de contrôle, pas un par édition. Ici, il relance toute la suite de
  tests ; si `pytest` échoue, le code de sortie non nul du hook indique à Claude que son tour n'est
  pas vraiment fini, et pourquoi. C'est le seul hook capable de contredire un « j'ai terminé ».

> **Un hook est un plancher, pas un substitut:**
>
> Un hook ne se déclenche que sur la forme de tool call à laquelle il est associé — il ne comprend pas
> l'intention comme le fait un modèle qui suit une skill. Réservez-le à la poignée de règles qui ne
> doivent jamais être négociables (secrets, lint ou tests obligatoires) ; laissez tout ce qui demande
> du jugement à une skill.

## Encoder vos standards : les skills, vérifiés par des agents {#encode-your-standards-skills-checked-by-agents}

Une **skill** est l'endroit où « du bon Bash » ou « un bon Dockerfile » est réellement écrit — une
fois, comme source unique de vérité, au lieu d'être retapé dans le chat à chaque session. Voici la
vraie skill `bash-best-practices` de ce blog, en intégralité — elle commence par un champ
`description` et un flag `disable-model-invocation`, puis les conventions elles-mêmes :

**.claude/skills/bash-best-practices/SKILL.md**

````markdown
---
name: bash-best-practices
description: Generic Bash best practices — quoting, set -o nounset/errexit, [[ ]] vs [ ], $() vs backticks, explicit error handling, shellcheck-clean patterns, and common pitfalls. Language-level rules, not any one project's conventions.
disable-model-invocation: false
---

# Bash Best Practices

Generic Bash rules applicable to any project. This skill covers only what's true of Bash itself —
check whether the current project has its own conventions skill (logging system, sourcing pattern,
function naming, status files, etc.) and defer to it for anything project-specific.

## Recommended script setup

​```bash
#!/usr/bin/env bash
set -o nounset
set -o errexit
​```

A widely-used default for scripts that should fail fast and never silently operate on an unset
variable. Not universal law — some scripts deliberately omit `errexit` because they must keep going
despite a failing step. Check what the current project actually does before assuming.

`pipefail` (`set -o pipefail`) is a separate opt-in: without it, a failing command in the middle of
a pipe does not by itself abort the script. Check whether the current project sets it before relying
on that behavior.

## Variables and scoping

**Local scope, `readonly` unless reassigned:**

​```bash
function my_function() {
    local -r arg1="${1}"
    ...
}
​```

**Undefined variable error (under `set -o nounset`):**

​```bash
echo "${UNDEFINED_VAR}"             # Error under nounset
value="${UNDEFINED_VAR:-default}"   # OK: provides a default
​```

## Error handling — principle

Propagate, log, and return/exit explicitly — never swallow an error silently:

​```bash
# Bad: swallowed error
result=$(some_command) || echo "Error"           # Error not propagated

# Good: propagate, log, and return/exit explicitly
result=$(some_command) || { echo "Command failed" >&2; return 1; }
​```

Guard-clause functions (argument-count/non-empty checks at the top of a function, returning or
exiting early on failure) are a robust, common pattern. Check whether the current project already
has its own assertion helpers before writing ad hoc `[[ ]]` checks everywhere.

## Bash patterns (shellcheck-clean)

**Variable quoting (SC2086):**

​```bash
# Bad: unquoted variable splits on whitespace and globs
files=$file_list
for f in $files; do ...   # a filename with a space becomes two iterations
rm $file                  # if $file="a b", removes two files

# Good: quote scalars; use a real array to iterate a list
rm "${file}"
files=(a b "c d")               # array, not a space-joined string
for f in "${files[@]}"; do ...  # each element stays intact, even with spaces
​```

Note: `for f in ${var}` (braces but no quotes) still word-splits — braces are not quoting. Quote
(`"${var}"`) or use an array (`"${arr[@]}"`).

**Command substitution:**

​```bash
# Preferred
result=$(command)

# Avoid (deprecated)
result=`command`
​```

**Conditional:**

​```bash
# Good
if [[ -f "${file}" ]]; then ...
if [[ "${var}" == "value" ]]; then ...

# Avoid
if [ -f ${file} ]; then ...  # Unquoted, uses [ instead of [[
​```

**Checking `$?` explicitly (SC2181)** — prefer checking a command's exit status directly:

​```bash
# Preferred
if command; then ...

# If you must defer the check (e.g. after an intermediate command that consumes it),
# justify the shellcheck disable inline
# shellcheck disable=SC2181
if [[ $? -ne 0 ]]; then ...
​```

## Shellcheck directives

Use sparingly, and always justify inline with a comment — a disable without a reason invites someone
to copy it somewhere it doesn't apply.

## Common pitfalls

| Pitfall | Fix |
| --- | --- |
| `if $? -eq 0` (checking exit after other commands ran in between) | Check immediately: `if command; then ...`, or `# shellcheck disable=SC2181` right before the check if you must defer it |
| Unquoted variables | Always quote: `"${var}"` |
| Assuming `pipefail` is set | It's an explicit opt-in (`set -o pipefail`), not a Bash default — check the current project |
| `` result=`command` `` (backticks) | Prefer `result=$(command)` — nests cleanly, easier to read |


## Pipes (SC2094)

```bash
# Good
grep pattern < file | sort

# Bad (sometimes)
sort < file | grep pattern > file  # Overwrites input
```

## String escaping for generated scripts

When Bash writes shell code (e.g., via Jinja2 template), escape carefully:

```bash
# Template generating a shell script
cat > "${output_script}" << 'EOF'
#!/bin/bash
log::info "Running ${job_name}"
EOF
# Single-quote the heredoc (EOF) to prevent variable expansion
```

---

Remember: Bash is fragile. Defensive programming (quoting, guard clauses, explicit error handling)
prevents silent failures.
````

Le champ `description` a un double rôle : c'est ce qu'un humain lit pour savoir ce que couvre la
skill, et c'est ce que Claude compare à la tâche courante pour décider de la charger *sans qu'on le
lui demande*. Un flag contrôle cela : `disable-model-invocation: false` garde le chargement
automatique actif ; passez-le à `true` et la skill ne se charge que si une command l'appelle
explicitement.

Une skill seule n'est qu'une mémoire plus stricte. Ce qui l'a transformée en véritable audit, c'est
un **agent** assorti — un sous-agent avec un nom, un jeu d'outils fixe et étroit, et une seule
mission :

**.claude/agents/bash-best-practices-reviewer.md**

````markdown
---
name: bash-best-practices-reviewer
description: Read-only audit of a project's Bash scripts against the bash-best-practices skill and a configurable size ceiling. Checks set -euo pipefail, grep-under-pipefail safety, heredoc placement, and file size; reports violations with file:line, never edits.
tools: Read, Grep, Glob, Bash
model: sonnet
---

# Bash Best-Practices Reviewer

Read-only sweep of a project's shell layer against the `bash-best-practices` skill — read it first, it
is the source of truth; this agent only says where to look and how to report. **Detection only:
report violations, never edit.** A project may also ship its own bash-conventions rule/skill with
extra gates (exit-code contract, sourcing/loader pattern, project-specific traps) — read it too if
present and fold its gates in.

## Why an isolated agent

The sweep reads many shell files that are irrelevant to the rest of the work. Running it in a bubble
keeps that noise out of the main context — only the findings return.

## Scope

Default to every tracked `*.sh` under the project's shell directories. If the project names specific
roots (a bash layer that ships into containers, a scripts dir), restrict to those.

## Methodology

Read `bash-best-practices` (and any project bash rule), then check each script:

1. **Mandatory safety header.** The project's shebang + `set -euo pipefail`. Flag any executable
   script missing `set -euo pipefail`.
   ```bash
   for f in $(grep -rl '' . --include='*.sh'); do
     grep -q 'set -euo pipefail' "$f" || echo "MISSING pipefail: $f"; done
   ```
2. **`grep` under pipefail.** A `grep` whose no-match exit (1) would abort the script must end with
   `|| true`. Flag bare `grep` in assignments / conditionals that can legitimately match nothing.
3. **Heredocs / inline multiline text.** Long heredoc prose belongs in a templates directory,
   referenced by path — not inline. Flag large heredocs.
4. **Size ceiling.** Report any `.sh` over the project's soft ceiling (default ~300 lines) as a split
   candidate along function seams (soft, not a hard failure).
   ```bash
   find . -name '*.sh' -exec wc -l {} + | awk '$1>300'
   ```
5. **Project gates.** If the project's bash rule defines an exit-code contract, a sourcing/loader
   pattern, or known traps, check those too and cite the rule.

## Output format

Group by severity.

### Summary
Scripts swept; blocking vs soft violations.

### Blocking violations
```
❌ path/to/foo.sh:12  missing `set -euo pipefail`
```

### Anomalies (grep||true, heredoc, project traps)
```
⚠️ path/to/baz.sh:70  grep without `|| true` under pipefail
```

### Size overruns (soft)
```
📏 path/to/qux.sh  341 lines (>300) — split candidate along function seams
```

### Final confidence
Sweep complete? Zero violations is a valid outcome.
````

Deux choix de conception pèsent plus lourd que la longueur du fichier ne le suggère :

* **`tools: Read, Grep, Glob, Bash`** — pas d'`Edit`, pas de `Write`. Cet agent ne peut
  physiquement pas modifier un fichier, ce qui veut dire qu'une review ne peut jamais dériver
  accidentellement en réécriture, et qu'une chaîne hostile enfouie dans un fichier relu ne peut pas
  l'y pousser non plus.
* **Un agent n'est accessible qu'à travers une slash command correspondante** — `/review:bash` est
  la seule porte d'entrée. Il n'y a aucune entrée d'autocomplétion pour l'agent lui-même ; la
  découvrabilité vit entièrement dans la couche command, la logique de review entièrement dans la
  couche agent.

Le même motif se répète pour `python-best-practices` (couplée à `python-best-practices-reviewer`,
surtout utile sur les points que [`ruff` et `mypy` n'attrapent pas
déjà](/blog/python-qa)) et pour `dockerfile-best-practices`. Une skill, un agent étroit, une command — une
forme qui passe à l'échelle pour n'importe quel langage ajouté ensuite.

### Un cas concret : le contrôle de Time-to-Value de ce blog {#a-concrete-case-this-blogs-own-time-to-value-check}

Tous les couplages vus jusqu'ici relisent du code. Sur ce blog, avant de publier un article, je
lance `/review:reader` — il charge la skill `reader-first-docs` et vérifie la structure du post. L'un
des critères mesurés est le Time-to-Value : dans les 30 premières secondes de lecture environ, un
lecteur doit déjà comprendre ce que couvre l'article et s'il vaut la peine de continuer. Un score
TTV *élevé* signifie l'inverse — le lecteur a dû traverser trop de texte avant d'arriver au moment
« ah, *voilà* de quoi parle l'article ». Voici la skill en intégralité :

**.claude/skills/reader-first-docs/SKILL.md**

````markdown
---
name: reader-first-docs
description: Time-to-value audit methodology for Docusaurus blog articles — front-load proof, keep install/code before the first result visible, measure TTV mechanically from line positions. Defines the four passes (mechanical, repetition, author's-journey noise, qualitative), the metric thresholds, and the OK/MINOR/RESTRUCTURE verdict. Docusaurus-specific (MDX components, <!-- truncate --> anchor, AlertBox, Terminal, Snippet). For the audit workflow (journal, batch selection, TODO filing), invoke via /review:reader, not directly.
disable-model-invocation: false
---

# Reader-First Docs — Docusaurus Blog

Load before auditing an article under `blog/` or `.unpublished/`. This skill defines the
**methodology** only — what to measure and how to judge. The `/review:reader` command owns the
workflow: which articles to audit, in what order, and how to track progress across sessions.

The target structure every article should converge toward is defined in `blog-post-structure` —
load it too if the nature of a required restructure is unclear. While reading an article's
`<Terminal>`/`<Snippet>` blocks for Pass 1 below, also note a value the reader would change (a
port, a container name, a version) hardcoded and repeated across 2+ commands instead of declared
once with `<Vars>` — `blog-post-structure`'s self-check names this explicitly; a `/review:reader`
pass is a natural place to catch an older article that predates the component.

## The principle: Time-to-Value (TTV)

A reader arrives with one question: *should I keep reading, and can I get value fast?* Every line
they scan before seeing a result is a line they might leave on. Structure the article so the
**result comes first** — literally, in reading order — not after the prerequisites, the theory,
or the install section.

A Docusaurus article has a natural dividing line: `<!-- truncate -->`. Everything before it is the
excerpt the reader sees on the blog index page. Everything after is the body the committed reader
opens. The body must prove its value **early** — not after a long install section, not after a
code dump the reader has no context for, not after a recap of what they just read in the TLDR.

## Article structure anchor points

```bash
grep -n '<!-- truncate -->\|^## \|<Terminal\|<Snippet\|<Prerequisite\|apt install\|^!\[\|```mermaid\|```plaintext\|<ProjectSetup\|<StepsCard\|<QuickJump' <path>
wc -l <path>
```

Let `T` = line of `<!-- truncate -->`, `E` = last line of the file, `BODY = E − T`.

**`<QuickJump>` is scaffolding, not content.** If a `<QuickJump ... />` sits between `T` and the
first real content line, move `T` to the line right after it before computing anything — it never
counts as install/abstraction/proof and never inflates `BODY` or the TTV percentage. Treat it the
same way the excerpt before `<!-- truncate -->` is treated: present, useful to the reader, outside
the measurement.

All three passes below work from `T` as the zero point. The excerpt before `T` is not evaluated —
it is already the reader's entry point and has its own quality signal (the 30-second test reads it).

## Pass 1 — Mechanical (position-based, not a full read)

Compute from line positions only. This is what keeps a batch cheap.

**"Proof"** is the first of these strictly after `T`:

- a `<Terminal>` showing **output** — not one whose content is only an install command
  (`apt install`, `yarn add`, `docker pull`);
- an image of a result (`![…](./images/…)`), excluding the banner image above the TLDR;
- a `plaintext` / `mermaid` block showing the outcome;
- a before/after pair.

When in doubt about a `<Terminal source="./files/x.txt">`, read that one file — it is short.

If no proof exists anywhere in the article body, `TTV = 100%` — say so explicitly.

| Metric | Computation | Threshold |
| --- | --- | --- |
| **Time to value (TTV)** | `(proof_line − T) / BODY` | 🟢 < 15% · 🟠 < 30% · 🔴 ≥ 30% |
| **Install-before-proof** | a `<Prerequisite>`, `apt install`, or `## Prerequisites` heading sits before `proof_line` | red flag (binary) |
| **Abstraction-before-proof** | a `<Snippet>` of implementation sits before `proof_line` | red flag (binary) |
| **Unmarked deep-dive** | a section covering internals whose title carries no "optional / skip / under the hood" signal | count |
| **Landing** | do the last three paragraphs recap *and* point somewhere next? | yes / no |

## Pass 2 — Repetition ("is this information new?")

TTV gets the reader in; repetition drives them back out. An article that says the same thing four
times reads as padded, and it is usually 15–20% longer than it needs to be.

Method — mechanical, no full read required:

1. Extract the article's **core claims**: take the `<TLDR>`, the `## ` headings, and any
   takeaways card. That is typically 4 to 6 distinct facts.
2. `grep -in` a distinctive phrase for each claim across the file, and count where it lands.
3. Measure the weight of each section:

   ```bash
   awk -v t=$T 'NR>=t' <path> | awk '/^## /{if(s)print s" | "w" words"; s=$0; w=0; next} {w+=NF} END{print s" | "w" words"}'
   ```

| Metric | Computation | Threshold |
| --- | --- | --- |
| **Redundancy** | occurrences of the most-repeated core claim | 🟢 ≤ 3 · 🟠 4-5 · 🔴 ≥ 6 |
| **Dead-weight block** | a summary/takeaways block whose every bullet restates a fact already in the body | yes / no |
| **Duplicated warning** | two `<AlertBox>` carrying the same caution in different sections | count |

Frequent shapes worth naming in the report:

- a takeaways card mirroring the body one-for-one;
- a bullet list whose items rephrase the section headings (adds no new clause);
- an intro sentence that restates its own heading;
- a step-by-step list followed by one subsection per step (the list was the summary, the sections
  are the depth — the list is redundant, cut it or turn it into a `<StepsCard>`);
- the same caution in two `<AlertBox>` — a classic side effect of a past restructuring.

**Count facts, not words.** A short section that adds nothing is worse than a long one that teaches
something. Say what a further cut would *cost* rather than chasing a percentage — if the remaining
length is all information, say so plainly. A section being long is not by itself a finding.

## Pass 2b — Author's-journey noise

Repetition pads an article; the author's journey *discourages* the reader. A guide that keeps
saying "this surprised us" reads as "this is fragile, you will suffer too" — and the reader leaves
before the payoff. Hunt it separately, because pass 2 will not catch it: each sentence is unique,
so redundancy scores 🟢 while the article still bleeds readers.

Mechanical sweep:

```bash
grep -niE "found out|turns out|I tried|hard way|bit me|at first|I expected|I only know|to my surprise|my first attempt|worth knowing you" <path>
```

Then classify each hit — the grep finds candidates, it does not judge them:

| Shape | Verdict | Fix |
| --- | --- | --- |
| First-person **judgment** — "I reach for the light one by reflex" | keep | this is the blog's voice |
| First-person **chronology** — "I found out the hard way while building it" | cut | keep the fact, drop the story |
| A bug the article already works around in its own script | cut from prose | the explanation belongs in the script's comment |
| Engineering choices justified to the reader — why a layer sits where it sits, what was rejected | cut | that is a code-review conversation |
| A demonstrated result then hedged — "that is a stronger claim than it looks" | cut the hedge | state the limitation once, flatly, as a property of the tool |

| Metric | Computation | Threshold |
| --- | --- | --- |
| **Journey noise** | passages classified *cut* above | 🟢 0 · 🟠 1-2 · 🔴 ≥ 3 |
| **Failure stories** | `<AlertBox>` whose subject is something that went wrong | 🟢 ≤ 1 · 🔴 ≥ 2 |

Full rationale: `writing_style` memory (*No Trial-and-Error Narrative*), [[feedback_article_weight]],
and `.claude/rules/blog-prose.md`.

## Pass 3 — The 30-second test (qualitative)

Read **only** the title, the frontmatter `description`, and lines `T` to `T + 40`. Nothing else.
Then answer as someone who has never heard of this tool:

- **Do I keep reading, or do I leave?**
- **Why**, in one sentence.
- What is the article asking me to do before it has shown me anything?

This is the heart of the audit. The mechanical metrics of passes 1 and 2 only make the judgment
reproducible from one article to the next — they never override it. An article can score 12% TTV
and still lose the reader if the opening never says what the thing is *for*.

## Verdict

Exactly one per article:

| Verdict | Condition | Action |
| --- | --- | --- |
| `OK` | proof within the first screen, no red flag, redundancy 🟢, the 30-second test says "keep reading" | journal only |
| `MINOR` | sound structure, one weakness (unmarked deep-dive, flat landing, proof slightly late but 🟠, redundancy 🟠, one dead-weight block, journey noise 🟠) | journal + one-line note, **no TODO** |
| `RESTRUCTURE` | TTV 🔴, **or** install-before-proof, **or** abstraction-before-proof, **or** redundancy 🔴, **or** journey noise 🔴, **or** failure stories 🔴, **or** the 30-second test says "I leave" | one TODO |

`MINOR` never produces a TODO: a batch sweep over 40+ articles would bury real findings under
one-line files for every article with a single flat ending.

For `RESTRUCTURE`, file one TODO (format and language: see `/review:reader`). The proposed fix is
always a **reorder**, never a cut — name which existing section/line range each reordered block
comes from, so nothing reads as discarded.

**One exception: journey noise.** There the fix *is* a cut, because the passage carries no reader
value to relocate. Quote each offending sentence in the TODO with its line number and the fact it
was carrying, so the cut is reviewable rather than a blanket "trim the prose".

## What this does NOT mean

- **Don't delete reference material.** TTV is about *order*, not *completeness*. Advanced sections
  still have a place — they just aren't the first thing every reader sees.
- **Don't compress the opening into a one-liner.** A stranger must understand what the article
  teaches without already knowing the tool — not just recognize it from the title.
- **A long section is not a finding.** A deep-dive covering internals is legitimately the bulk of a
  good technical article; flag it only when its title carries no signal that it is optional.
````

La métrique centrale, le Time-to-Value, est mécanique, pas une impression : on trouve `T`, la ligne
de `<!-- truncate -->` ; on trouve la première vraie *preuve* après elle — une sortie de commande
réelle, jamais une simple étape d'installation ; on calcule `TTV = (proof_line − T) / BODY`, où
`BODY` est tout ce qui suit `T`. C'est exactement le calcul derrière la démo d'ouverture : sur
[l'article sur le pre-commit](/blog/git-precommit), la sortie réelle arrive dix
lignes après `<!-- truncate -->`, `BODY` fait 148 lignes, donc `TTV ≈ 7 %` — largement sous le seuil
vert de 15 % défini par la skill elle-même.

Trois paliers transforment ce pourcentage en verdict : 🟢 `OK` sous 15 %, 🟠 `MINOR` sous 30 % (une
note de journal, jamais un TODO, et c'est voulu — sinon un passage sur 40 articles enterrerait les
vraies trouvailles sous des one-liners pour chaque post à la conclusion simplement plate), 🔴
`RESTRUCTURE` à 30 % ou plus, le seul palier qui crée un TODO.

> **Toutes les skills ne vont pas de pair avec un agent:**
>
> `/review:reader` ne délègue pas à un agent isolé comme le fait `/review:bash` — il a besoin d'un
> accès `Write` pour journaliser l'avancement d'un lot entier dans
> `.todos/0000-reader-review-journal.md`, donc il tourne inline, même skill, sans couche d'isolement.
> Un agent `reader-first-docs-reviewer` existe bien dans ce projet, mais il audite un autre périmètre
> (de la documentation longue comme un README, pas des articles de blog) et n'est encore relié à
> aucune command. La forme décrite dans la section précédente est courante, pas universelle.

## Toujours actives, sans invocation : les rules {#always-on-no-invocation-needed-rules}

Les skills et les agents attendent tous les deux un déclencheur — une tâche qui correspond, ou une
command tapée. Une **rule** n'attend rien : elle se charge à l'instant où un fichier correspondant
est ouvert ou modifié, par glob sur `paths`, sans aucune invocation.

Voici le vrai fichier — sa première ligne renvoie déjà vers l'endroit où vit réellement le
raisonnement :

**.claude/rules/bash.md**

```markdown
---
paths:
  - "**/*.sh"
  - "**/*.bash"
  - "**/.bash_aliases"
---

# Bash — always apply

Full rationale and more patterns: `bash-best-practices` skill. Verify: `shellcheck`/`shfmt`.

- ✅ DO: `set -o nounset` + `set -o errexit` (+ `pipefail` if the project relies on failing mid-pipe)
  at the top of every executable script.
- ✅ DO: quote every variable expansion — `"${var}"`, `"${arr[@]}"` for arrays.
  ❌ DON'T: `rm $file` / `for f in $files` — unquoted, splits on whitespace and globs.
- ✅ DO: `result=$(command)`. ❌ DON'T: `` result=`command` `` (deprecated backticks).
- ✅ DO: `if [[ -f "${file}" ]]`. ❌ DON'T: `if [ -f ${file} ]` (unquoted, `[` not `[[`).
- ✅ DO: propagate errors explicitly — `cmd || { echo "failed" >&2; return 1; }`.
  ❌ DON'T: `result=$(cmd) || echo "Error"` — swallows the failure, doesn't return/exit.
- ✅ DO: check exit status immediately — `if command; then`. Only defer to `$?` with an inline
  `# shellcheck disable=SC2181` justification.
- ✅ DO: a `grep` inside a `set -o pipefail` script that can legitimately match nothing ends with
  `|| true` — otherwise its exit 1 aborts the script.
```

Une rule ne répète pas le raisonnement de la skill, elle en extrait les paires DO/DON'T brutes dont
un modèle a besoin en pleine édition, et renvoie vers le *pourquoi*. La même forme couvre le
Markdown, en gardant le gras en `**astérisques**` et jamais en `__underscores__`, là où
[un linter volontairement désactivé](/blog/markdown-lint) laisserait sinon passer les
deux en silence.

J'ai écrit les rules en dernier, et je me suis ensuite à moitié demandé si elles n'auraient pas dû
venir en premier — une rule a l'air d'être la couche la plus « basique ». Mécaniquement, elle ne
l'est pas : une rule est *définie* comme un court extrait de sa skill sœur, donc la skill doit
exister avant qu'il y ait quoi que ce soit à extraire. Écrire les skills et les agents avant les
rules, même par accident, s'est révélé être le seul ordre qui fonctionne vraiment.

## Tout relier ensemble {#wiring-it-all-together}

Une fois que quelques langages ont partagé la même forme skill → agent → command, la carte est
devenue la documentation :

| Command          | Agent                                | Skill                       |
| ---------------- | ------------------------------------ | --------------------------- |
| `/review:bash`   | `bash-best-practices-reviewer`       | `bash-best-practices`       |
| `/review:python` | `python-best-practices-reviewer`     | `python-best-practices`     |
| `/review:docker` | `dockerfile-best-practices-reviewer` | `dockerfile-best-practices` |

Chaque ligne est indépendante et peut être ajoutée seule — une quatrième ligne pour un nouveau
langage coûte un fichier skill, un fichier agent et un fichier command, dont aucun ne touche aux
trois autres.

## Sous le capot (passez si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it}

### Pourquoi les agents reviewers ne peuvent pas éditer {#why-the-reviewer-agents-cant-edit}

`Read, Grep, Glob, Bash` et rien d'autre, ce n'est pas de la prudence pour la prudence. Tout le
travail d'un agent de review est de lire des fichiers qu'il n'a pas écrits et de les juger — dès
qu'il peut aussi `Edit`, le « détection seulement » cesse d'être une garantie imposée par la liste
d'outils pour devenir une consigne que le prompt se contente de demander. Garder les deux séparés
signifie qu'on peut faire confiance au rapport d'un reviewer pour être exactement cela : un rapport,
pas un diff déjà appliqué sans second regard.

### Le script à zéro token, une fois de plus {#the-zero-token-script-one-more-time}

`todo_next_id.sh` mérite un deuxième coup d'œil parce qu'il se généralise bien au-delà de la
numérotation des TODO :

**.claude/scripts/todo\_next\_id.sh**

```bash
#!/usr/bin/env bash
set -euo pipefail

# Prints the next .todos/ backlog ID, zero-padded.
#
# Scans every *.md under the given directory and its subfolders (DONE/,
# PARTIAL/, BLOCKED/, UNNEEDED/, POSTPONED/, ...), extracts the leading digit
# run of each filename regardless of a status prefix, and prints max + 1.
# Existing 3-digit IDs and new wider ones coexist fine — only the numeric
# value is compared, never the string width.
#
# The prefix is stripped by matching "everything up to the first digit",
# not by enumerating specific prefix words/separators — so it copes with a
# one-word prefix (DONE_, PARTIAL-), a multi-word one (WONT_DO_, wont-do-),
# or no prefix at all, uniformly. A digit run elsewhere in the filename
# (a year, an HTTP status code, ...) is never mistaken for the ID, because
# only the run immediately following that first stripped prefix is captured.
#
# Usage: todo_next_id.sh [todos-dir] [width]
#   todos-dir  defaults to ./.todos
#   width      zero-padding width for the printed ID, defaults to 4

todos_dir="${1:-.todos}"
width="${2:-4}"

max=0
if [[ -d "${todos_dir}" ]]; then
    highest="$(find "${todos_dir}" -type f -name '*.md' -printf '%f\n' |
        sed -nE 's/^[^0-9]*0*([0-9]{3,}).*/\1/p' |
        sort -n | tail -1)"
    [[ -n "${highest}" ]] && max="${highest}"
fi

# 10# forces base-10 parsing, otherwise a leading zero (e.g. 020) is read as octal
printf "%0${width}d\n" $((10#${max} + 1))
```

Deux lignes de `find`/`sed`/`sort` répondent à « quel est le prochain ID libre » avec une certitude
totale — aucun risque d'hallucination, puisqu'il n'y a aucun modèle sur le chemin. Son voisin
`todo_lock.sh` fait la même chose pour un problème plus dur, la concurrence : deux sessions Claude
Code qui courent après le même numéro de TODO le résolvent avec `mkdir` (atomique — il échoue si le
dossier existe déjà), pas en demandant à l'une des sessions de « vérifier d'abord ». Chaque fois
qu'une étape se réduit à *compter, parser, verrouiller, hasher, differ* — prenez un script avant de
prendre un prompt.

## Conclusion {#conclusion}

L'ordre qui a réellement fonctionné ici a été : commands, puis hooks, puis skills couplées à des
agents, puis rules — et avec le recul, ce n'est pas un accident à corriger : une rule est
mécaniquement un extrait d'une skill, donc skills-avant-rules était le seul ordre viable. Ce qui a
changé, ce n'est pas que Claude est devenu plus intelligent de session en session — c'est que le
règlement a cessé de vivre dans le chat pour rejoindre la couche faite pour le porter : une limite
nette dans un hook, un standard dans une skill, une vérification isolée dans un agent, un réflexe
dans une rule, et, partout où la tâche relevait de la pure logique, un script qui n'a jamais rien
demandé au modèle.

Une fois qu'un tel dispositif existe, la question suivante est ce qu'il coûte à faire tourner — et
c'est précisément là que [les astuces d'économie de tokens
d'un article précédent](/blog/claude-ia-spare-tokens) prennent le relais.
