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
65 changes: 65 additions & 0 deletions .claude/hooks/precompact-issues-capture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# PreCompact hook — ask for /issues capture BEFORE the context is discarded.
#
# The problem this closes: `.claude/hooks/issues-surface.sh` already prints a
# "run /issues capture" reminder, but it is a SessionStart hook, so on a
# `compact` trigger it fires *after* compaction has already happened. By then
# the in-flight follow-ups, deferrals and half-formed risks it wants recorded
# are exactly what was just summarised away. The reminder arrives after the
# thing it is trying to save is gone.
#
# PreCompact fires while that material is still in context, which is the only
# moment the reminder can actually be acted on.
#
# KNOWN LIMIT — read before trusting this. Claude Code injects hook stdout into
# the model's context for SessionStart / UserPromptSubmit / PreToolUse /
# PostToolUse. Whether it does so for PreCompact is NOT verified here, and could
# not be verified offline. So this hook deliberately prints plain human text
# rather than a hookSpecificOutput JSON envelope: if the platform does inject
# it, the text is useful as-is; if it does not, the operator still sees a clean,
# readable transcript line rather than a raw JSON blob. Either way the
# SessionStart reminder in issues-surface.sh remains the backstop, so nothing
# regresses if this turns out to be transcript-only. Re-check when the hook
# reference documents PreCompact context injection.
#
# Contract: READ-ONLY and always exits 0. It never writes the ledger, never
# commits, and must never be able to fail a compaction.
set -uo pipefail

payload="$(cat 2>/dev/null || true)"

# `trigger` is "manual" (the user ran /compact) or "auto" (the context window
# filled). Both lose the same material; the wording differs only so the operator
# can tell which one they are looking at.
trigger="$(printf '%s' "$payload" \
| grep -o '"trigger"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -n1 | sed -E 's/.*"([^"]*)"$/\1/')"

case "$trigger" in
manual) why="This compaction was requested manually." ;;
auto) why="The context window filled, so this compaction was automatic." ;;
*) why="This session is about to be compacted." ;;
esac

echo "[issues] ${why} Anything this session discovered but has not written down is about to be summarised away: unresolved follow-ups, deferrals, known risks, and work you decided NOT to do and why. Record them now with /issues add … (or /issues capture for a sweep) — docs/outstanding-issues.md is the only memory that survives a context reset. Requests land as immutable files under docs/outstanding-issues-inbox/; they are not committed unless you are explicitly asked to commit."

# Self-verification, because the KNOWN LIMIT above cannot be resolved by reading code.
# Whether the platform injects this hook's stdout into model context is not something the
# repo can determine — the installed CLI ships a compiled binary with no inspectable
# bundle. What the repo CAN do is make the question answerable instead of permanently open:
# append one line per firing to a log outside the worktree, so after the next compaction
# `cat "$(git rev-parse --absolute-git-dir)/claude-precompact.log"` says whether the hook
# ran at all. If lines appear but the reminder never reached the model, the limit is real
# and the SessionStart backstop is doing the work; if no lines appear, the registration is
# wrong. Either answer is actionable; "unverified" is not.
#
# Kept under the git dir, never the worktree, so it can never be staged or committed.
log_dir="$(git rev-parse --absolute-git-dir 2>/dev/null || true)"
if [ -n "$log_dir" ] && [ -d "$log_dir" ]; then
printf '%s precompact trigger=%s\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)" \
"${trigger:-unknown}" \
>>"$log_dir/claude-precompact.log" 2>/dev/null || true
fi

exit 0
101 changes: 101 additions & 0 deletions .claude/hooks/push-format-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# PreToolUse — block `git push` when the tree is not Prettier-clean.
#
# Why this exists at all, given .githooks/pre-push already checks formatting:
# `core.hooksPath` is set by *this checkout's* `npm install`. An agent session
# that pushes from an environment where that never ran — Claude Code on the web,
# a fresh container, a worktree created without an install — bypasses the git
# hook entirely, and only CI catches the break. AGENTS.md records three CI
# failures on 2026-07-30 from exactly this, two of them on a file the author had
# not edited (a per-file `prettier --check` passed while the repository-wide
# check failed).
#
# So this hook deliberately does NOT duplicate the git hook. It runs ONLY when
# the git hook is absent or not wired to this repo's .githooks directory — i.e.
# exactly the gap case. In a normally installed checkout it exits in
# milliseconds having done nothing, and `guard-push.mjs` remains the real gate
# (it is stricter: it checks the *pushed commit* in an isolated worktree, not
# the working tree, which is a check this hook cannot perform).
#
# Escape hatch: prefix the command with CLAUDE_ALLOW_UNFORMATTED_PUSH=1.
#
# Contract: never fails a tool call by accident. Any parse problem, missing
# dependency, or unexpected state exits 0 with no decision, leaving the call
# exactly as it was. Failing open is correct here — the git hook and CI are both
# still downstream.
set -uo pipefail

payload="$(cat 2>/dev/null || true)"
[ -z "$payload" ] && exit 0

# --- extract the command ------------------------------------------------------
if command -v jq >/dev/null 2>&1; then
tool_name="$(printf '%s' "$payload" | jq -r '.tool_name // empty' 2>/dev/null || true)"
command_text="$(printf '%s' "$payload" | jq -r '
.tool_input.command // .tool_input.script // .tool_input.code // empty
' 2>/dev/null || true)"
else
tool_name="$(printf '%s' "$payload" \
| grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -n1 | sed -E 's/.*"([^"]*)"$/\1/')"
# Quote-naive extraction truncates at the first escaped quote, so fall back to
# the whole payload for matching. Over-matching is the safe direction here: the
# worst case is one extra Prettier run on a command that merely mentions a push.
command_text="$(printf '%s' "$payload" \
| grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -n1 | sed -E 's/.*"([^"]*)"$/\1/')"
[ -z "$command_text" ] && command_text="$payload"
fi

case "$tool_name" in
"" | Bash | PowerShell) ;;
*) exit 0 ;;
esac

# --- is this a push? ----------------------------------------------------------
printf '%s' "$command_text" | grep -Eq '(^|[;&|[:space:]])git[[:space:]]+push([[:space:]]|$)' || exit 0

# --- documented escape hatch (leading prefix only, not an incidental mention) --
printf '%s' "$command_text" \
| grep -Eq '^[[:space:]]*CLAUDE_ALLOW_UNFORMATTED_PUSH=1([[:space:]]|$)' && exit 0

# --- only act in the gap case: the repo's pre-push hook is not wired ----------
repo_root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || true)}"
[ -z "$repo_root" ] && exit 0
hooks_path="$(git -C "$repo_root" config --get core.hooksPath 2>/dev/null || true)"
if [ -n "$hooks_path" ]; then
# Normalise Windows backslashes so a native `git config` value compares equal
# to the POSIX path this script sees.
normalised="$(printf '%s' "$hooks_path" | tr '\\' '/')"
case "$normalised" in
*/.githooks)
if [ -x "$repo_root/.githooks/pre-push" ]; then
exit 0
fi
;;
esac
fi

# --- run the repository-wide check, never a per-file one ---------------------
# Per-file is not the repository-wide check: on 2026-07-30 a doc/ledger edit in
# the same push was the missed file twice out of three, while `prettier --check`
# on the edited source file passed.
command -v npx >/dev/null 2>&1 || exit 0
[ -d "$repo_root/node_modules/prettier" ] || exit 0

if unformatted="$(cd "$repo_root" && npx --no-install prettier --check . 2>&1)"; then
exit 0
fi

json_escape() {
printf '%s' "$1" \
| tr '\n\r\t' ' ' \
| tr -d '\000-\010\013\014\016-\037' \
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}

offenders="$(printf '%s' "$unformatted" | grep -E '^\[warn\] ' | head -n 8 | sed 's/^\[warn\] //' | tr '\n' ' ')"
reason="Blocked: this push would land unformatted files, and this checkout has no wired .githooks/pre-push guard to catch it, so CI would be the first thing to fail (AGENTS.md records three such CI failures on 2026-07-30). Unformatted: ${offenders:-see prettier output}. Fix with: npm run format — then COMMIT the result, because a push sends commits and not your working tree, so formatting after committing leaves the unformatted blob on the branch. Override for this one command with the CLAUDE_ALLOW_UNFORMATTED_PUSH=1 prefix."

printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$(json_escape "$reason")"
exit 0
Empty file modified .claude/hooks/session-start.sh
100644 → 100755
Empty file.
142 changes: 137 additions & 5 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,139 @@
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.local)",
"Read(./.env.*.local)",
"Read(./.env.production)",
"Read(./.env.staging)",
"Bash(git push --force:*)",
"Bash(git push -f:*)",
"mcp__supabase__execute_sql",
"mcp__supabase__apply_migration",
"mcp__supabase__deploy_edge_function",
"mcp__supabase__create_project",
"mcp__supabase__create_branch",
"mcp__supabase__delete_branch",
"mcp__supabase__merge_branch",
"mcp__supabase__reset_branch",
"mcp__supabase__rebase_branch",
"mcp__supabase__pause_project",
"mcp__supabase__restore_project"
],
"ask": [
"Bash(npm run eval:*)",
"Bash(npm run test:live)",
"Bash(npm run test:live:*)",
"Bash(npm run test:cross-tenant:staging)",
"Bash(npm run verify:release)",
"Bash(npm run verify:release:*)",
"Bash(npm run check:supabase-project)",
"Bash(npm run check:production-readiness)",
"Bash(npm run check:production-readiness:*)",
"Bash(npm run check:github-shell-access:live)",
"Bash(npm run sync:pr-branches)",
"Bash(npm run sync:pr-branches:*)",
"Bash(npm run reindex)",
"Bash(npm run reindex:*)",
"Bash(npm run import:docs)",
"Bash(npm run import:docs:*)",
"Bash(npm run enrich:*)",
"Bash(npm run classify:documents)",
"Bash(npm run governance:release)",
"Bash(npm run audit:source-governance:release)",
"Bash(gh api:*)",
"Bash(git push:*)",
"Bash(git fetch:*)",
"Bash(railway:*)",
"mcp__railway__set-variables",
"mcp__railway__redeploy",
"mcp__railway__create-deployment",
"mcp__railway__accept-deploy",
"mcp__railway__update-service",
"mcp__railway__create-service",
"mcp__railway__create-project",
"mcp__railway__set-feature-flag",
"mcp__railway__delete-feature-flag",
"mcp__railway__generate-domain",
"mcp__railway__railway-agent"
],
"allow": [
"Bash(npm run lint)",
"Bash(npm run lint:internal)",
"Bash(npm run typecheck)",
"Bash(npm run typecheck:source)",
"Bash(npm run format)",
"Bash(npm run format:check)",
"Bash(npm run format:changed)",
"Bash(npm run test)",
"Bash(npm run test:focused)",
"Bash(npm run test:focused --*)",
"Bash(npm run verify:cheap)",
"Bash(npm run verify:pr-local)",
"Bash(npm run verify:pr-local --*)",
"Bash(npm run verify:phone-chrome)",
"Bash(npm run ensure)",
"Bash(npm run skills)",
"Bash(npm run docs:check-index)",
"Bash(npm run docs:check-inventory)",
"Bash(npm run docs:check-links)",
"Bash(npm run docs:check-scripts)",
"Bash(npm run docs:update)",
"Bash(npm run sitemap:check)",
"Bash(npm run sitemap:update)",
"Bash(npm run check:base-freshness)",
"Bash(npm run check:runtime)",
"Bash(npm run check:installed-lock-parity)",
"Bash(npm run check:skills)",
"Bash(npm run check:gate-manifest)",
"Bash(npm run check:pr-policy)",
"Bash(npm run check:ci-scope)",
"Bash(npm run check:branch-review-ledger)",
"Bash(npm run check:outstanding-issues)",
"Bash(npm run check:ledger-write-discipline)",
"Bash(npm run ledger:lookup --*)",
"Bash(npm run issues:report --*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git branch:*)",
"Bash(git rev-parse:*)",
"Bash(git merge-base:*)",
"Bash(git worktree list:*)",
"Bash(node scripts/check-base-freshness.mjs:*)",
"Bash(node scripts/clean-worktree.mjs:*)"
]
},
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh\"",
"timeout": 900
},
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/scripts/check-base-freshness.mjs\""
"command": "node \"$CLAUDE_PROJECT_DIR/scripts/check-base-freshness.mjs\" --hook",
"timeout": 30
},
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/issues-surface.sh\""
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/issues-surface.sh\"",
"timeout": 30
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/precompact-issues-capture.sh\"",
"timeout": 15
}
]
}
Expand All @@ -24,7 +144,8 @@
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/pr-handoff-stop.sh\" post"
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/pr-handoff-stop.sh\" post",
"timeout": 15
}
]
}
Expand All @@ -35,7 +156,18 @@
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/pr-handoff-stop.sh\" pre"
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/pr-handoff-stop.sh\" pre",
"timeout": 15
}
]
},
{
"matcher": "Bash|PowerShell",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/push-format-guard.sh\"",
"timeout": 180
}
]
}
Expand Down
Loading
Loading