Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude/hooks/guard-build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# PreToolUse(Bash): refuse to start a second `next build` concurrently.
#
# Why this exists: concurrent builds collided on the Next.js build lock during
# the OG-image work, wasting a full build cycle each time.
set -uo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"

CMD="$(hook_field '.tool_input.command')"

# `npm run build` chains next-build; match both it and a direct next build.
# invokes() rather than a substring grep — see the note in lib.sh.
invokes "$CMD" 'npm run build|npm run next-build|next build' || exit 0

PIDS="$(next_build_pids)"
[ -z "$PIDS" ] && exit 0

deny "A 'next build' is already running (PID ${PIDS}) — a second one will collide on the Next.js build lock.
Wait for it to finish, or ask the user whether to stop it. Do not start a parallel build."
62 changes: 62 additions & 0 deletions .claude/hooks/guard-destructive.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# PreToolUse(Bash): make package removal and recursive deletes ASK first.
#
# Why this exists: a previous session ran `npm uninstall sqlite3` when only the
# package.json entry was wanted, and had to be interrupted by hand.
#
# Why a hook rather than the permissions.ask rules alone: those rules are
# prefix-matched, so `cd . && npm uninstall sqlite3` slips straight past them —
# and an `&&` chain is exactly the shape the original incident took. invokes()
# splits on shell separators, so it catches the command wherever it sits.
# The permissions.ask rules in settings.json are kept as a second layer.
#
# Decision is "ask", not "deny": these are legitimate commands that need a
# human in the loop, not forbidden ones.
set -uo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"

CMD="$(hook_field '.tool_input.command')"

# Three independent rules, any of which asks:
#
# R7.2 — the flag rule. `rm -[a-zA-Z]*[rR][a-zA-Z]*` covers -r, -rf, -fr, -R and
# friends. Plain `rm -f somefile` is deliberately NOT matched: a single-file
# delete is not the overreach this guards against, and prompting on every one
# would train the prompt to be ignored.
#
# `git reset --hard` and `git checkout --` destroy uncommitted work, which in
# this workflow is routinely the only copy. Plain `git checkout <branch>` and
# a soft/mixed `git reset` are not matched. The pattern itself lives in
# lib.sh as HOOK_DESTRUCTIVE so the tests cannot assert against a stale copy.
#
# R7.2b — `git clean`, handled separately because of its dry-run exemption.
# Added after auditing what `git clean -fdx` removes HERE: data/ (9.0G),
# .env, .claude/plans/, certificates/, docs/handoffs/ — none of it in git. It
# was completely ungated while `rm -rf` was gated.
#
# R7.3 — the untracked-path rule. Any rm naming a gitignored path, whatever its
# flags. This is what catches `rm data/raid-tracker.db`, which needs no -r.
#
# Each rule is checked independently. An earlier version let the git-clean
# dry-run exemption `exit 0` for the whole hook, so an unrelated `head -n 20`
# elsewhere in the command disabled the rm rules too.

if invokes_strict "$CMD" "$HOOK_DESTRUCTIVE"; then
ask "This command removes packages, discards work, or recursively deletes files.
Command: ${CMD}
Confirm this is what you want. If only a package.json entry should change, edit package.json directly instead of uninstalling."
fi

if invokes_real_git_clean "$CMD"; then
ask "This git clean deletes untracked files, which git cannot restore.
Command: ${CMD}
In this repo an unrestricted clean removes data/ (the live database), .env, certificates/, .claude/plans/ and docs/handoffs/ — none of them recoverable.
Run it with -n first to see what it would take."
fi

if rm_touches_ignored "$CMD"; then
ask "This rm names a path that is NOT in git — deleting it cannot be undone with git.
Command: ${CMD}
In this repo that includes data/ (the live database, backed up only by manual snapshots), .env, certificates/, .claude/plans/ and docs/handoffs/.
Confirm the path is really disposable."
fi
25 changes: 25 additions & 0 deletions .claude/hooks/guard-dev-server.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# PreToolUse(Bash): refuse to start a second dev server on the dev port.
#
# Why this exists: a previous session killed only the `npm` wrapper when
# shutting down, leaving an orphaned `next dev` squatting on port 3000, which
# then blocked the user's own `npm run dev`. Documentation can't prevent that;
# this can. Deny is the right call over auto-kill: the process holding 3000 is
# just as likely to be the user's deliberately-started server as it is debris.
set -uo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"

CMD="$(hook_field '.tool_input.command')"

# Only interested in commands that actually START a dev server, not ones that
# merely mention it (echo, grep, a heredoc writing docs).
invokes "$CMD" 'npm run dev|next dev' || exit 0

PIDS="$(dev_port_pids)"
[ -z "$PIDS" ] && exit 0

OWNERS="$(describe_pids "$PIDS")"
deny "Port ${DEV_PORT} is already in use — do not start a second dev server.
Holding process(es): ${OWNERS}
This may be the user's own server. Do NOT kill it unsolicited. Either reuse it (curl http://localhost:${DEV_PORT}) or ask the user first.
If it is confirmed orphaned debris, kill the real PID (not the npm wrapper): kill ${PIDS}"
Loading
Loading