243 lines
9.4 KiB
Bash
Executable File
243 lines
9.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Check if all phases in task_plan.md are complete
|
|
# Default invocation: advisory echo, always exits 0 (Stop hook status report).
|
|
# With --gate: deliberate completion gate, opt-in per plan via <plan-dir>/.mode.
|
|
# Used by Stop hook to report task completion status.
|
|
#
|
|
# Plan-file resolution (v2.40+):
|
|
# 1. $1 (explicit path) — first non-flag positional argument
|
|
# 2. resolve-plan-dir.sh: $PLAN_ID env → .planning/.active_plan → newest mtime
|
|
# 3. Legacy ./task_plan.md
|
|
#
|
|
# This restores slug-mode parity: the Stop hook and any caller invoking with
|
|
# zero args now respects the active plan dir instead of silently defaulting to
|
|
# the legacy root path.
|
|
#
|
|
# Gate mode (v3, --gate flag):
|
|
# The gate is OFF unless ALL of these hold (design "Gate decision table"):
|
|
# 1. <plan-dir>/.mode exists and contains "gate" (explicit opt-in)
|
|
# 2. an in_progress phase exists (not merely complete<total)
|
|
# 3. the Stop hook input JSON on stdin does not set stop_hook_active=true
|
|
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
|
# 5. the ledger advanced since the last block (stall → allow stop)
|
|
# When all hold, it emits a single-line block-decision JSON on stdout and
|
|
# exits 0. Otherwise it falls back to advisory output and exits 0.
|
|
# Without --gate, or in non-gated mode, behavior is byte-equivalent to v2.43.
|
|
#
|
|
# Stdin handling: the Claude Code Stop hook pipes a JSON payload on stdin. To
|
|
# avoid hanging when nothing is piped, stdin is read ONLY when fd 0 is not a
|
|
# TTY ([ -t 0 ]). Hook-piped input is EOF-terminated, so the read returns; an
|
|
# interactive terminal (TTY) is skipped entirely. No data on stdin is treated
|
|
# as stop_hook_active=false.
|
|
|
|
GATE=0
|
|
PLAN_FILE=""
|
|
for _arg in "$@"; do
|
|
case "$_arg" in
|
|
--gate) GATE=1 ;;
|
|
*)
|
|
if [ -z "$PLAN_FILE" ]; then
|
|
PLAN_FILE="$_arg"
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
PLAN_DIR=""
|
|
if [ -n "${PLAN_FILE}" ]; then
|
|
PLAN_DIR="$(dirname "${PLAN_FILE}")"
|
|
else
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR="."
|
|
RESOLVER="${SCRIPT_DIR}/resolve-plan-dir.sh"
|
|
RESOLVED_DIR=""
|
|
if [ -f "${RESOLVER}" ]; then
|
|
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
|
|
fi
|
|
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
|
|
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
|
|
PLAN_DIR="${RESOLVED_DIR}"
|
|
else
|
|
PLAN_FILE="task_plan.md"
|
|
PLAN_DIR="."
|
|
fi
|
|
fi
|
|
|
|
if [ ! -f "$PLAN_FILE" ]; then
|
|
echo "[planning-with-files] No task_plan.md found — no active planning session."
|
|
exit 0
|
|
fi
|
|
|
|
# Count total phases
|
|
TOTAL=$(grep -c "### Phase" "$PLAN_FILE" || true)
|
|
|
|
# Count both formats per field and keep the larger of the two. A plan may mix
|
|
# '**Status:** pending' on one phase with '[in_progress]' on another; counting
|
|
# only the primary format (and falling back to inline ONLY when all three
|
|
# primaries are zero) lost the inline count and let an in_progress plan slip
|
|
# past the gate. Per-field max preserves the legacy single-format result
|
|
# (the other format contributes 0) while catching mixed plans.
|
|
COMPLETE_PRIMARY=$(grep -cF "**Status:** complete" "$PLAN_FILE" || true)
|
|
IN_PROGRESS_PRIMARY=$(grep -cF "**Status:** in_progress" "$PLAN_FILE" || true)
|
|
PENDING_PRIMARY=$(grep -cF "**Status:** pending" "$PLAN_FILE" || true)
|
|
|
|
COMPLETE_INLINE=$(grep -c "\[complete\]" "$PLAN_FILE" || true)
|
|
IN_PROGRESS_INLINE=$(grep -c "\[in_progress\]" "$PLAN_FILE" || true)
|
|
PENDING_INLINE=$(grep -c "\[pending\]" "$PLAN_FILE" || true)
|
|
|
|
: "${COMPLETE_PRIMARY:=0}"; : "${IN_PROGRESS_PRIMARY:=0}"; : "${PENDING_PRIMARY:=0}"
|
|
: "${COMPLETE_INLINE:=0}"; : "${IN_PROGRESS_INLINE:=0}"; : "${PENDING_INLINE:=0}"
|
|
|
|
if [ "$COMPLETE_INLINE" -gt "$COMPLETE_PRIMARY" ]; then COMPLETE="$COMPLETE_INLINE"; else COMPLETE="$COMPLETE_PRIMARY"; fi
|
|
if [ "$IN_PROGRESS_INLINE" -gt "$IN_PROGRESS_PRIMARY" ]; then IN_PROGRESS="$IN_PROGRESS_INLINE"; else IN_PROGRESS="$IN_PROGRESS_PRIMARY"; fi
|
|
if [ "$PENDING_INLINE" -gt "$PENDING_PRIMARY" ]; then PENDING="$PENDING_INLINE"; else PENDING="$PENDING_PRIMARY"; fi
|
|
|
|
# Default to 0 if empty
|
|
: "${TOTAL:=0}"
|
|
: "${COMPLETE:=0}"
|
|
: "${IN_PROGRESS:=0}"
|
|
: "${PENDING:=0}"
|
|
|
|
# advisory_report: the v2.43 status echo. Always exit 0 after calling.
|
|
advisory_report() {
|
|
if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
|
|
echo "[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting."
|
|
else
|
|
echo "[planning-with-files] Task in progress ($COMPLETE/$TOTAL phases complete). Update progress.md before stopping."
|
|
if [ "$IN_PROGRESS" -gt 0 ]; then
|
|
echo "[planning-with-files] $IN_PROGRESS phase(s) still in progress."
|
|
fi
|
|
if [ "$PENDING" -gt 0 ]; then
|
|
echo "[planning-with-files] $PENDING phase(s) pending."
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# ---- Default (advisory) path: byte-equivalent to v2.43 ----
|
|
if [ "$GATE" -ne 1 ]; then
|
|
advisory_report
|
|
exit 0
|
|
fi
|
|
|
|
# ---- Gate path (--gate). Resolves to advisory unless every guard says block. ----
|
|
|
|
# Guard 1: gated mode. The .mode file must contain "gate". Absent or other
|
|
# content means advisory mode (legacy behavior preserved).
|
|
MODE_FILE="${PLAN_DIR}/.mode"
|
|
if [ ! -f "${MODE_FILE}" ] || ! grep -q "gate" "${MODE_FILE}" 2>/dev/null; then
|
|
advisory_report
|
|
exit 0
|
|
fi
|
|
|
|
# Guard 3: stop_hook_active. Read the Stop hook JSON from stdin only when fd 0
|
|
# is not a TTY (see header). A true value means we are already inside a forced
|
|
# continuation; allow the stop to avoid runaway recursion.
|
|
STDIN_JSON=""
|
|
if [ ! -t 0 ]; then
|
|
STDIN_JSON="$(cat 2>/dev/null)"
|
|
fi
|
|
# Anchor on the VALUE: "stop_hook_active" immediately followed (allowing
|
|
# whitespace and the colon) by true. A bare glob like *stop_hook_active*true*
|
|
# false-positives on '{"stop_hook_active": false, "other": true}', which would
|
|
# silently disable the gate. Newlines are collapsed so the match works whether
|
|
# the payload is pretty-printed or single-line.
|
|
STOP_HOOK_ACTIVE="$(
|
|
printf '%s' "${STDIN_JSON}" \
|
|
| tr '\n' ' ' \
|
|
| sed -n 's/.*"stop_hook_active"[[:space:]]*:[[:space:]]*true.*/FOUND/p'
|
|
)"
|
|
if [ "${STOP_HOOK_ACTIVE}" = "FOUND" ]; then
|
|
advisory_report
|
|
exit 0
|
|
fi
|
|
|
|
# Guard 2: an in_progress phase must exist. Merely complete<total is a normal
|
|
# state and must NOT block (issue #178 lesson).
|
|
if [ "$IN_PROGRESS" -le 0 ]; then
|
|
advisory_report
|
|
exit 0
|
|
fi
|
|
|
|
# ledger_line_count: total lines across all <plan-dir>/ledger-*.jsonl files.
|
|
# Echoes a single integer (0 when no ledger files exist).
|
|
ledger_line_count() {
|
|
_total=0
|
|
for _lf in "${PLAN_DIR}"/ledger-*.jsonl; do
|
|
[ -f "${_lf}" ] || continue
|
|
_n="$(grep -c '' "${_lf}" 2>/dev/null || echo 0)"
|
|
_total=$((_total + _n))
|
|
done
|
|
printf "%s" "${_total}"
|
|
}
|
|
|
|
CAP="${PWF_GATE_CAP:-20}"
|
|
case "${CAP}" in
|
|
''|*[!0-9]*) CAP=20 ;;
|
|
esac
|
|
|
|
BLOCKS_FILE="${PLAN_DIR}/.stop_blocks"
|
|
BLOCKS="$(cat "${BLOCKS_FILE}" 2>/dev/null || echo 0)"
|
|
case "${BLOCKS}" in
|
|
''|*[!0-9]*) BLOCKS=0 ;;
|
|
esac
|
|
|
|
LEDGER_FILE="${PLAN_DIR}/.gate_last_ledger"
|
|
LEDGER_PREV="$(cat "${LEDGER_FILE}" 2>/dev/null || echo 0)"
|
|
case "${LEDGER_PREV}" in
|
|
''|*[!0-9]*) LEDGER_PREV=0 ;;
|
|
esac
|
|
LEDGER_NOW="$(ledger_line_count)"
|
|
|
|
# Guard 4: block-count cap. At or over the cap, allow the stop.
|
|
if [ "${BLOCKS}" -ge "${CAP}" ]; then
|
|
advisory_report
|
|
echo "[planning-with-files] gate cap reached ($BLOCKS/$CAP) — allowing stop."
|
|
exit 0
|
|
fi
|
|
|
|
# Guard 5: stall detection. If we have blocked before (BLOCKS > 0) and the
|
|
# ledger line count has not advanced since the last block, nothing progressed:
|
|
# allow the stop instead of looping.
|
|
if [ "${BLOCKS}" -gt 0 ] && [ "${LEDGER_NOW}" -eq "${LEDGER_PREV}" ]; then
|
|
advisory_report
|
|
echo "[planning-with-files] no progress since last gate block — allowing stop."
|
|
exit 0
|
|
fi
|
|
|
|
# All guards passed: block the stop.
|
|
# json_escape: escape a string for safe inclusion in a JSON string literal.
|
|
# Escapes backslash and double-quote, then neutralizes every bare control
|
|
# character JSON forbids (0x01-0x1F) by mapping it to a space. A phase heading
|
|
# may carry a literal tab or other control byte; left raw it produces invalid
|
|
# JSON ("Bad control character in string literal") that the Stop hook rejects.
|
|
json_escape() {
|
|
printf "%s" "$1" \
|
|
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \
|
|
| tr '\001-\037' ' '
|
|
}
|
|
|
|
# first_in_progress_phase: heading text of the first phase whose Status is
|
|
# in_progress. Reads the plan top-to-bottom, remembers the most recent
|
|
# "### " heading, and prints it (with the "### " prefix stripped) at the first
|
|
# in_progress status line. Plain text only — no plan body beyond the heading.
|
|
first_in_progress_phase() {
|
|
awk '
|
|
/^### / { heading = substr($0, 5); next }
|
|
/\*\*Status:\*\* in_progress/ { print heading; exit }
|
|
/\[in_progress\]/ { print heading; exit }
|
|
' "$PLAN_FILE"
|
|
}
|
|
|
|
PHASE_NAME="$(first_in_progress_phase)"
|
|
if [ -z "${PHASE_NAME}" ]; then
|
|
PHASE_NAME="unknown phase"
|
|
fi
|
|
PHASE_ESCAPED="$(json_escape "${PHASE_NAME}")"
|
|
|
|
NEW_BLOCKS=$((BLOCKS + 1))
|
|
printf "%s\n" "${NEW_BLOCKS}" > "${BLOCKS_FILE}" 2>/dev/null || true
|
|
printf "%s\n" "${LEDGER_NOW}" > "${LEDGER_FILE}" 2>/dev/null || true
|
|
|
|
printf '{"decision":"block","reason":"[planning-with-files] Gated plan incomplete: phase '\''%s'\'' is in_progress (%s/%s complete, gate block %s/%s). Finish or update the plan, then stop."}\n' \
|
|
"${PHASE_ESCAPED}" "${COMPLETE}" "${TOTAL}" "${NEW_BLOCKS}" "${CAP}"
|
|
exit 0
|