466 lines
17 KiB
Bash
466 lines
17 KiB
Bash
#!/bin/sh
|
|
# Standalone Claude Code skill-hook entrypoint.
|
|
#
|
|
# Skill frontmatter command hooks receive their host identity as JSON on stdin;
|
|
# Claude Code does not export session_id for child processes. Keep stdin
|
|
# parsing here rather than teaching inject-plan.sh to consume input, because
|
|
# that script is also a public direct-call surface. UserPromptSubmit may emit
|
|
# plain context, while PreToolUse and PostToolUse require structured JSON for
|
|
# model-visible additionalContext.
|
|
#
|
|
# Events:
|
|
# userprompt re-arm this session's nudge, then preserve injector stdout.
|
|
# pretool serialize injector output as PreToolUse additionalContext.
|
|
# posttool validate the effective plan, then nudge once per turn.
|
|
# precompact forward the reminder with the resolved session identity.
|
|
# stop validate selection, then preserve stdin for the completion gate.
|
|
#
|
|
# The helper always exits 0. Missing identity or an unusable cache fails toward
|
|
# a repeated reminder, never toward a shared empty-id marker that could silence
|
|
# another session.
|
|
|
|
set -u
|
|
|
|
EVENT=""
|
|
for _arg in "$@"; do
|
|
case "$_arg" in
|
|
--event=*) EVENT="${_arg#--event=}" ;;
|
|
esac
|
|
done
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR="."
|
|
INJECT_PLAN="${SCRIPT_DIR}/inject-plan.sh"
|
|
GATE_STOP="${SCRIPT_DIR}/gate-stop.sh"
|
|
CHECK_COMPLETE="${SCRIPT_DIR}/check-complete.sh"
|
|
|
|
FAST_PATH="${SCRIPT_DIR}/inject-plan.py"
|
|
|
|
[ "${PLANNING_DISABLED:-}" = "1" ] && exit 0
|
|
[ -f "$INJECT_PLAN" ] || exit 0
|
|
|
|
# No planning state where the resolver will look and no selector to validate:
|
|
# every event below answers with nothing (and Stop is not consumed until a
|
|
# plan is accepted), so answer with nothing now, before any fork. A set
|
|
# PLAN_ID or PWF_PLAN_ROOT still gets its refusal notice from the injector.
|
|
if [ -z "${PLAN_ID:-}" ] && [ -z "${PWF_PLAN_ROOT:-}" ] \
|
|
&& [ ! -f task_plan.md ] && [ ! -d .planning ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Locate a CPython 3 without forking (v3.17.0). Every $(...) and every
|
|
# pipeline is a fork, and under Git Bash on Windows a fork costs about 90 ms;
|
|
# the old `$(command -v ...)` plus a `-c` version probe cost three of them on
|
|
# every event. This walk is stat calls only. Explicit PWF_TRUSTED_PYTHON or
|
|
# PYTHON_BIN wins; otherwise python3 is preferred over python across the whole
|
|
# PATH so an old distro's Python 2 `python` is never picked while a python3
|
|
# exists further down. Only absolute PATH entries are searched (a relative or
|
|
# empty entry would let the current repository plant the interpreter) and the
|
|
# Microsoft Store aliases are skipped by path: they are discoverable yet refuse
|
|
# to run a script. Python stays optional: without it the payload is still
|
|
# consumed to EOF, the session id is treated as absent, and the PostToolUse
|
|
# throttle deliberately degrades to repeat output.
|
|
select_python() {
|
|
for _fp_explicit in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
|
|
[ -n "$_fp_explicit" ] || continue
|
|
case "$_fp_explicit" in
|
|
/*|[A-Za-z]:[\\/]*) ;;
|
|
*) continue ;;
|
|
esac
|
|
case "$_fp_explicit" in
|
|
*[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]*) continue ;;
|
|
esac
|
|
if [ -f "$_fp_explicit" ] && [ -x "$_fp_explicit" ]; then
|
|
printf '%s\n' "$_fp_explicit"
|
|
return 0
|
|
fi
|
|
done
|
|
_fp_found=""
|
|
# set -u is active: an unset IFS or PATH must not kill the hook.
|
|
if [ "${IFS+set}" = set ]; then
|
|
_fp_saved_ifs="$IFS"
|
|
_fp_ifs_was_set=1
|
|
else
|
|
_fp_saved_ifs=""
|
|
_fp_ifs_was_set=0
|
|
fi
|
|
for _fp_name in python3 python; do
|
|
IFS=:
|
|
set -f
|
|
for _fp_dir in ${PATH-}; do
|
|
case "$_fp_dir" in
|
|
/*) ;;
|
|
*) continue ;;
|
|
esac
|
|
case "$_fp_dir" in
|
|
*[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]*) continue ;;
|
|
esac
|
|
if [ -f "${_fp_dir}/${_fp_name}" ] && [ -x "${_fp_dir}/${_fp_name}" ]; then
|
|
_fp_found="${_fp_dir}/${_fp_name}"
|
|
break
|
|
fi
|
|
done
|
|
set +f
|
|
if [ "$_fp_ifs_was_set" = 1 ]; then IFS="$_fp_saved_ifs"; else unset IFS; fi
|
|
if [ -n "$_fp_found" ]; then
|
|
printf '%s\n' "$_fp_found"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
PWF_PYTHON=""
|
|
select_python_into_var() {
|
|
# Assign without a subshell: the walk above is the only work this costs.
|
|
_spv_out="$(select_python 2>/dev/null)" || _spv_out=""
|
|
PWF_PYTHON="$_spv_out"
|
|
}
|
|
select_python_into_var
|
|
|
|
# Run the injector for one context. scripts/inject-plan.py is a byte-identical
|
|
# twin of inject-plan.sh in one interpreter process (about 130 forks fewer per
|
|
# event; see the header of hooks/claude-hook.sh). It exits 0 only when its
|
|
# stdout is the complete answer, so any other status falls back to the
|
|
# reference chain. PWF_FAST_PATH=0 forces the reference chain. -I keeps the
|
|
# project directory off sys.path; -B never writes bytecode into the skill dir.
|
|
# PWF_SHELL_PWD hands the twin this shell's $PWD spelling so both routes
|
|
# derive the same cache slots; MSYS2_ENV_CONV_EXCL keeps Git Bash from
|
|
# rewriting it on the way.
|
|
run_inject() {
|
|
if [ "${PWF_FAST_PATH:-}" != "0" ] && [ -n "$PWF_PYTHON" ] && [ -f "$FAST_PATH" ]; then
|
|
if PWF_SHELL_PWD="$PWD" \
|
|
MSYS2_ENV_CONV_EXCL="${MSYS2_ENV_CONV_EXCL:+${MSYS2_ENV_CONV_EXCL};}PWF_SHELL_PWD" \
|
|
"$PWF_PYTHON" -I -B "$FAST_PATH" "--context=$1" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
fi
|
|
sh "$INJECT_PLAN" "--context=$1" 2>/dev/null
|
|
}
|
|
|
|
# Keep the injector's established no-probe boundary. The preflight token is
|
|
# emitted only after a plan exists as a regular contained file, but before
|
|
# session admission needs stdin identity. Rejected paths must not make this
|
|
# wrapper execute any interpreter, so the preflight and the refusal notices
|
|
# stay on the shell chain; the twin runs only once the plan is accepted.
|
|
_preflight="$(sh "$INJECT_PLAN" --context=preflight 2>/dev/null)" || exit 0
|
|
if [ "$_preflight" != "PWF_PLAN_ELIGIBLE_V1" ]; then
|
|
case "$EVENT" in
|
|
userprompt) sh "$INJECT_PLAN" --context=userprompt 2>/dev/null || : ;;
|
|
precompact) sh "$INJECT_PLAN" --context=precompact 2>/dev/null || : ;;
|
|
esac
|
|
exit 0
|
|
fi
|
|
PARSED_IDENTITY=""
|
|
HOOK_PAYLOAD=""
|
|
if [ "$EVENT" = "stop" ]; then
|
|
# Stop's consumer must receive Claude's original JSON so stop_hook_active
|
|
# can prevent recursive continuation. Stop payloads are bounded host
|
|
# metadata; command substitution preserves the JSON while trimming only
|
|
# insignificant trailing newlines.
|
|
HOOK_PAYLOAD="$(cat 2>/dev/null)" || HOOK_PAYLOAD=""
|
|
fi
|
|
parse_identity() {
|
|
"$PWF_PYTHON" -I -c '
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
|
|
SAFE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z")
|
|
|
|
try:
|
|
payload = json.load(sys.stdin)
|
|
except (json.JSONDecodeError, OSError, UnicodeError, ValueError):
|
|
raise SystemExit(0)
|
|
|
|
if not isinstance(payload, dict):
|
|
raise SystemExit(0)
|
|
session_id = payload.get("session_id")
|
|
if not isinstance(session_id, str) or SAFE.fullmatch(session_id) is None:
|
|
raise SystemExit(0)
|
|
|
|
agent_id = payload.get("agent_id")
|
|
prompt_id = payload.get("prompt_id")
|
|
agent_valid = agent_id is None or (
|
|
isinstance(agent_id, str) and SAFE.fullmatch(agent_id) is not None
|
|
)
|
|
prompt_valid = isinstance(prompt_id, str) and SAFE.fullmatch(prompt_id) is not None
|
|
|
|
marker_key = ""
|
|
if agent_valid and (agent_id is None or prompt_valid):
|
|
digest = hashlib.sha256(b"planning-with-files-skill-turn-v1\0")
|
|
for value in (session_id, agent_id or "main"):
|
|
encoded = value.encode("utf-8", "surrogatepass")
|
|
digest.update(len(encoded).to_bytes(8, "big"))
|
|
digest.update(encoded)
|
|
marker_key = digest.hexdigest()
|
|
|
|
print("|".join((session_id, marker_key, prompt_id if prompt_valid else "")))
|
|
' 2>/dev/null
|
|
}
|
|
|
|
if [ -n "$PWF_PYTHON" ]; then
|
|
# Output is delimiter-safe because every source field is allowlisted. The
|
|
# marker key includes agent_id when present, so sibling agents in one Claude
|
|
# session cannot suppress one another. Old hosts without prompt_id still
|
|
# use UserPromptSubmit re-arming for the main agent; subagents without a
|
|
# turn id skip throttling rather than risk a permanent marker.
|
|
if [ "$EVENT" = "stop" ]; then
|
|
PARSED_IDENTITY="$(printf '%s' "$HOOK_PAYLOAD" | parse_identity)" \
|
|
|| PARSED_IDENTITY=""
|
|
else
|
|
PARSED_IDENTITY="$(parse_identity)" || PARSED_IDENTITY=""
|
|
fi
|
|
else
|
|
# The host writes one complete JSON payload. Consume it even on the
|
|
# dependency-free fallback so the hook owns exactly one native stdin frame.
|
|
[ "$EVENT" = "stop" ] || cat >/dev/null 2>&1 || :
|
|
fi
|
|
|
|
# Never trust a manually inherited PWF_SESSION_ID over the hook's own payload.
|
|
unset PWF_SESSION_ID
|
|
SESSION_ID=""
|
|
TURN_KEY=""
|
|
PROMPT_ID=""
|
|
case "$PARSED_IDENTITY" in
|
|
*"|"*"|"*)
|
|
SESSION_ID="${PARSED_IDENTITY%%|*}"
|
|
_identity_tail="${PARSED_IDENTITY#*|}"
|
|
TURN_KEY="${_identity_tail%%|*}"
|
|
PROMPT_ID="${_identity_tail#*|}"
|
|
PWF_SESSION_ID="$SESSION_ID"
|
|
export PWF_SESSION_ID
|
|
;;
|
|
esac
|
|
|
|
turn_cache_root() {
|
|
if [ -n "${XDG_CACHE_HOME:-}" ]; then
|
|
printf '%s\n' "${XDG_CACHE_HOME}/pwf-turn"
|
|
elif [ -n "${HOME:-}" ]; then
|
|
printf '%s\n' "${HOME}/.cache/pwf-turn"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
clear_turn_marker() {
|
|
[ -n "$TURN_KEY" ] || return 0
|
|
_root="$(turn_cache_root 2>/dev/null)" || return 0
|
|
cache_action clear "$_root" >/dev/null 2>&1 || :
|
|
}
|
|
|
|
# Cache state is advisory, but it still must not follow a planted link or use a
|
|
# directory controlled by another account. TURN_KEY exists only when the
|
|
# already-selected Python parsed an authentic bounded identity, so use that
|
|
# interpreter for lstat/ownership/mode checks before and after directory setup.
|
|
cache_action() {
|
|
_cache_action="$1"
|
|
_cache_root="$2"
|
|
"$PWF_PYTHON" -I - "$_cache_action" "$_cache_root" "$TURN_KEY" "$PROMPT_ID" <<'PY'
|
|
import os
|
|
import secrets
|
|
import stat
|
|
import sys
|
|
|
|
action, root, key, prompt_id = sys.argv[1:]
|
|
reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
binary = getattr(os, "O_BINARY", 0)
|
|
|
|
|
|
def identity(info):
|
|
return info.st_dev, info.st_ino, info.st_mode
|
|
|
|
|
|
def same_object(left, right):
|
|
return (left.st_dev, left.st_ino) == (right.st_dev, right.st_ino)
|
|
|
|
|
|
def acceptable_directory(info):
|
|
if not stat.S_ISDIR(info.st_mode):
|
|
return False
|
|
if getattr(info, "st_file_attributes", 0) & reparse:
|
|
return False
|
|
return os.name != "posix" or info.st_uid == os.getuid()
|
|
|
|
|
|
def acceptable_file(info):
|
|
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_size > 256:
|
|
return False
|
|
if getattr(info, "st_file_attributes", 0) & reparse:
|
|
return False
|
|
return os.name != "posix" or info.st_uid == os.getuid()
|
|
|
|
|
|
temporary = ""
|
|
try:
|
|
if not key or len(key) != 64 or any(char not in "0123456789abcdef" for char in key):
|
|
raise OSError("invalid cache key")
|
|
|
|
existed = os.path.lexists(root)
|
|
if existed and not acceptable_directory(os.lstat(root)):
|
|
raise OSError("unsafe cache root")
|
|
os.makedirs(root, mode=0o700, exist_ok=True)
|
|
before = os.lstat(root)
|
|
if not acceptable_directory(before):
|
|
raise OSError("unsafe cache root")
|
|
if os.name == "posix":
|
|
os.chmod(root, 0o700)
|
|
after = os.lstat(root)
|
|
# chmod intentionally changes st_mode. Freeze the directory object across
|
|
# that operation, then use the post-chmod identity for every later check.
|
|
if not acceptable_directory(after) or not same_object(before, after):
|
|
raise OSError("cache root changed")
|
|
if os.name == "posix" and stat.S_IMODE(after.st_mode) & 0o077:
|
|
raise OSError("cache root is not private")
|
|
|
|
root_real = os.path.realpath(os.path.abspath(root))
|
|
slot = os.path.join(root_real, key)
|
|
if os.path.commonpath((root_real, slot)) != root_real:
|
|
raise OSError("cache slot escaped")
|
|
|
|
if action == "clear":
|
|
if not os.path.lexists(slot):
|
|
raise SystemExit(0)
|
|
slot_info = os.lstat(slot)
|
|
if not acceptable_file(slot_info):
|
|
raise OSError("unsafe cache slot")
|
|
os.unlink(slot)
|
|
raise SystemExit(0)
|
|
|
|
if action != "claim":
|
|
raise OSError("unknown cache action")
|
|
desired = ((prompt_id or "legacy") + "\n").encode("ascii", "strict")
|
|
if os.path.lexists(slot):
|
|
slot_before = os.lstat(slot)
|
|
if not acceptable_file(slot_before):
|
|
raise OSError("unsafe cache slot")
|
|
descriptor = os.open(slot, os.O_RDONLY | binary | no_follow)
|
|
try:
|
|
opened = os.fstat(descriptor)
|
|
slot_after = os.lstat(slot)
|
|
if (
|
|
not acceptable_file(opened)
|
|
or identity(slot_before) != identity(opened)
|
|
or identity(slot_after) != identity(opened)
|
|
):
|
|
raise OSError("cache slot changed")
|
|
previous = os.read(descriptor, 257)
|
|
finally:
|
|
os.close(descriptor)
|
|
if previous == desired:
|
|
print("seen")
|
|
raise SystemExit(0)
|
|
|
|
temporary = os.path.join(root_real, f".{key}.{os.getpid()}.{secrets.token_hex(8)}")
|
|
descriptor = os.open(
|
|
temporary,
|
|
os.O_CREAT | os.O_EXCL | os.O_WRONLY | binary | no_follow,
|
|
0o600,
|
|
)
|
|
try:
|
|
os.write(descriptor, desired)
|
|
finally:
|
|
os.close(descriptor)
|
|
root_final = os.lstat(root)
|
|
if not acceptable_directory(root_final) or identity(after) != identity(root_final):
|
|
raise OSError("cache root changed")
|
|
os.replace(temporary, slot)
|
|
temporary = ""
|
|
claimed = os.lstat(slot)
|
|
if not acceptable_file(claimed) or claimed.st_size != len(desired):
|
|
raise OSError("unsafe claimed slot")
|
|
print("claimed")
|
|
except (OSError, UnicodeError, ValueError):
|
|
pass
|
|
finally:
|
|
if temporary:
|
|
try:
|
|
os.unlink(temporary)
|
|
except OSError:
|
|
pass
|
|
PY
|
|
}
|
|
|
|
# Return 0 when the reminder should be emitted, 1 when this turn already saw
|
|
# it. Any unsafe or unusable cache result fails toward the reminder.
|
|
claim_turn_marker() {
|
|
[ -n "$TURN_KEY" ] && [ -n "$PWF_PYTHON" ] || return 0
|
|
_root="$(turn_cache_root 2>/dev/null)" || return 0
|
|
_cache_result="$(cache_action claim "$_root" 2>/dev/null)" || _cache_result=""
|
|
[ "$_cache_result" = "seen" ] && return 1
|
|
return 0
|
|
}
|
|
|
|
# Encode the injector's bounded output without interpolating it into a command
|
|
# or format string. Walk characters directly because awk implementations do
|
|
# not agree on how many escapes gsub replacement text consumes. LC_ALL=C
|
|
# makes the walk byte-wise: in a UTF-8 locale gawk on Windows walks UTF-16
|
|
# units and re-emits a character outside the BMP as a lone surrogate.
|
|
json_string() {
|
|
tr '\001-\011\013-\037' ' ' \
|
|
| LC_ALL=C awk 'BEGIN { first = 1 }
|
|
{
|
|
if (!first) printf "\\n"
|
|
for (i = 1; i <= length($0); i++) {
|
|
c = substr($0, i, 1)
|
|
if (c == "\\") printf "%s", "\\\\"
|
|
else if (c == "\"") printf "%s", "\\\""
|
|
else printf "%s", c
|
|
}
|
|
first = 0
|
|
}'
|
|
}
|
|
|
|
emit_context_json() {
|
|
_event_name="$1"
|
|
_context="$2"
|
|
[ -n "$_context" ] || return 0
|
|
_encoded="$(printf '%s' "$_context" | json_string)"
|
|
printf '{"hookSpecificOutput":{"hookEventName":"%s","additionalContext":"%s"}}\n' \
|
|
"$_event_name" "$_encoded"
|
|
}
|
|
|
|
case "$EVENT" in
|
|
userprompt)
|
|
clear_turn_marker
|
|
[ -f "$INJECT_PLAN" ] || exit 0
|
|
# Plain stdout is explicitly model context for UserPromptSubmit. Do
|
|
# not capture or reframe it: preserve injector output byte-for-byte.
|
|
run_inject userprompt || :
|
|
;;
|
|
pretool)
|
|
_context="$(run_inject pretool)" || exit 0
|
|
emit_context_json "PreToolUse" "$_context"
|
|
;;
|
|
posttool)
|
|
[ -f "$INJECT_PLAN" ] || exit 0
|
|
_decision="$(run_inject validate)" || exit 0
|
|
[ "$_decision" = "PWF_PLAN_ACCEPTED_V1" ] || exit 0
|
|
claim_turn_marker || exit 0
|
|
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."}}'
|
|
;;
|
|
precompact)
|
|
# PreCompact does not support additionalContext. Preserve the current
|
|
# plain diagnostic output and pass only the real session identity into
|
|
# resolution; do not invent an unsupported event-specific JSON field.
|
|
run_inject precompact || :
|
|
;;
|
|
stop)
|
|
_decision="$(run_inject validate)" || exit 0
|
|
[ "$_decision" = "PWF_PLAN_ACCEPTED_V1" ] || exit 0
|
|
if [ -f "$GATE_STOP" ]; then
|
|
printf '%s' "$HOOK_PAYLOAD" | sh "$GATE_STOP" 2>/dev/null || :
|
|
elif [ -f "$CHECK_COMPLETE" ]; then
|
|
# Some existing IDE mirrors ship check-complete.sh without the thin
|
|
# gate-stop.sh dispatcher. Keep their current Stop capability.
|
|
printf '%s' "$HOOK_PAYLOAD" | sh "$CHECK_COMPLETE" --gate 2>/dev/null || :
|
|
fi
|
|
;;
|
|
*)
|
|
exit 0
|
|
;;
|
|
esac
|
|
|
|
exit 0
|