Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Structured planning workflow that uses files to track tasks, decisions, and project progress.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/check-complete.sh
1#!/usr/bin/env bash2# Check if all phases in task_plan.md are complete3# Default invocation: advisory echo, always exits 0 (Stop hook status report).4# With --gate: deliberate completion gate, opt-in per plan via <plan-dir>/.mode.5# Used by Stop hook to report task completion status.6#7# Plan-file resolution (v2.40+):8# 1. $1 (explicit path) — first non-flag positional argument9# 2. resolve-plan-dir.sh: $PLAN_ID env → .planning/.active_plan → newest mtime10# 3. Legacy ./task_plan.md11#12# This restores slug-mode parity: the Stop hook and any caller invoking with13# zero args now respects the active plan dir instead of silently defaulting to14# the legacy root path.15#16# Gate mode (v3, --gate flag):17# The gate is OFF unless ALL of these hold (design "Gate decision table"):18# 1. <plan-dir>/.mode exists and contains "gate" (explicit opt-in)19# 2. an in_progress phase exists (not merely complete<total)20# 3. the Stop hook input JSON on stdin does not set stop_hook_active=true21# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)22# 5. the ledger advanced since the last block (stall → allow stop)23# When all hold, it emits a single-line block-decision JSON on stdout and24# exits 0. Otherwise it falls back to advisory output and exits 0.25# Without --gate, or in non-gated mode, behavior is byte-equivalent to v2.43.26#27# Stdin handling: the Claude Code Stop hook pipes a JSON payload on stdin. To28# avoid hanging when nothing is piped, stdin is read ONLY when fd 0 is not a29# TTY ([ -t 0 ]). Hook-piped input is EOF-terminated, so the read returns; an30# interactive terminal (TTY) is skipped entirely. No data on stdin is treated31# as stop_hook_active=false.3233GATE=034PLAN_FILE=""35for _arg in "$@"; do36case "$_arg" in37--gate) GATE=1 ;;38*)39if [ -z "$PLAN_FILE" ]; then40PLAN_FILE="$_arg"41fi42;;43esac44done4546PLAN_DIR=""47if [ -n "${PLAN_FILE}" ]; then48PLAN_DIR="$(dirname "${PLAN_FILE}")"49else50SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR="."51RESOLVER="${SCRIPT_DIR}/resolve-plan-dir.sh"52RESOLVED_DIR=""53if [ -f "${RESOLVER}" ]; then54RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"55fi56if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then57PLAN_FILE="${RESOLVED_DIR}/task_plan.md"58PLAN_DIR="${RESOLVED_DIR}"59else60PLAN_FILE="task_plan.md"61PLAN_DIR="."62fi63fi6465if [ ! -f "$PLAN_FILE" ]; then66echo "[planning-with-files] No task_plan.md found — no active planning session."67exit 068fi6970# Count total phases71TOTAL=$(grep -c "### Phase" "$PLAN_FILE" || true)7273# Count both formats per field and keep the larger of the two. A plan may mix74# '**Status:** pending' on one phase with '[in_progress]' on another; counting75# only the primary format (and falling back to inline ONLY when all three76# primaries are zero) lost the inline count and let an in_progress plan slip77# past the gate. Per-field max preserves the legacy single-format result78# (the other format contributes 0) while catching mixed plans.79COMPLETE_PRIMARY=$(grep -cF "**Status:** complete" "$PLAN_FILE" || true)80IN_PROGRESS_PRIMARY=$(grep -cF "**Status:** in_progress" "$PLAN_FILE" || true)81PENDING_PRIMARY=$(grep -cF "**Status:** pending" "$PLAN_FILE" || true)8283COMPLETE_INLINE=$(grep -c "\[complete\]" "$PLAN_FILE" || true)84IN_PROGRESS_INLINE=$(grep -c "\[in_progress\]" "$PLAN_FILE" || true)85PENDING_INLINE=$(grep -c "\[pending\]" "$PLAN_FILE" || true)8687: "${COMPLETE_PRIMARY:=0}"; : "${IN_PROGRESS_PRIMARY:=0}"; : "${PENDING_PRIMARY:=0}"88: "${COMPLETE_INLINE:=0}"; : "${IN_PROGRESS_INLINE:=0}"; : "${PENDING_INLINE:=0}"8990if [ "$COMPLETE_INLINE" -gt "$COMPLETE_PRIMARY" ]; then COMPLETE="$COMPLETE_INLINE"; else COMPLETE="$COMPLETE_PRIMARY"; fi91if [ "$IN_PROGRESS_INLINE" -gt "$IN_PROGRESS_PRIMARY" ]; then IN_PROGRESS="$IN_PROGRESS_INLINE"; else IN_PROGRESS="$IN_PROGRESS_PRIMARY"; fi92if [ "$PENDING_INLINE" -gt "$PENDING_PRIMARY" ]; then PENDING="$PENDING_INLINE"; else PENDING="$PENDING_PRIMARY"; fi9394# Default to 0 if empty95: "${TOTAL:=0}"96: "${COMPLETE:=0}"97: "${IN_PROGRESS:=0}"98: "${PENDING:=0}"99100# advisory_report: the v2.43 status echo. Always exit 0 after calling.101advisory_report() {102if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then103echo "[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting."104else105echo "[planning-with-files] Task in progress ($COMPLETE/$TOTAL phases complete). Update progress.md before stopping."106if [ "$IN_PROGRESS" -gt 0 ]; then107echo "[planning-with-files] $IN_PROGRESS phase(s) still in progress."108fi109if [ "$PENDING" -gt 0 ]; then110echo "[planning-with-files] $PENDING phase(s) pending."111fi112fi113}114115# ---- Default (advisory) path: byte-equivalent to v2.43 ----116if [ "$GATE" -ne 1 ]; then117advisory_report118exit 0119fi120121# ---- Gate path (--gate). Resolves to advisory unless every guard says block. ----122123# Guard 1: gated mode. The .mode file must contain "gate". Absent or other124# content means advisory mode (legacy behavior preserved).125MODE_FILE="${PLAN_DIR}/.mode"126if [ ! -f "${MODE_FILE}" ] || ! grep -q "gate" "${MODE_FILE}" 2>/dev/null; then127advisory_report128exit 0129fi130131# Guard 3: stop_hook_active. Read the Stop hook JSON from stdin only when fd 0132# is not a TTY (see header). A true value means we are already inside a forced133# continuation; allow the stop to avoid runaway recursion.134STDIN_JSON=""135if [ ! -t 0 ]; then136STDIN_JSON="$(cat 2>/dev/null)"137fi138# Anchor on the VALUE: "stop_hook_active" immediately followed (allowing139# whitespace and the colon) by true. A bare glob like *stop_hook_active*true*140# false-positives on '{"stop_hook_active": false, "other": true}', which would141# silently disable the gate. Newlines are collapsed so the match works whether142# the payload is pretty-printed or single-line.143STOP_HOOK_ACTIVE="$(144printf '%s' "${STDIN_JSON}" \145| tr '\n' ' ' \146| sed -n 's/.*"stop_hook_active"[[:space:]]*:[[:space:]]*true.*/FOUND/p'147)"148if [ "${STOP_HOOK_ACTIVE}" = "FOUND" ]; then149advisory_report150exit 0151fi152153# Guard 2: an in_progress phase must exist. Merely complete<total is a normal154# state and must NOT block (issue #178 lesson).155if [ "$IN_PROGRESS" -le 0 ]; then156advisory_report157exit 0158fi159160# ledger_line_count: total lines across all <plan-dir>/ledger-*.jsonl files.161# Echoes a single integer (0 when no ledger files exist).162ledger_line_count() {163_total=0164for _lf in "${PLAN_DIR}"/ledger-*.jsonl; do165[ -f "${_lf}" ] || continue166_n="$(grep -c '' "${_lf}" 2>/dev/null || echo 0)"167_total=$((_total + _n))168done169printf "%s" "${_total}"170}171172CAP="${PWF_GATE_CAP:-20}"173case "${CAP}" in174''|*[!0-9]*) CAP=20 ;;175esac176177BLOCKS_FILE="${PLAN_DIR}/.stop_blocks"178BLOCKS="$(cat "${BLOCKS_FILE}" 2>/dev/null || echo 0)"179case "${BLOCKS}" in180''|*[!0-9]*) BLOCKS=0 ;;181esac182183LEDGER_FILE="${PLAN_DIR}/.gate_last_ledger"184LEDGER_PREV="$(cat "${LEDGER_FILE}" 2>/dev/null || echo 0)"185case "${LEDGER_PREV}" in186''|*[!0-9]*) LEDGER_PREV=0 ;;187esac188LEDGER_NOW="$(ledger_line_count)"189190# Guard 4: block-count cap. At or over the cap, allow the stop.191if [ "${BLOCKS}" -ge "${CAP}" ]; then192advisory_report193echo "[planning-with-files] gate cap reached ($BLOCKS/$CAP) — allowing stop."194exit 0195fi196197# Guard 5: stall detection. If we have blocked before (BLOCKS > 0) and the198# ledger line count has not advanced since the last block, nothing progressed:199# allow the stop instead of looping.200if [ "${BLOCKS}" -gt 0 ] && [ "${LEDGER_NOW}" -eq "${LEDGER_PREV}" ]; then201advisory_report202echo "[planning-with-files] no progress since last gate block — allowing stop."203exit 0204fi205206# All guards passed: block the stop.207# json_escape: escape a string for safe inclusion in a JSON string literal.208# Escapes backslash and double-quote, then neutralizes every bare control209# character JSON forbids (0x01-0x1F) by mapping it to a space. A phase heading210# may carry a literal tab or other control byte; left raw it produces invalid211# JSON ("Bad control character in string literal") that the Stop hook rejects.212json_escape() {213printf "%s" "$1" \214| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \215| tr '\001-\037' ' '216}217218# first_in_progress_phase: heading text of the first phase whose Status is219# in_progress. Reads the plan top-to-bottom, remembers the most recent220# "### " heading, and prints it (with the "### " prefix stripped) at the first221# in_progress status line. Plain text only — no plan body beyond the heading.222first_in_progress_phase() {223awk '224/^### / { heading = substr($0, 5); next }225/\*\*Status:\*\* in_progress/ { print heading; exit }226/\[in_progress\]/ { print heading; exit }227' "$PLAN_FILE"228}229230PHASE_NAME="$(first_in_progress_phase)"231if [ -z "${PHASE_NAME}" ]; then232PHASE_NAME="unknown phase"233fi234PHASE_ESCAPED="$(json_escape "${PHASE_NAME}")"235236NEW_BLOCKS=$((BLOCKS + 1))237printf "%s\n" "${NEW_BLOCKS}" > "${BLOCKS_FILE}" 2>/dev/null || true238printf "%s\n" "${LEDGER_NOW}" > "${LEDGER_FILE}" 2>/dev/null || true239240printf '{"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' \241"${PHASE_ESCAPED}" "${COMPLETE}" "${TOTAL}" "${NEW_BLOCKS}" "${CAP}"242exit 0243