computer-use-bash
Run shell commands so the result is unambiguous, and read that result truthfully.
Quick reference
| Situation |
Rule |
Editing a .sh file while it is executing |
Bash reads the script by raw BYTE OFFSET as it goes, not by re-parsing lines, so an in-place edit that changes the file's length shifts every byte after it: the next read lands mid-token in the shifted content, and the run can die naming code that reads completely fine when you check the file afterward (measured: inserting a few bytes near the TOP of a running script, in a region already executed, turned sleep 4 into leep 4, and the script failed on leep: command not found seconds later). A SAME-length in-place edit does not error at all - it silently runs whatever new code now sits in that byte range instead, which is quieter and just as dangerous. Never edit a running script in place: write the change to a NEW file and rename it OVER the original path (an editor's atomic save already does this) - the running process holds the OLD file open by inode and keeps reading THAT content, whatever the directory entry now points to. |
| A command exits non-zero |
NEVER dismiss it as "a quirk". Reproduce the smallest failing form to find the deterministic cause, or fix the command. |
| A command "succeeds" (exit 0) |
Exit 0 is necessary but NOT sufficient. ALSO verify the real artifact/output (file written, content/size correct, options actually applied) - some tools exit 0 while writing nothing or silently ignoring options (e.g. the vips out.tif[opts] bracket form). Check the result, not only the status. |
| Critical command + a check in one call |
Run the mutation in its OWN call (or join with &&). A trailing command's exit masks or misattributes the real one. |
| A heredoc carrying code or self-authored text |
QUOTE the delimiter (<<'PY', not <<PY). Unquoted, bash expands $(...), backticks and $VAR INSIDE the body before the interpreter ever sees it, so the text is silently mangled or a command runs. Same mechanism as the double-quoted-arg row, one wrapper out - and the one that slips through, because a heredoc body looks inert. |
Self-authored text inside a double-quoted arg (git -m, a --hook/--title) |
NEVER include backticks or $(...): bash command-substitutes them, RUNNING the word as a command (this once ran a real shutdown) and corrupting the text. Use plain words, single quotes, or pass the text via a file (-F/--body-file). |
A path or filename that BEGINS with - |
cat, tar, stat, ls, grep and dirname parse it as OPTIONS, and the error names a flag the caller never typed (cat on a -media... path reports an unexpected argument -m; tar reports an invalid option and EXITS BEFORE CREATING THE ARCHIVE, so the NEXT step fails with a missing-file error pointing at the wrong cause). Prefix the path with ./, or pass it absolute. -- does NOT help inside a for loop: the separator would have to precede EACH expanded argument, and one -- before the loop is not that. Measured three times in one session, every hit from globbing a directory whose entries begin with a dash - nothing the caller wrote contained a dash at all. |
| Grep to find EVERY site to change |
NEVER pipe the enumeration through head/tail -N: the cap is silent and becomes a false "all N sites updated" claim. Count first with grep -rc, then list uncapped and reconcile against that count. |
Capturing a grep -c count into a variable |
grep EXITS 1 when it matches nothing, so the "safe" idiom n=$(grep -c PATTERN file || echo 0) fires the fallback on a zero count and sets n to the TWO-LINE string 0\n0, silently misformatting every comparison and report line built from it. Write n=$(grep -c PATTERN file 2>/dev/null); n=${n:-0} - grep -c already prints 0 itself, so no fallback is needed. |
${VAR:-default} used as a switch (an allowlist, a config key, a feature flag) |
:- fires on UNSET or EMPTY, so blanking a variable selects the DEFAULT exactly as never setting it. A true-allowlist that writes "" for an unlisted name therefore fails OPEN - the child sees the default, not a denial - and so does a config file that clears the key. Only an explicit positive value in the child environment denies anything. Use ${VAR-default} (no colon) when an EMPTY value must stay empty, or test explicitly with [ -n "$VAR" ]. The matching test trap: an assertion on ABSENCE is green before the fix, after it, and after a regression - assert the VALUE. |
Alternation under grep -E |
BRE and ERE are INVERTED here, so the habit from one silently breaks the other. Plain grep (BRE) writes alternation as `a\ |
grep -q combined with -v |
In Claude Code's bash grep is a shell FUNCTION, and its exit status is WRONG for the quiet-inverted form: grep -qv PATTERN file exits 1 where /usr/bin/grep exits 0. -q alone and -v alone are both correct, so only the combination a shell condition uses is affected, and nothing is printed to notice it by. It silently inverts a wait-loop's terminal test: until ! grep -qv DONE status.txt fires on the first poll and the loop treats an unfinished job as complete. Call /usr/bin/grep explicitly wherever the exit status decides anything. |
A port probe using /dev/tcp/HOST/PORT |
/dev/tcp is a BASH builtin, not a real filesystem path, so it does not exist under sh - and pct exec, docker exec and ssh host cmd commonly hand you dash on Debian/Ubuntu. There the redirect just fails, so the probe reports CLOSED for EVERY port including wide-open ones, with nothing in the output saying why: a false negative indistinguishable from a real closed port. Name the shell explicitly (bash -c '...'), or use nc -z / Python's socket.create_connection. Either way put a known-OPEN and a known-CLOSED control port in the SAME run - that is what separates "the port is shut" from "this probe cannot open any port at all". |
cmd | tail/head/grep |
The pipeline's exit is the LAST stage's, not cmd's. Use set -o pipefail or check ${PIPESTATUS[0]}. |
command -v X >/dev/null && X ... || echo "(X not installed)" |
The || catches X's FINDING exit as well as its absence, so the presence guard does not fix the bare-|| trap, it hides it. Measured with a control: the chained form printed (diff not installed) immediately after diff printed a real diff, and printed the identical sentence when diff was genuinely absent - the two outcomes are indistinguishable from the output. Every --check mode behaves this way: shfmt -d, grep, diff, git diff --exit-code, pytest and shellcheck all exit non-zero to REPORT A FINDING. Put presence and result on SEPARATE branches: if ! command -v X >/dev/null; then echo "(X not installed)"; elif X ...; then echo clean; else echo findings; fi. |
| Check/kill a process by name |
pgrep/pkill -f PATTERN matches your OWN shell. Prefer a pidfile + kill -0, a port/unit/cgroup signal, or bracket the first char ([p]attern) AND keep the keyword out of echo labels in the same command. |
| Running a Python file/helper as a command |
NEVER bash script.py: bash has no import builtin, so the file's import os line runs ImageMagick's import (an X11 screen-grab on PATH), which blocks FOREVER on X11 at 0% CPU (process state S) and drops a stray screenshot file named after the module. The #!/usr/bin/env python3 shebang is just a comment to bash. Run Python via python3 script.py or the tool's documented launcher (e.g. a run-python.sh shim), never bash. A 0-CPU "slow" script is this: pgrep -x import finds the stuck grab, kill it by PID. |
| Backgrounding a long job (run_in_background) |
Make the long command ITSELF the background task. NEVER put cmd & INSIDE a run_in_background call: the wrapper reaches its next line and EXITS at once, firing a FALSE completion notification, while the &-detached child is reparented to init and runs ORPHANED + untracked - so its real end-signal never comes and you cannot stop it by task id. Run ONE command per run_in_background; for parallel jobs make SEPARATE calls, never one wrapper that backgrounds several with &. If a job does end up orphaned, poll ground truth (process alive + output-file growth) - no notification will fire. |
systemd-run --service-type=oneshot over SSH |
Default (no --no-block) BLOCKS until the transient unit's start job completes - for Type=oneshot that means until the whole command exits, so a long job over SSH reads as a hung connection (measured: sleep 3 took 3.03s, exit 0). A FAST return by itself is not proof of failure: a quick SUCCESS returns just as fast too (measured: /bin/true in well under 0.1s, exit 0) - read systemd-run's own exit code, not the timing. Pass --no-block to fire-and-forget; then that exit code means only job accepted, never the outcome (measured: /bin/false still exits 0), so poll systemctl is-active <unit> for the real result. A plain oneshot unit (no RemainAfterExit) never reports active: it goes activating then failed or inactive, so one sample can land mid-activating - poll until the state leaves it. |
| Waiting for an event |
Set an EXPECTED-duration ceiling BEFORE you wait (a quick estimate or one-shot instrumentation). This applies to every wait, INCLUDING waiting on a background job's completion signal (a task notification, a log line, a flag, a port) - a signal is not a licence to wait unboundedly. If the event overruns the ceiling by 2x, STOP and INVESTIGATE (hung? mis-scoped? wrong command/marker? contended?) instead of continuing to wait for the signal. Otherwise wait the measured time plus a small margin (1.3-1.5x, or a few seconds), or on the concrete signal, never an arbitrary long sleep (over-waiting compounds across cycles). Record measured timings so they are reused, not rediscovered. |
| Judging current state from output/logs |
Read the freshest lines and check their timestamps; never conclude from a stale capture. |
A long-lived process's CURRENT %CPU (ps -o pcpu) |
ps -o pcpu is a LIFETIME AVERAGE (CPU ticks used divided by process age since it started), not the current rate - verified in both directions on a real worker: one that burned CPU hard and is now idle still reads a high, stale percentage, and one that idled and just started bursting still reads a low one (0.4s into a fresh burst it read 5.3% while the process was genuinely at 100%). Read the CURRENT rate instead: pidstat -p PID INTERVAL 1 (needs the sysstat package, not always installed), or two /proc/<pid>/stat reads an INTERVAL apart, delta of fields 14+15 (utime+stime) divided by getconf CLK_TCK times the elapsed seconds - always available, no install needed. skills/compuse-toolbox/scripts/transfer.py check --pid N --interval S (see bitranox:compuse-toolbox) already automates that delta to judge whether a long job is alive - prefer it over hand-rolling the /proc read. |
| Keep / prune the NEWEST timestamped file(s) |
Sort by MTIME, not by name: ls -t (newest first), or find DIR -maxdepth 1 -printf '%T@ %p\0' | sort -zrn - the record separator must MATCH on both sides, \n with plain sort -rn or \0 with sort -z; mixing them (\n into sort -z) silently emits the input UNSORTED, so the prune keeps whatever came first. NEVER rely on plain ls/glob order (lexical) - a varying prefix breaks it (bak-dream-... sorts before bak-dreamtest-...), so the alphabetically-last STALE file is kept and a newer one deleted. |
| Finding files that were MOVED, renamed or archived |
Key on CTIME (find DIR -newerct '2 days ago'), never mtime. A rename does not touch mtime, so -newermt returns ZERO and reads as "nothing was archived" rather than as the wrong field - an empty result that looks like a real answer. chmod and chown also bump ctime while leaving mtime alone. Measured: a written-down recovery command used -newermt and returned 0 files for 27 notes archived that same day, so the procedure was broken from the day it was written. |
| Work the Bash tool cannot do (shell state across calls, an interactive prompt, a full-screen TUI) |
Drive a DETACHED tmux session. send-keys does not wait for readiness, so end the sent command with tmux wait-for -S CH and block on timeout N tmux wait-for CH - a signal barrier, never a sleep. Read with capture-pane -p FILTERED for non-empty lines. Identify it with has-session / list-panes -F '#{pane_current_command}', never pgrep -f. |
Why exit codes get misread
A block like mutate ... ; echo done ; verify returns ONLY the last command's status. So a failed critical command is hidden by a succeeding trailing command (false success), or a trivial trailing failure makes a successful critical command look failed (false failure) which then gets waved off as "a quirk". Both are real defects. Run the critical command alone (clean, unambiguous status), then verify separately. When you must chain, use && (status reflects the first failure), and for pipelines read ${PIPESTATUS[@]}.
Never "quirk" an error
A non-zero exit always has a deterministic cause. Reproduce the smallest failing form and isolate it (for example git rev-parse --short A B fails because --short abbreviates one revision, a knowable rule, not a quirk). Dismissing the error guarantees the same confusion next time and can hide a real failure.
When the Bash tool is the wrong shape: tmux
Three things a one-shot command cannot do: keep shell state between calls, answer a prompt a program
puts up, and read a full-screen TUI. A detached tmux session does all three, and the two traps
below both produce output that looks like a failure while the mechanism is fine.
tmux new-session -d -s work
tmux send-keys -t work 'long-running-thing; tmux wait-for -S ready' Enter
timeout 60 tmux wait-for ready # blocks until the command SIGNALS, no sleep, no polling
tmux capture-pane -p -t work | grep . # non-empty lines only
send-keys returns immediately. It delivers keystrokes; it does not wait for the program to
be ready for them, and it does not wait for what you sent to finish. Appending tmux wait-for -S <channel> to the sent command and blocking on timeout N tmux wait-for <channel> turns that into
a real barrier - the command signals when it is genuinely done, so there is no interval to guess.
capture-pane pads to the pane height, so the bottom of its output is blank lines, and a
tail of it shows you nothing but padding while the content sits above. Filter for non-empty lines
instead. To ask whether the session is alive, use has-session; to see what it is running, use
list-panes -F '#{pane_current_command}'. Never pgrep -f, which matches the shell running your
own check (see the process row above).
Hook
block-pgrep-self-match (PreToolUse on Bash) blocks the pgrep/pkill -f echo-label self-match. It does not replace preferring pidfile/port/unit signals over a name grep.
1---2name: compuse-bash3description: Use when running bash/shell commands and interpreting their exit codes and output - pipelines, chaining a check after a mutating command, process checks with pgrep/pkill, backgrounding, waiting for an event, or when a command "failed" or its result looks ambiguous.4---56# computer-use-bash78Run shell commands so the result is unambiguous, and read that result truthfully.910## Quick reference1112| Situation | Rule |13|---------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|14| Editing a `.sh` file while it is executing | Bash reads the script by raw BYTE OFFSET as it goes, not by re-parsing lines, so an in-place edit that changes the file's length shifts every byte after it: the next read lands mid-token in the shifted content, and the run can die naming code that reads completely fine when you check the file afterward (measured: inserting a few bytes near the TOP of a running script, in a region already executed, turned `sleep 4` into `leep 4`, and the script failed on `leep: command not found` seconds later). A SAME-length in-place edit does not error at all - it silently runs whatever new code now sits in that byte range instead, which is quieter and just as dangerous. Never edit a running script in place: write the change to a NEW file and rename it OVER the original path (an editor's atomic save already does this) - the running process holds the OLD file open by inode and keeps reading THAT content, whatever the directory entry now points to. |15| A command exits non-zero | NEVER dismiss it as "a quirk". Reproduce the smallest failing form to find the deterministic cause, or fix the command. |16| A command "succeeds" (exit 0) | Exit 0 is necessary but NOT sufficient. ALSO verify the real artifact/output (file written, content/size correct, options actually applied) - some tools exit 0 while writing nothing or silently ignoring options (e.g. the `vips out.tif[opts]` bracket form). Check the result, not only the status. |17| Critical command + a check in one call | Run the mutation in its OWN call (or join with `&&`). A trailing command's exit masks or misattributes the real one. |18| A heredoc carrying code or self-authored text | QUOTE the delimiter (`<<'PY'`, not `<<PY`). Unquoted, bash expands `$(...)`, backticks and `$VAR` INSIDE the body before the interpreter ever sees it, so the text is silently mangled or a command runs. Same mechanism as the double-quoted-arg row, one wrapper out - and the one that slips through, because a heredoc body looks inert. |19| Self-authored text inside a double-quoted arg (`git -m`, a `--hook`/`--title`) | NEVER include backticks or `$(...)`: bash command-substitutes them, RUNNING the word as a command (this once ran a real `shutdown`) and corrupting the text. Use plain words, single quotes, or pass the text via a file (`-F`/`--body-file`). |20| A path or filename that BEGINS with `-` | `cat`, `tar`, `stat`, `ls`, `grep` and `dirname` parse it as OPTIONS, and the error names a flag the caller never typed (`cat` on a `-media...` path reports an unexpected argument `-m`; `tar` reports an invalid option and EXITS BEFORE CREATING THE ARCHIVE, so the NEXT step fails with a missing-file error pointing at the wrong cause). Prefix the path with `./`, or pass it absolute. `--` does NOT help inside a `for` loop: the separator would have to precede EACH expanded argument, and one `--` before the loop is not that. Measured three times in one session, every hit from globbing a directory whose entries begin with a dash - nothing the caller wrote contained a dash at all. |21| Grep to find EVERY site to change | NEVER pipe the enumeration through `head`/`tail -N`: the cap is silent and becomes a false "all N sites updated" claim. Count first with `grep -rc`, then list uncapped and reconcile against that count. |22| Capturing a `grep -c` count into a variable | `grep` EXITS 1 when it matches nothing, so the "safe" idiom `n=$(grep -c PATTERN file \|\| echo 0)` fires the fallback on a zero count and sets `n` to the TWO-LINE string `0\n0`, silently misformatting every comparison and report line built from it. Write `n=$(grep -c PATTERN file 2>/dev/null); n=${n:-0}` - `grep -c` already prints `0` itself, so no fallback is needed. |23| `${VAR:-default}` used as a switch (an allowlist, a config key, a feature flag) | `:-` fires on UNSET **or EMPTY**, so blanking a variable selects the DEFAULT exactly as never setting it. A true-allowlist that writes `""` for an unlisted name therefore fails OPEN - the child sees the default, not a denial - and so does a config file that clears the key. Only an explicit positive value in the child environment denies anything. Use `${VAR-default}` (no colon) when an EMPTY value must stay empty, or test explicitly with `[ -n "$VAR" ]`. The matching test trap: an assertion on ABSENCE is green before the fix, after it, and after a regression - assert the VALUE. |24| Alternation under `grep -E` | BRE and ERE are INVERTED here, so the habit from one silently breaks the other. Plain `grep` (BRE) writes alternation as `a\\|b`; `grep -E` (ERE) writes it BARE as `a\|b`, and there a backslash-pipe means a LITERAL pipe character. So `grep -iE "a\\|b"` searches for the three-character string a-pipe-b and matches NOTHING - a false negative from a command that reads as correct, which is worst when the search is deciding whether something EXISTS. The shell keeps the backslash inside double quotes, so nothing warns you and the exit status is a plain "no match". |25| `grep -q` combined with `-v` | In Claude Code's bash `grep` is a shell FUNCTION, and its exit status is WRONG for the quiet-inverted form: `grep -qv PATTERN file` exits 1 where `/usr/bin/grep` exits 0. `-q` alone and `-v` alone are both correct, so only the combination a shell condition uses is affected, and nothing is printed to notice it by. It silently inverts a wait-loop's terminal test: `until ! grep -qv DONE status.txt` fires on the first poll and the loop treats an unfinished job as complete. Call `/usr/bin/grep` explicitly wherever the exit status decides anything. |26| A port probe using `/dev/tcp/HOST/PORT` | `/dev/tcp` is a BASH builtin, not a real filesystem path, so it does not exist under `sh` - and `pct exec`, `docker exec` and `ssh host cmd` commonly hand you dash on Debian/Ubuntu. There the redirect just fails, so the probe reports CLOSED for EVERY port including wide-open ones, with nothing in the output saying why: a false negative indistinguishable from a real closed port. Name the shell explicitly (`bash -c '...'`), or use `nc -z` / Python's `socket.create_connection`. Either way put a known-OPEN and a known-CLOSED control port in the SAME run - that is what separates "the port is shut" from "this probe cannot open any port at all". |27| `cmd \| tail`/`head`/`grep` | The pipeline's exit is the LAST stage's, not `cmd`'s. Use `set -o pipefail` or check `${PIPESTATUS[0]}`. |28| `command -v X >/dev/null && X ... \|\| echo "(X not installed)"` | The `\|\|` catches X's FINDING exit as well as its absence, so the presence guard does not fix the bare-`\|\|` trap, it hides it. Measured with a control: the chained form printed `(diff not installed)` immediately after `diff` printed a real diff, and printed the identical sentence when `diff` was genuinely absent - the two outcomes are indistinguishable from the output. Every `--check` mode behaves this way: `shfmt -d`, `grep`, `diff`, `git diff --exit-code`, `pytest` and `shellcheck` all exit non-zero to REPORT A FINDING. Put presence and result on SEPARATE branches: `if ! command -v X >/dev/null; then echo "(X not installed)"; elif X ...; then echo clean; else echo findings; fi`. |29| Check/kill a process by name | `pgrep`/`pkill -f PATTERN` matches your OWN shell. Prefer a pidfile + `kill -0`, a port/unit/cgroup signal, or bracket the first char (`[p]attern`) AND keep the keyword out of `echo` labels in the same command. |30| Running a Python file/helper as a command | NEVER `bash script.py`: bash has no `import` builtin, so the file's `import os` line runs ImageMagick's `import` (an X11 screen-grab on PATH), which blocks FOREVER on X11 at 0% CPU (process state S) and drops a stray screenshot file named after the module. The `#!/usr/bin/env python3` shebang is just a comment to bash. Run Python via `python3 script.py` or the tool's documented launcher (e.g. a `run-python.sh` shim), never `bash`. A 0-CPU "slow" script is this: `pgrep -x import` finds the stuck grab, kill it by PID. |31| Backgrounding a long job (run_in_background) | Make the long command ITSELF the background task. NEVER put `cmd &` INSIDE a `run_in_background` call: the wrapper reaches its next line and EXITS at once, firing a FALSE completion notification, while the `&`-detached child is reparented to init and runs ORPHANED + untracked - so its real end-signal never comes and you cannot stop it by task id. Run ONE command per `run_in_background`; for parallel jobs make SEPARATE calls, never one wrapper that backgrounds several with `&`. If a job does end up orphaned, poll ground truth (process alive + output-file growth) - no notification will fire. |32| `systemd-run --service-type=oneshot` over SSH | Default (no `--no-block`) BLOCKS until the transient unit's start job completes - for `Type=oneshot` that means until the whole command exits, so a long job over SSH reads as a hung connection (measured: `sleep 3` took 3.03s, exit 0). A FAST return by itself is not proof of failure: a quick SUCCESS returns just as fast too (measured: `/bin/true` in well under 0.1s, exit 0) - read `systemd-run`'s own exit code, not the timing. Pass `--no-block` to fire-and-forget; then that exit code means only `job accepted`, never the outcome (measured: `/bin/false` still exits 0), so poll `systemctl is-active <unit>` for the real result. A plain oneshot unit (no `RemainAfterExit`) never reports `active`: it goes `activating` then `failed` or `inactive`, so one sample can land mid-`activating` - poll until the state leaves it. |33| Waiting for an event | Set an EXPECTED-duration ceiling BEFORE you wait (a quick estimate or one-shot instrumentation). This applies to every wait, INCLUDING waiting on a background job's completion signal (a task notification, a log line, a flag, a port) - a signal is not a licence to wait unboundedly. If the event overruns the ceiling by ~2x, STOP and INVESTIGATE (hung? mis-scoped? wrong command/marker? contended?) instead of continuing to wait for the signal. Otherwise wait the measured time plus a small margin (~1.3-1.5x, or a few seconds), or on the concrete signal, never an arbitrary long sleep (over-waiting compounds across cycles). Record measured timings so they are reused, not rediscovered. |34| Judging current state from output/logs | Read the freshest lines and check their timestamps; never conclude from a stale capture. |35| A long-lived process's CURRENT %CPU (`ps -o pcpu`) | `ps -o pcpu` is a LIFETIME AVERAGE (CPU ticks used divided by process age since it started), not the current rate - verified in both directions on a real worker: one that burned CPU hard and is now idle still reads a high, stale percentage, and one that idled and just started bursting still reads a low one (0.4s into a fresh burst it read 5.3% while the process was genuinely at 100%). Read the CURRENT rate instead: `pidstat -p PID INTERVAL 1` (needs the `sysstat` package, not always installed), or two `/proc/<pid>/stat` reads an INTERVAL apart, delta of fields 14+15 (utime+stime) divided by `getconf CLK_TCK` times the elapsed seconds - always available, no install needed. `skills/compuse-toolbox/scripts/transfer.py check --pid N --interval S` (see `bitranox:compuse-toolbox`) already automates that delta to judge whether a long job is alive - prefer it over hand-rolling the `/proc` read. |36| Keep / prune the NEWEST timestamped file(s) | Sort by MTIME, not by name: `ls -t` (newest first), or `find DIR -maxdepth 1 -printf '%T@ %p\0' \| sort -zrn` - the record separator must MATCH on both sides, `\n` with plain `sort -rn` or `\0` with `sort -z`; mixing them (`\n` into `sort -z`) silently emits the input UNSORTED, so the prune keeps whatever came first. NEVER rely on plain `ls`/glob order (lexical) - a varying prefix breaks it (`bak-dream-...` sorts before `bak-dreamtest-...`), so the alphabetically-last STALE file is kept and a newer one deleted. |37| Finding files that were MOVED, renamed or archived | Key on CTIME (`find DIR -newerct '2 days ago'`), never mtime. A rename does not touch mtime, so `-newermt` returns ZERO and reads as "nothing was archived" rather than as the wrong field - an empty result that looks like a real answer. `chmod` and `chown` also bump ctime while leaving mtime alone. Measured: a written-down recovery command used `-newermt` and returned 0 files for 27 notes archived that same day, so the procedure was broken from the day it was written. |38| Work the Bash tool cannot do (shell state across calls, an interactive prompt, a full-screen TUI) | Drive a DETACHED `tmux` session. `send-keys` does not wait for readiness, so end the sent command with `tmux wait-for -S CH` and block on `timeout N tmux wait-for CH` - a signal barrier, never a `sleep`. Read with `capture-pane -p` FILTERED for non-empty lines. Identify it with `has-session` / `list-panes -F '#{pane_current_command}'`, never `pgrep -f`. |3940## Why exit codes get misread4142A block like `mutate ... ; echo done ; verify` returns ONLY the last command's status. So a failed critical command is hidden by a succeeding trailing command (false success), or a trivial trailing failure makes a successful critical command look failed (false failure) which then gets waved off as "a quirk". Both are real defects. Run the critical command alone (clean, unambiguous status), then verify separately. When you must chain, use `&&` (status reflects the first failure), and for pipelines read `${PIPESTATUS[@]}`.4344## Never "quirk" an error4546A non-zero exit always has a deterministic cause. Reproduce the smallest failing form and isolate it (for example `git rev-parse --short A B` fails because `--short` abbreviates one revision, a knowable rule, not a quirk). Dismissing the error guarantees the same confusion next time and can hide a real failure.4748## When the Bash tool is the wrong shape: tmux4950Three things a one-shot command cannot do: keep shell state between calls, answer a prompt a program51puts up, and read a full-screen TUI. A detached `tmux` session does all three, and the two traps52below both produce output that looks like a failure while the mechanism is fine.5354```bash55tmux new-session -d -s work56tmux send-keys -t work 'long-running-thing; tmux wait-for -S ready' Enter57timeout 60 tmux wait-for ready # blocks until the command SIGNALS, no sleep, no polling58tmux capture-pane -p -t work | grep . # non-empty lines only59```6061**`send-keys` returns immediately.** It delivers keystrokes; it does not wait for the program to62be ready for them, and it does not wait for what you sent to finish. Appending `tmux wait-for -S63<channel>` to the sent command and blocking on `timeout N tmux wait-for <channel>` turns that into64a real barrier - the command signals when it is genuinely done, so there is no interval to guess.6566**`capture-pane` pads to the pane height**, so the bottom of its output is blank lines, and a67`tail` of it shows you nothing but padding while the content sits above. Filter for non-empty lines68instead. To ask whether the session is alive, use `has-session`; to see what it is running, use69`list-panes -F '#{pane_current_command}'`. Never `pgrep -f`, which matches the shell running your70own check (see the process row above).7172## Hook7374`block-pgrep-self-match` (PreToolUse on Bash) blocks the `pgrep`/`pkill -f` echo-label self-match. It does not replace preferring pidfile/port/unit signals over a name grep.