#!/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 /.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. /.mode exists and contains "gate" (explicit opt-in) # 2. an in_progress phase exists (not merely complete/.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/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