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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ apply:

fmt:
npx --loglevel error --yes prettier --write **/*.yml
uvx mdformat --wrap 80 --number *.md
uvx --with mdformat-gfm --with mdformat-frontmatter mdformat --wrap 80 --number **/*.md
uvx ruff format --line-length=100 **/*.py
uvx ruff check --fix --line-length=100 **/*.py
npx --loglevel error --yes @johnnymorganz/stylua-bin -- **/*.lua

fmt_check:
npx --loglevel error --yes prettier --check **/*.yml
uvx mdformat --check --wrap 80 --number *.md
uvx --with mdformat-gfm --with mdformat-frontmatter mdformat --check --wrap 80 --number **/*.md
uvx ruff format --check --line-length=100 **/*.py
uvx ruff check --line-length=100 **/*.py
npx --loglevel error --yes @johnnymorganz/stylua-bin --check -- **/*.lua
Expand Down
11 changes: 11 additions & 0 deletions ansible/dotfiles.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@
- skills
- setup

- name: Include agent hooks and Cursor rules deployment
ansible.builtin.include_tasks:
file: tasks/agent_hooks.yml
apply:
tags:
- hooks
- setup
tags:
- hooks
- setup

- name: Include paperless setup tasks
include_tasks: tasks/paperless.yml
tags:
Expand Down
32 changes: 32 additions & 0 deletions ansible/tasks/agent_hooks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
- name: Ensure agent config directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ ansible_user_dir }}/.claude"
- "{{ ansible_user_dir }}/.cursor"
tags:
- hooks
- setup

- name: Link Claude settings (global prompt hooks)
ansible.builtin.file:
src: "{{ dotfiles_dir }}/claude/settings.json"
dest: "{{ ansible_user_dir }}/.claude/settings.json"
state: link
force: true
tags:
- hooks
- setup

- name: Link Cursor rules
ansible.builtin.file:
src: "{{ dotfiles_dir }}/cursor/rules"
dest: "{{ ansible_user_dir }}/.cursor/rules"
state: link
force: true
tags:
- hooks
- setup
1 change: 1 addition & 0 deletions ansible/tasks/neovim.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- debugpy
- isort
- mdformat
- mdformat-frontmatter
- neovim
- pynvim
- ruff
Expand Down
73 changes: 73 additions & 0 deletions claude/hooks/dbt-rules.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# PostToolUse(Write|Edit) hook: inject dbt conventions (cursor/rules/dbt.mdc)
# when a .sql/.yml file inside a dbt project (dbt_project.yml ancestor) is
# edited. Claude Code has no equivalent of Cursor's glob-scoped rule loading,
# so this mirrors it: full rule on the first qualifying edit per session,
# then a one-line reminder of the load-bearing rules on later edits — cheap
# insurance against context compaction without re-paying ~700 tokens per
# edit.

set -euo pipefail

input=$(cat)
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')

case "$file_path" in
*.sql | *.yml) ;;
*) exit 0 ;;
esac

dir=$(dirname "$file_path")
found=""
while [ -n "$dir" ] && [ "$dir" != "/" ]; do
if [ -f "$dir/dbt_project.yml" ]; then
found=1
break
fi
dir=$(dirname "$dir")
done
[ -n "$found" ] || exit 0

emit() {
jq -n --arg ctx "$1" \
'{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}'
}

session_id=$(printf '%s' "$input" | jq -r '.session_id // empty')
marker=""
case "$session_id" in
"" | *[!A-Za-z0-9_-]*) ;;
*) marker="${TMPDIR:-/tmp}/claude-dbt-rules-${session_id}" ;;
esac

if [ -n "$marker" ] && [ -e "$marker" ]; then
emit "Reminder — dbt conventions apply (full rules injected earlier this session): every model has a uniqueness test on its declared grain; a failing grain test is a finding, NEVER fixed by widening the key or deduplicating — stop and report the join that fanned out; all dedup (DISTINCT/QUALIFY/row_number) must be called for in the plan; marts carry a reconciliation test against a number the model does not control."
exit 0
fi

script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
dbt_rule="$script_dir/../../cursor/rules/dbt.mdc"

if [ ! -f "$dbt_rule" ]; then
echo "dbt-rules hook: missing dbt rule at $dbt_rule" >&2
exit 1
fi

# Strip the YAML frontmatter (between the first two `---` lines).
rule_body=$(awk 'BEGIN{f=0} /^---$/{f++; next} f>=2{print}' "$dbt_rule")

prompt=$(
cat <<PROMPT
You are editing files in a dbt project. The following conventions apply to
all dbt work this session:

<dbt_rules>
$rule_body
</dbt_rules>
PROMPT
)

if [ -n "$marker" ]; then
touch "$marker"
fi
emit "$prompt"
72 changes: 72 additions & 0 deletions claude/hooks/plan-review.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# PostToolUse(Write|Edit) hook: review plan documents.
# Path-gates first so non-plan edits exit silently, then emits an audit
# prompt wrapping the canonical planning rule (cursor/rules/planning.mdc)
# as PostToolUse additionalContext JSON — plain stdout at exit 0 never
# reaches the model. The full rule (~300 tokens) is injected on every
# plan edit: a once-per-session marker would go stale after context
# compaction, leaving the model auditing against a rubric it no longer
# has.

set -euo pipefail

input=$(cat)
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')

case "$file_path" in
*/.cursor/plans/* | */.claude/plans/*) ;;
*) exit 0 ;;
esac

emit() {
jq -n --arg ctx "$1" \
'{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}'
}

script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
planning_rule="$script_dir/../../cursor/rules/planning.mdc"

if [ ! -f "$planning_rule" ]; then
echo "plan-review hook: missing planning rule at $planning_rule" >&2
exit 1
fi

# Strip the YAML frontmatter (between the first two `---` lines).
rule_body=$(awk 'BEGIN{f=0} /^---$/{f++; next} f>=2{print}' "$planning_rule")

prompt=$(
cat <<PROMPT
You just edited a plan document. Before any implementation, audit it
against the planning rule in <planning_rule> below. The gates are:

1. Exit criteria
2. Invariants
3. Failure modes (including the one-line premortem)
4. Assumptions & unknowns
5. Outside view
6. Minimal viable change
7. Visualization confirmation (only when the plan involves figures)

A gate passes ONLY if you can quote the plan line(s) that satisfy it.
Paraphrases, "implied somewhere," or "this is obvious" do NOT count.
If you are unsure whether a quote satisfies a gate, the gate FAILS.

<planning_rule>
$rule_body
</planning_rule>

## Audit output

Output exactly one line per gate, in the order listed above:

\`<gate>: PASS — "<quoted plan line>"\`
\`<gate>: FAIL — <specific gap>\`
\`<gate>: N/A — <why this gate does not apply>\`

If there are no FAIL lines, end with \`PLAN OK\` on its own line and
begin implementing. Otherwise stop and ask the user to fill the gaps
before implementing.
PROMPT
)

emit "$prompt"
111 changes: 111 additions & 0 deletions claude/hooks/stop-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Stop hook: nag about build/render when, in the current turn:
# - a .sql file was edited inside a dbt project (dbt_project.yml ancestor), or
# - a .qmd file was edited (any location — quarto renders standalone files)
# AND no build/render command was run this turn.
#
# Reads the per-session state file written by track-tool-use.sh instead of
# parsing the transcript. A nag is delivered as {"decision": "block",
# "reason": ...} — the only output Claude Code feeds back to the model.
#
# Robustness invariants (this is a reminder, not an enforcement gate):
# - Blocks at most ONCE per turn: when stop_hook_active is set (Claude is
# already continuing because of this hook), clear state and allow the
# stop unconditionally. We deliberately do not re-check whether the
# build actually ran — re-checking is how agents get stuck in loops.
# - Every error path fails open (exit 0, no block): missing/malformed
# state file or fields, jq failures, unreadable input.

set -uo pipefail # no -e: error paths must fall through to allow the stop

input=$(cat) || exit 0
session_id=$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null) || exit 0
[ -z "$session_id" ] && exit 0
case "$session_id" in
*[!A-Za-z0-9_-]*) exit 0 ;;
esac

state_file="${TMPDIR:-/tmp}/claude-turn-${session_id}.jsonl"

stop_hook_active=$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null) || stop_hook_active=false
if [ "$stop_hook_active" = "true" ]; then
rm -f "$state_file"
exit 0
fi

[ -f "$state_file" ] || exit 0

edits=$(jq -r 'select(.tool == "Write" or .tool == "Edit") | .path // empty' \
"$state_file" 2>/dev/null | grep -E '\.(sql|qmd)$') || edits=""

if [ -z "$edits" ]; then
rm -f "$state_file"
exit 0
fi

# Walk up from a directory looking for any of the given marker filenames.
# Returns 0 if any marker is found, 1 otherwise.
has_marker() {
local dir="$1"
shift
while [ -n "$dir" ] && [ "$dir" != "/" ]; do
for marker in "$@"; do
[ -f "$dir/$marker" ] && return 0
done
dir=$(dirname "$dir")
done
return 1
}

dbt_files=""
quarto_files=""

while IFS= read -r path; do
[ -z "$path" ] && continue
case "$path" in
*.sql)
if has_marker "$(dirname "$path")" dbt_project.yml; then
dbt_files="${dbt_files}${path}"$'\n'
fi
;;
*.qmd)
quarto_files="${quarto_files}${path}"$'\n'
;;
esac
done <<<"$edits"

if [ -z "$dbt_files" ] && [ -z "$quarto_files" ]; then
rm -f "$state_file"
exit 0
fi

bashes=$(jq -r 'select(.tool == "Bash") | .cmd // empty' "$state_file" 2>/dev/null) || bashes=""

if printf '%s' "$bashes" | grep -qE '(just (build|html|render)|dbt (build|run|test)|quarto render)'; then
rm -f "$state_file"
exit 0
fi

# Keep the state file: the post-block stop is allowed via stop_hook_active,
# which also cleans it up.
reason=$(
echo "You edited build-relevant files this turn but did not run a build/render command:"
if [ -n "$dbt_files" ]; then
echo
echo "dbt project files (dbt_project.yml ancestor found):"
printf '%s' "$dbt_files" | sed 's/^/ /'
echo " → run \`dbt build\` (or \`just build\` if the project has a justfile target)."
fi
if [ -n "$quarto_files" ]; then
echo
echo "quarto documents:"
printf '%s' "$quarto_files" | sed 's/^/ /'
echo " → run \`quarto render <file>\` (or \`just html\`/\`just render\` if the project has it)."
fi
echo
echo "Run the build/render now; if it fails, fix the failures before stopping, and report the result either way."
echo "If a build is genuinely not applicable (e.g. the edit was comment-only or the file was deleted later in the turn), say why in one line instead."
)

jq -n --arg reason "$reason" '{decision: "block", reason: $reason}' 2>/dev/null || true
exit 0
24 changes: 24 additions & 0 deletions claude/hooks/track-tool-use.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# PostToolUse(Write|Edit|Bash) hook: append this turn's tool uses to a
# per-session state file so the Stop hook (stop-check.sh) can see which
# files were edited and which commands ran without parsing the transcript.
# Never blocks anything: no output, always exits 0.

set -uo pipefail

input=$(cat) || exit 0
session_id=$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null) || exit 0
[ -z "$session_id" ] && exit 0

# Session id becomes part of a filename; accept only safe characters.
case "$session_id" in
*[!A-Za-z0-9_-]*) exit 0 ;;
esac

printf '%s' "$input" | jq -c '{
tool: .tool_name,
path: (.tool_input.file_path // null),
cmd: (.tool_input.command // null)
}' >>"${TMPDIR:-/tmp}/claude-turn-${session_id}.jsonl" 2>/dev/null || true

exit 0
Loading
Loading