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: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ jobs:
ai/tests/test-canonical-skills.sh
ai/tests/test-plain-writing-contract.sh
ai/tests/test-ai-installers.sh
ai/tests/test-command-log.sh
ai/tests/test-log-step-done.sh
ai/skills/ran/scripts/tests/test-ran-report.sh
ai/helpers/tests/test-repo-context.sh
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Skills live in [`ai/skills/`](ai/skills). The installer symlinks the same direct
| [`plain-writing`](ai/skills/plain-writing) | Write, rewrite, or review prose for other people in clear, direct English. |
| [`posthog-context`](ai/skills/posthog-context) | PostHog repo workflow, database access rules, production architecture notes, SDK repository locations. |
| [`quarterly-planning`](ai/skills/quarterly-planning) | Draft quarterly goals for a PostHog team, walking the HOGS framework from issues and strategy docs. |
| [`ran`](ai/skills/ran) | Show which workflow steps have run against this branch and which are missing or stale. |
| [`resolve-conflicts`](ai/skills/resolve-conflicts) | Resolve git conflicts with mergiraf structural merging, lock file handling, stacked PR dedup. |
| [`review-fix-cycle`](ai/skills/review-fix-cycle) | One review, fix, simplify, clean comments, commit iteration. |
| [`simplify`](ai/codex/skills/simplify) | Simplify recently changed code for clarity and maintainability without changing behavior. Codex only; Claude bundles its own. |
Expand Down
4 changes: 4 additions & 0 deletions ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,13 @@ Run the installer and portability tests with:
```sh
ai/tests/test-ai-installers.sh
ai/tests/test-canonical-skills.sh
ai/tests/test-command-log.sh
ai/tests/test-log-step-done.sh
ai/tests/test-plain-writing-contract.sh
python3 ai/skills/plain-writing/scripts/tests/test_plain_writing_lint.py
ai/tests/test-skill-spec.sh
ai/skills/ran/scripts/tests/test-ran-report.sh
ai/helpers/tests/test-repo-context.sh
```

`test-skill-spec.sh` validates every skill against the agentskills.io spec: directory name equals the frontmatter `name`, `description` is 1–1024 characters, and frontmatter only uses spec keys plus this repo's own extensions (`argument-hint`, `model`, `color`). Skills listed in `codex/excluded-skills.txt` also carry `compatibility: Designed for Claude Code (or similar products)` so spec-aware clients know they are Claude-only.
86 changes: 86 additions & 0 deletions ai/bin/log-command.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Claude Code hook that logs workflow commands so a later session can answer
# "which steps have run against this branch?" after the context is gone.
#
# Registered on two events, because both paths are common: UserPromptSubmit
# catches a command you type, PostToolUse/Skill catches one the model invokes.
#
# Writes one JSON object per line to
# $RAN_STATE_DIR/<org>/<repo>/<branch>.jsonl (default ~/.local/state/ran).
# Appending never rewrites the file, so two worktrees on one branch cannot
# clobber each other's entries.
#
# Both events fire before the command does its work, so every record here is
# `status: "started"`. log-step-done.sh writes the matching "done", and steps
# declared `completion` in the step table count only those.
#
# UserPromptSubmit fires on every prompt in every repo, so the cheap rejection
# comes first: one jq reads the command name, and nothing else runs (no helper
# sourcing, no git, no second parse) until that name is known to be a step.
#
# Best-effort by contract: every failure path exits 0, and nothing is ever
# written to stdout. UserPromptSubmit stdout is injected into the session as
# context, so a stray echo here would land in the model's prompt on every turn.

set -uo pipefail

command -v jq > /dev/null 2>&1 || exit 0

# $(cat) costs one fork; bash's `read` from a pipe consumes a byte per syscall,
# which is the slower trade on the large prompts a paste produces.
INPUT=$(cat)
[ -n "$INPUT" ] || exit 0

# One parse for everything the rejection needs. Only the command's first token
# crosses on the TSV line; a refname cannot contain whitespace, and the prompt's
# arbitrary text never does.
IFS=$'\t' read -r event cwd raw < <(
printf '%s' "$INPUT" | jq -r '
[ .hook_event_name // "",
.cwd // "",
(if .hook_event_name == "PostToolUse"
then (if .tool_name == "Skill" then (.tool_input.skill // "") else "" end)
else ((.prompt // "") | select(startswith("/")) | split(" ")[0])
end) // ""
] | @tsv' 2> /dev/null
)

[ -n "${raw:-}" ] || exit 0
case "$event" in
UserPromptSubmit | PostToolUse) ;;
*) exit 0 ;;
esac

SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
# shellcheck source=../helpers/command-steps.sh
. "${SCRIPT_DIR}/../helpers/command-steps.sh" 2> /dev/null || exit 0
# shellcheck source=../helpers/repo-context.sh
. "${SCRIPT_DIR}/../helpers/repo-context.sh" 2> /dev/null || exit 0

step=$(canonical_step "$raw") || exit 0

[ -n "$cwd" ] && [ -d "$cwd" ] && cd "$cwd" 2> /dev/null || exit 0
derive_org_repo || exit 0
repo_context_is_path_safe || exit 0

branch=$(git branch --show-current 2> /dev/null)
[ -n "$branch" ] || exit 0
log_file=$(command_log_path "$REPO_ORG" "$REPO_REPO" "$branch") || exit 0
sha=$(git rev-parse --short HEAD 2> /dev/null) || exit 0

mkdir -p "${log_file%/*}" 2> /dev/null || exit 0

# Reads session and agent straight off the payload rather than paying a fork
# each to pass them in.
printf '%s' "$INPUT" | jq -c \
--arg step "$step" \
--arg command "$raw" \
--arg sha "$sha" \
--arg branch "$branch" \
'{ts: (now | todate), step: $step, command: $command, status: "started",
source: (if .hook_event_name == "PostToolUse" then "skill" else "typed" end),
sha: $sha, branch: $branch,
session: (.session_id // ""), agent: (.agent_id // null)}' \
>> "$log_file" 2> /dev/null

exit 0
53 changes: 53 additions & 0 deletions ai/bin/log-step-done.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Record that a workflow step finished, for the /ran report and /go's resume.
#
# Usage: log-step-done.sh <step>
#
# A skill calls this as its last action, so the entry proves the work happened
# rather than that a command was submitted. log-command.sh cannot do it: both
# hooks it runs on fire before the command executes, so a review abandoned at
# the prompt writes what a finished one writes. Steps declared `completion` in
# COMMAND_STEP_TABLE count only the records this writes.
#
# Unlike the hook, this fails loudly. It runs from a SKILL.md step where a wrong
# name is a typo, and a silent exit 0 would turn that typo into a step that
# reads "never ran" on every branch with nothing to show why.

set -uo pipefail

die() {
echo "log-step-done: $1" >&2
exit 1
}

[ $# -eq 1 ] || die "usage: log-step-done.sh <step>"
step="$1"

command -v jq > /dev/null 2>&1 || die "jq is required"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../helpers/command-steps.sh
. "${SCRIPT_DIR}/../helpers/command-steps.sh" || die "could not load command-steps.sh"
# shellcheck source=../helpers/repo-context.sh
. "${SCRIPT_DIR}/../helpers/repo-context.sh" || die "could not load repo-context.sh"

command_step_declared "$step" || die "'$step' is not a step in COMMAND_STEP_TABLE"

derive_org_repo || die "not a GitHub repository"
repo_context_is_path_safe || die "org or repo is not safe as a path component"

branch=$(git branch --show-current 2> /dev/null)
[ -n "$branch" ] || die "detached HEAD: no branch to record against"
log_file=$(command_log_path "$REPO_ORG" "$REPO_REPO" "$branch") || die "branch name has no safe log filename"
sha=$(git rev-parse --short HEAD 2> /dev/null) || die "no commits on this branch"

mkdir -p "${log_file%/*}" || die "could not create ${log_file%/*}"

jq -c -n \
--arg step "$step" \
--arg sha "$sha" \
--arg branch "$branch" \
'{ts: (now | todate), step: $step, command: null, status: "done",
source: "skill", sha: $sha, branch: $branch,
session: "", agent: null}' \
>> "$log_file" || die "could not append to $log_file"
5 changes: 5 additions & 0 deletions ai/codex/excluded-skills.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ explain-open
go
review-fix-cycle

# Reads a log that only Claude Code hooks write. Under Codex the hooks never fire,
# so every step of the session's own work would report as never run, which reads as
# authoritative and is wrong. Revisit if Codex gains prompt and tool-call hooks.
ran

# Reads untrusted CI logs and PR comments from outside contributors, and relies on
# Claude's path-scoped allowed-tools to fence what it may run. Codex has no per-skill
# tool scoping, so that fence silently disappears. Revisit if Codex adds one.
Expand Down
80 changes: 80 additions & 0 deletions ai/helpers/command-steps.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# The workflow vocabulary: which commands count as a step, what order the steps
# run in, and where the log for a branch lives.
#
# Two related tables, deliberately not one-to-one. COMMAND_STEP_TABLE declares
# the pipeline the checklist renders; canonical_step maps what a user can type or
# the model can invoke onto it, and also recognizes commands that are logged
# without being positions in the sequence (go, explain-open). The set is
# editorial, not derivable: order is not a property of ai/skills/, `simplify` and
# `code-review` have no directory here at all, and the aliases are judgments.
#
# Usage: source command-steps.sh, then:
# canonical_step /simplify -> prints "simplify", returns 0
# canonical_step /model -> prints nothing, returns 1
# command_step_table_json -> the table as JSON, for the jq verdict
# command_log_path <org> <repo> <branch> -> prints the log path, returns 1 if
# the branch has no safe filename
# command_step_declared <step> -> returns 0 if the table has that step

# step:evidence:optionality. `evidence` is where the row's state comes from:
# `log` for a step a command records, `commits` for one only the branch shows,
# `completion` for one that counts only once the skill says it finished.
# `optional` marks a step whose absence is not worth flagging.
#
# The hook records a command when it is submitted, which is before it runs, so a
# review abandoned at the prompt logs what a finished one logs. The two review
# steps therefore take `completion`: skipping a review nobody ran is the one
# outcome this vocabulary exists to prevent, and a re-run of a review that did
# happen costs only time. The rest keep `log`, where a false positive costs a
# repeated `simplify` and the work is visible in the tree or on the PR anyway.
COMMAND_STEP_TABLE='implement:commits:required
simplify:log:required
comment-cleanup:log:optional
commit:log:required
create-pr:log:required
review-code:completion:required
address-pr-reviews:completion:required
ci-monitor:log:required'

command_step_table_json() {
printf '%s\n' "$COMMAND_STEP_TABLE" |
jq -R -n -c '[inputs | select(length > 0) | split(":")
| {step: .[0], evidence: .[1], optional: (.[2] == "optional")}]'
}

command_step_declared() { # step
while IFS=: read -r table_step _; do
[ "$table_step" = "$1" ] && return 0
done <<< "$COMMAND_STEP_TABLE"
return 1
}

canonical_step() {
case "${1#/}" in
simplify) echo simplify ;;
comment-cleanup) echo comment-cleanup ;;
commit) echo commit ;;
create-pr) echo create-pr ;;
# review-fix-cycle is a review plus its own fix loop, so it satisfies the
# same step as a direct review-code run.
review-code | code-review | review-fix-cycle) echo review-code ;;
address-pr-reviews) echo address-pr-reviews ;;
ci-monitor) echo ci-monitor ;;
explain-open) echo explain-open ;;
go) echo go ;;
*) return 1 ;;
esac
}

# The writer and the reader have to agree on this byte for byte, or the report
# reads a file the hook never wrote. Callers must have validated org and repo
# with repo_context_is_path_safe first.
command_log_path() { # org repo branch
local safe="${3//\//-}"
safe="${safe//[!A-Za-z0-9._-]/}"
case "$safe" in
"" | [.-]*) return 1 ;;
esac
printf '%s/%s/%s/%s.jsonl' "${RAN_STATE_DIR:-$HOME/.local/state/ran}" "$1" "$2" "$safe"
}
15 changes: 15 additions & 0 deletions ai/helpers/repo-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,18 @@ derive_org_repo() {
fi
return 1
}

# The captures come from a remote URL, which whoever set the remote controls:
# git@github.com:../evil.git parses as an org of "..". Any caller that builds a
# filesystem path out of REPO_ORG/REPO_REPO must pass this first, or the path
# escapes the directory it was meant to stay under. Rejects rather than rewrites,
# so two different repos can never collapse onto one sanitized name.
repo_context_is_path_safe() {
local component
for component in "$REPO_ORG" "$REPO_REPO"; do
case "$component" in
"" | [.-]* | *[!A-Za-z0-9._-]*) return 1 ;;
esac
done
return 0
}
68 changes: 58 additions & 10 deletions ai/install-claude.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ export ZSH=$HOME/.dotfiles
. $ZSH/ai/helpers/json-settings.sh
. $ZSH/ai/helpers/managed-links.sh

# Drop every hook this repo owns from settings.json, identified by its command
# path. Filtering happens at the individual hook level, so a hand-added hook
# sharing an element with a managed one survives; elements left empty are
# dropped. Inline lint/format hook commands carry no path marker and stay.
#
# Install runs this before merging too, not just uninstall: merge_json_settings
# dedupes arrays with `unique`, which collapses byte-identical elements only, so
# changing a shipped hook's command or timeout would otherwise leave the old
# entry firing alongside the new one.
prune_managed_hooks() { # settings_file
prune_target="$1"
[ -f "$prune_target" ] || return 0
command -v jq > /dev/null 2>&1 || return 0
prune_tmp=$(mktemp)
if jq 'if .hooks then .hooks |= with_entries(.value |= (map(.hooks |= map(select((.command // "") | test("/\\.claude/skills/[^\"]*-detect\\.sh|/\\.dotfiles/ai/bin/") | not))) | map(select((.hooks | length) > 0)))) else . end' "$prune_target" > "$prune_tmp"; then
mv "$prune_tmp" "$prune_target"
return 0
fi
rm -f "$prune_tmp"
return 1
}

# Uninstall function
uninstall_claude_config() {
info "Uninstalling Claude configuration…"
Expand Down Expand Up @@ -49,18 +71,10 @@ uninstall_claude_config() {
fi
fi

# Remove path-identifiable managed hooks (skill detect scripts, ai/bin
# helpers) from settings.json so uninstalled hooks stop firing. Filtering
# happens at the individual hook level, so a hand-added hook sharing an
# element with a managed one survives; elements left empty are dropped.
# Inline lint/format hook commands carry no path marker and stay in place.
if [ "$INSTALL_HOOKS" = "true" ] && [ -f "$HOME/.claude/settings.json" ] && command -v jq >/dev/null 2>&1; then
tmp_settings=$(mktemp)
if jq 'if .hooks then .hooks |= with_entries(.value |= (map(.hooks |= map(select((.command // "") | test("/\\.claude/skills/[^\"]*-detect\\.sh|/\\.dotfiles/ai/bin/") | not))) | map(select((.hooks | length) > 0)))) else . end' "$HOME/.claude/settings.json" > "$tmp_settings"; then
mv "$tmp_settings" "$HOME/.claude/settings.json"
if [ "$INSTALL_HOOKS" = "true" ]; then
if prune_managed_hooks "$HOME/.claude/settings.json"; then
success "Removed managed detect/bin hooks from settings.json"
else
rm -f "$tmp_settings"
warning "Could not update hooks in settings.json"
fi
fi
Expand Down Expand Up @@ -431,6 +445,26 @@ if [ "$INSTALL_HOOKS" = "true" ]; then
"timeout": 30
}
]
},
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "~/.dotfiles/ai/bin/suggest-go-after-plan.sh",
"timeout": 5
}
]
},
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "~/.dotfiles/ai/bin/log-command.sh",
"timeout": 5
}
]
}
],
"Stop": [
Expand Down Expand Up @@ -465,12 +499,26 @@ if [ "$INSTALL_HOOKS" = "true" ]; then
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.dotfiles/ai/bin/log-command.sh",
"timeout": 5
}
]
}
]
}
}
EOF
)

prune_managed_hooks "$SETTINGS_FILE" || warning "Could not prune managed hooks before merging"

# Always run the merge. Do not re-add a `jq -e '.hooks.PostToolUse'` guard:
# it would skip the merge for users who already have any PostToolUse entry,
# preventing newly-added hook categories (SessionStart, new PreToolUse
Expand Down
Loading
Loading