# Claude Code A series of 2 article(s), in reading order. # Claude Code - Optimizing Token Usage > A practical guide to reducing token consumption in Claude Code; CLI commands, optimization of CLAUDE.md, and other context management. --- ![Claude Code - Optimizing Token Usage](/img/v2/claude-code-tokens.webp) **TL;DR** Tokens are the currency of AI conversations: every word you send and receive has a cost. This article walks through practical techniques to reduce token consumption in Claude Code — from slash commands like `/clear` and `/compact`, to CLAUDE.md optimizations, smarter data-fetching rules, and off-peak usage tips. If you are new to Claude Code, you may not yet be aware that every interaction has a cost measured in **tokens**. Roughly speaking, a token is a small chunk of text (about 4 characters or ¾ of a word). The more context Claude has to process — your conversation history, open files, tool outputs — the more tokens are consumed. On paid plans, this affects your quota; on free plans, it directly limits how much you can do in a session. This article collects practical tips to help you get the most out of your token budget. ## Reducing Context **Token-Saving Tips** * **`/clear`** — Start a fresh conversation when the current history is no longer relevant. The longer the conversation, the more input tokens each new message costs. * **`/compact`** — When quota reaches \~40% and you want to keep the history, `/compact` summarizes it using AI. Run it when the conversation is long and will continue for several more exchanges. * **Commit your changes** — After completing a task, run `git add` + `git commit`. Claude Code continuously reads the diff to detect regressions; an empty `git diff` means less context consumed on every message. * **`/caveman`** ([plugin](https://github.com/JuliusBrussee/caveman)) — Forces Claude to respond as compactly as possible, reducing output tokens. Trade-off: lower response quality. * **`/caveman-compress CLAUDE.md`** — Rewrites your CLAUDE.md in ultra-compact syntax, replacing verbose sentences with abbreviations like `SOLID` or `SRP`, without losing any instruction. * **`/context`** — Shows what is loaded and how many tokens each item consumes, including each MCP server. Use **`/.mcp`** to enable or disable MCPs. * **`/usage-credits`** — Opens claude.ai to display your current usage percentage immediately. * **`CLAUDE.md` size** — This file is loaded at every startup, consuming input tokens before you type a single word. Keep it minimal — only what is truly necessary. * **Cache invalidation** — Switching models or adding an MCP server invalidates Claude's prompt cache, forcing it to re-read everything. Make those changes at the start of a new conversation (after `/clear`). * **Files and images** — Attaching a PDF, Word document, or image costs significantly more input tokens. Paste the relevant text directly into the prompt whenever possible. * **`/btw`** — Adds an ephemeral side note to the conversation without including it in the full context history. Note: `/btw` blocks agents from running any tools on your codebase. ### Model Choice The choice of model directly impacts token consumption — the more capable the model, the more it costs: **Model cost vs use case** * **Claude Opus** — Highest token cost. Best for complex reasoning, architectural decisions. * **Claude Sonnet** — Medium token cost. Best for day-to-day development tasks. * **Claude Haiku** — Lowest token cost. Best for speed, simple queries, quick edits. ## CLAUDE.md File Loaded at every startup — every byte here costs tokens before your first message. Write in telegraphic style; `SOLID` and `SRP` are understood without full sentences. If an instruction keeps growing because it needs to explain *why*, not just state the *what*, it may not belong here at all — [a later article](/blog/claude-code-workflow) covers moving that kind of standing rule into a skill, an agent or a rule file instead of retyping it into `CLAUDE.md` every time it grows. ### Data Fetching Rules Instructs Claude to use targeted [`grep`](/blog/ripgrep)/[`jq`](/blog/linux-jq)/`yq` calls instead of loading whole files. **Restrictive** — maximum token savings: ```markdown ## Data Fetching Rules **Fetch only what each check requires — never pull full metadata upfront.** ``` **Balanced** — full reads for short source files, targeted for infra: ```markdown ## Data Fetching Rules **Distinguish between code and metadata:** - **Source Code:** Because files are strictly max 200 lines, you may read full source files to grasp the complete import/dependency context and avoid breaking SRP. - **Infrastructure & Logs:** Never read full logs, `.lock` files, or raw JSON/YAML metadata. You MUST use targeted shell tools (`grep`, `jq`, `yq`) for Docker configs, `compose.yaml`, JSON files or CI/CD files. ``` > **Note:** > > The restrictive version may lead to hallucinations; the balanced version trades a few extra tokens for accurate file context on short source files. ### Output Mode Adds \~25 input tokens at startup but shortens every AI response, compounding savings over long sessions. **Aggressive** — shortest responses, reasoning less visible: ```markdown ## Output mode **Ultra-concise.** No filler words, no repetition, no introductions, no conclusions. Density over readability. Skip pleasantries. Go straight to findings. ``` **Moderate** — brief with one-line rationale per change: ```markdown ## Output mode **High-Density Technical.** No filler, pleasantries, or generic introductions. When proposing code changes, you MUST provide a 1-sentence technical rationale explaining your architectural choice (e.g., how it serves SOLID, DRY, or a specific PHP 8.4/Python 3.14 feature). Be a highly critical peer reviewer; flag flaws bluntly. ``` > **Note:** > > Aggressive mode saves more tokens but hides the AI's reasoning. Use moderate if you want to understand and learn from each suggestion. ### Code Generation Prevents Claude from returning entire files — only changed lines with minimal surrounding context. ```markdown ## Code generation Do not output unchanged code blocks. Only display the specific lines modified, providing minimal surrounding context. Rely strictly on tools for full file rewrites. ``` ## Off-Peak and Peak Hours Anthropic has confirmed that, during business hours, there is a different **message rate limit**. The number of tokens remains the same, but their weighting does not: you will use up your quota more or less quickly depending on the time of day. If you are on a tight budget, working during off-peak hours (evenings or weekends) will stretch your quota further. ## Extra ### If you don't want to install caveman Simply create a new conversation and paste this prompt, replacing `[YOUR-CLAUDE.MD-CONTENT]` with your actual file: > **Caveman-style compression prompt:** > > ```text > I want you to act as a token compressor for an LLM system prompt (in a "Caveman" style). Your goal is to drastically reduce the size of the following text while retaining 100% of the rules, technical constraints, and architectural guidelines. Compression rules: 1. Remove all connecting words, articles, polite language, and human grammar. 2. Use extreme telegraphic syntax, keywords, and standard abbreviations. 3. Group concepts by tags (e.g., [Rules], [Stack], [Lint]). 4. Do NOT omit ANY technical constraints (e.g., tool names, versions, strict limits). 5. Generate only the compressed result, with no introduction or conclusion. Here is the file to be compressed: > > [YOUR-CLAUDE.MD-CONTENT] > ``` --- # Claude Code - From Slash Commands to a Layered Workflow > How this blog's .claude/ folder grew from a handful of slash commands into commands, hooks, skills, agents and rules working together — a practical learning path, including where a deterministic script beats asking the AI. --- ![Claude Code - From Slash Commands to a Layered Workflow](/img/v2/vibe_coding_claude.webp) **TL;DR** This blog's `.claude/` folder grew, over several months, from a handful of slash commands into a five-layer system: commands you type, hooks that block or auto-approve deterministically, skills that encode what "good" means, agents that check it in an isolated context, and rules that enforce the short version automatically. This article walks through the order it was actually built in, what each layer is for, a real example of each, and why a plain Bash script — with zero AI involved — is sometimes the best possible building block. Like every beginner, my first Claude Code sessions with this blog started the same way, day after day: I re-explained, in the chat, what a good Bash script looks like here, what a Dockerfile must never do, or how to number a new TODO file without colliding with an existing one. Claude did the work, but I was the one carrying the rulebook — typed out again, session after session. My `.claude/` folder looks nothing like it did back then. Today it holds 15 slash commands, 4 read-only reviewer agents, 10 skills, 3 always-on rules, and a few plain Bash scripts that never touch the AI at all. None of it arrived at once — each layer showed up to solve a problem the previous one couldn't. ## What Five Layers Look Like In Practice Five ingredients make this possible, and each gets its own section further down — this is just the short version so the demo below doesn't read like alphabet soup: **Five Words to Keep In Mind** * **Command** — what you type yourself (`/something`). * **Skill** — a checklist Claude reads on its own, without being asked. * **Agent** — runs that checklist in its own separate, restricted window, away from your main conversation. * **Rule** — loads itself automatically the moment a matching file is open. * **Hook** — can block an action outright, no negotiation possible. Two of these five never wait for a human: a **rule** loads itself the instant a matching file opens, and a **hook** fires on every matching tool call — no typing involved, and Claude has no say either way. The other three are triggered by hand, and chain together the way the demo below shows it: you type a **command**, the command calls an **agent**, and the agent applies a **skill** to do the actual judging — though, as covered further down, a skill can also auto-load on its own when its description matches the task at hand, without a command in the loop at all. Here's what a critical read of a blog post looks like today, once all five are in place, before it ever gets published: > */review:reader git-precommit* Claude doesn't need to be told, again, what "good" means for one of this blog's articles — the `reader-first-docs` skill already spells it out, and `/review:reader` applies it directly: what share of the article sits between `` and the first real proof — actual command output, not just an install step? > `git-precommit` — real output ten lines after ``, about 7% of the article's > body. Comfortably under the 15% green threshold, nothing installed or explained first. > Verdict: OK, nothing to restructure. That checklist — front-load the value, mark deep-dives as optional, keep installation out of the first half — never had to be retyped, months after it was first written down. And one detail worth noting before anything else: not every command in this workflow relies on Claude at all. `/todos:add`, the command that files a new backlog entry, needs the next free ID first — a pure counting problem, so instead of asking the model to scan `.todos/` and count, it calls a plain script: ```bash $ .claude/scripts/todo_next_id.sh .todos 0105 ``` > **Best practice: a script beats a prompt:** > > Whenever a step is pure logic — count files, increment an ID, parse a date, check a lock — write it > as a plain script and have the command call it. It is deterministic (the same input always gives > the same output), it costs zero tokens, and it can be tested on its own, without a model in the > loop. Keep the AI for the steps that actually need judgment. ## Why a Layered Setup Works * **Only one layer is something you type.** Slash commands are the sole self-discoverable piece — autocomplete finds them. Skills, rules, agents and hooks are invisible until something triggers them; they shape *how* Claude works without adding to what you have to remember to invoke. * **Each layer answers a different question.** A skill decides *what* good looks like. An agent decides *who* checks it, and in what isolation. A rule decides *when* the short version applies automatically. A hook decides *what is simply not allowed* — no judgment call, no way to argue the model out of it. * **Read-only agents keep review noise out of the conversation that matters.** That's a token saving that comes from architecture, not from prompting discipline — a nice complement to the session-management tricks in [an earlier round of token-saving tips](/blog/claude-ia-spare-tokens), which focused on `/clear`, `/compact`, and `CLAUDE.md` size. * **A rule is deliberately not a full explanation.** It's a compressed DO/DON'T extract of its sibling skill, loaded only when a matching file is actually open — full rationale stays one click away, not in every context window. * **The cheapest layer of all doesn't call the model.** A plain script, as above, is the floor everything else builds on. ## Start Here: Your First Slash Command Slash commands were the easy entry point, and they still are — a Markdown file with a short frontmatter header, discovered automatically the moment it lands in `.claude/commands/`. For instance, simply create the file `.claude/commands/pr-description.md` in your project with this content: **.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. ``` Now type `/pr-description` in a Claude Code session, and that prompt runs with the current diff already in view — nothing to remember, nothing to re-explain. Two details in the file above matter more than they look: * **`argument-hint`** is what autocomplete shows before you commit to typing the command — cheap documentation for future-you. * **`allowed-tools`** restricts the blast radius before the command ever runs. This one can read Git history and files, and nothing else — it cannot edit anything, even by mistake. Three real commands from this blog's own `.claude/commands/` show the same shape at different sizes. `/review:bash` is barely more than a pointer to the agent covered later in this 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` maintains this blog's own internal-linking convention — 2 to 4 inline links per article, plus a reciprocal link in the older post it points to: **.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 ``` 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: `the natural words already in the sentence`. - 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 # 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. ```` And `/todos:add` is the command standing behind the `todo_next_id.sh` script from earlier — its `allowed-tools` line names the script explicitly, so the command can call it but nothing else: **.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: 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-.md`** with the skeleton below. The filename is an **identity**: never encode priority, status, or batch in it. ```markdown # NNNN — - **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. ```` ## Draw a Hard Line: Hooks A slash command's prompt is still an instruction Claude reads and can, in a long enough session, quietly drift from — the same way a person forgets an instruction given an hour and many messages ago. A **hook** works differently: it's a shell command that the Claude Code harness (the program running the session, separate from Claude itself) executes automatically at a fixed point — before Claude reads a file or runs a command (what Claude Code calls a *tool call*), right after one, or when the session ends — and its exit code decides what happens next. Claude has no say in that decision. It's the same idea as a Git `pre-commit` hook in [an earlier article](/blog/git-precommit), one layer down: that one intercepts a commit, this one intercepts a single tool call. This blog's own `.claude/settings.json` doesn't have any yet — it only carries a `permissions.allow` list. Hooks earned their place on a different, Python project of mine that runs `ruff` and `pytest` constantly during a session. Simplified to the bare mechanics: **.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" }] } ] } } ``` Four ideas worth carrying over, whatever language a project is in: * **`permissions.deny`** blocks `Read` on secrets outright — a hook doesn't even get involved, the tool call never happens. * **`PreToolUse`** here auto-approves `pytest` specifically, so a long, careful session doesn't stop for a confirmation prompt every single time the test suite runs. * **`PostToolUse`** reacts the instant a Python file is written or edited: it runs `ruff check` on that one file, right then, instead of waiting for the reader — or a CI job — to notice later. * **`Stop`** fires once, when Claude considers a task finished and is about to end its turn — the last checkpoint, not a per-edit one. Here it runs the full test suite one more time; if `pytest` fails, the hook's non-zero exit tells Claude the turn isn't actually over, and why. It's the only hook that can override "I'm done." > **A hook is a floor, not a substitute:** > > A hook only fires on the tool-call shape it's matched against — it doesn't understand intent the > way a skill-following model does. Use it for the handful of rules that must never be negotiable > (secrets, a required lint or test run); leave everything that needs judgment to a skill. ## Encode Your Standards: Skills, Checked by Agents A **skill** is where "good Bash" or "good Dockerfile" actually gets written down — once, as the single source of truth, instead of retyped into chat every session. Here's this blog's real `bash-best-practices` skill in full — it opens with a `description` field and a `disable-model-invocation` flag, then the actual conventions: **.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. ```` The `description` field does double duty: it's what a human reads to know what the skill covers, and it's what Claude matches against the current task to decide whether to load it *without being asked*. One flag controls that: `disable-model-invocation: false` keeps auto-loading switched on; set it to `true` and the skill only loads when a command explicitly calls it. A skill alone is just a stricter memory. What turned it into an actual audit was a matching **agent** — a subagent with a name, a fixed, narrow toolset, and one job: **.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. ```` Two design choices carry more weight than the file's length suggests: * **`tools: Read, Grep, Glob, Bash`** — no `Edit`, no `Write`. This agent physically cannot change a file, which means a review can never accidentally turn into a rewrite, and a hostile string buried in a reviewed file can't talk it into one either. * **An agent is reachable only through a matching slash command** — `/review:bash` is the sole door in. There's no autocomplete entry for the agent itself; discoverability lives entirely at the command layer, review logic lives entirely at the agent layer. The same pattern repeats for `python-best-practices` (paired with `python-best-practices-reviewer`, most valuable on the parts [`ruff` and `mypy` don't already catch](/blog/python-qa)) and for `dockerfile-best-practices`. One skill, one narrow agent, one command — a shape that scales to any language a project adds next. ### A Concrete Case: This Blog's Own Time-to-Value Check Every pairing so far reviews code. On this blog, before I publish an article, I run `/review:reader` — it loads the `reader-first-docs` skill and checks a post's structure. One of the criteria it measures is Time-to-Value: within roughly the first 30 seconds of reading, a reader should already understand what the article covers and whether it's worth their time to keep going. A *high* TTV score means the opposite happened — the reader had to wade through too much text before reaching that "oh, *this* is what the article is about" moment. Here's the skill in full: **.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. ```` The core metric, Time-to-Value, is mechanical, not a feeling: find `T`, the line of `<!-- truncate -->`; find the first real *proof* after it — actual command output, never just an install step; compute `TTV = (proof_line − T) / BODY`, where `BODY` is everything past `T`. That's exactly the arithmetic behind the opening demo: on [the pre-commit article](/blog/git-precommit), real output lands ten lines after `<!-- truncate -->`, `BODY` is 148 lines, so `TTV ≈ 7%` — comfortably under the skill's own 15% green threshold. Three tiers turn that percentage into a verdict: 🟢 `OK` under 15%, 🟠 `MINOR` under 30% (a journal note, deliberately never a TODO — a 40-article sweep would otherwise bury real findings under one-liners for every post with a merely flat ending), 🔴 `RESTRUCTURE` at 30% or beyond, which is the only tier that files a TODO. > **Not every skill pairs with an agent:** > > `/review:reader` doesn't dispatch to an isolated agent the way `/review:bash` does — it needs > `Write` access to log progress across a whole batch to `.todos/0000-reader-review-journal.md`, so > it runs inline instead, same skill, no isolation layer. A `reader-first-docs-reviewer` agent does > exist in this project, but it audits a different scope (long-form docs like a README, not blog > posts) and isn't wired to any command yet. The shape from the previous section is common, not > universal. ## Always-On, No Invocation Needed: Rules Skills and agents both wait to be triggered — by a matching task, or by typing a command. A **rule** doesn't wait for anything: it loads the instant a matching file is opened or edited, glob-matched against `paths`, no invocation at all. Here's the real file — its first line already points back to where the reasoning actually lives: **.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. ``` A rule doesn't repeat the skill's reasoning, it extracts the bare DO/DON'T pairs a model needs while it's mid-edit, and points back for the *why*. The same shape covers Markdown, keeping bold as `**asterisks**` never `__underscores__` the way [a linter left disabled on purpose](/blog/markdown-lint) would otherwise silently allow either. I wrote rules last and half-wondered afterward whether they should have come first — a rule looks like the more "basic" layer. Mechanically, it isn't: a rule is *defined* as a short extract of its sibling skill, so the skill has to exist before there's anything to extract. Writing skills and agents before rules, even by accident, turned out to be the order that actually works. ## Wiring It All Together Once a few languages had the same skill → agent → command shape, the map became the 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` | Each row is independent and addable on its own — a fourth row for a new language costs one skill file, one agent file, and one command file, none of which touch the other three. ## Under the Hood (skip this if you just want to use it) ### Why the reviewer agents can't edit `Read, Grep, Glob, Bash` and nothing else isn't caution for its own sake. A review agent's whole job is to read files it didn't write and judge them — the moment it can also `Edit`, "detection only" stops being a guarantee enforced by the tool list and becomes a guideline the prompt merely asks for. Keeping the two apart means a reviewer's report can be trusted to be exactly that: a report, not a diff already applied without a second look. ### The zero-token script, one more time `todo_next_id.sh` earns a second look because it generalizes past TODO numbering: **.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)) ``` Two lines of `find`/`sed`/`sort` answer "what's the next free ID" with total certainty — no hallucination risk, because there's no model in the path at all. Its sibling `todo_lock.sh` does the same for a harder problem, concurrency: two Claude Code sessions racing to grab the same TODO number resolve it with `mkdir` (atomic — it fails if the directory already exists), not by asking either session to "check first." Any time a step reduces to *count, parse, lock, hash, diff* — reach for a script before reaching for a prompt. ## Conclusion The order that actually worked here was commands, then hooks, then skills paired with agents, then rules — and with hindsight, that's not an accident to fix: a rule is mechanically an extract of a skill, so skills-before-rules was the only order that could have worked. What changed isn't that Claude got smarter session to session — it's that the rulebook stopped living in the chat and moved into the layer built to hold it: a hard line in a hook, a standard in a skill, an isolated check in an agent, a reflex in a rule, and, wherever the task was pure logic, a script that never asked the model at all. Once a setup like this exists, the next question is what it costs to run — which is exactly where [the token-saving tips from an earlier article](/blog/claude-ia-spare-tokens) pick up. ---