Files
OthmanAdi 3749f86ac3 fix: bind explicit plan selectors, floor slug modes on the project root, and classify doctor output on the data frame
Three defects reported by Raymond Manaloto (@sortakool), each reproduced on
Windows with Git Bash before any code changed. Two of them let a session work
on a plan nobody selected; the third made the diagnostic that should have
caught them report PASS.

closes #237: an explicit PLAN_ID was a hint, not a binding

resolve_from_env returned 1 both when no selector was set and when the selector
was rejected, so a PLAN_ID of valid slug shape naming no directory fell through
to .active_plan and then to newest-by-mtime. attest-plan.sh locked that other
plan at rc=0 and injection followed it. The guard in attest-plan.sh written for
exactly this case was unreachable, because the resolver never returned empty.
commands/plan-attest.md has promised the opposite since v3.9.0.

A non-empty PLAN_ID now resolves or stops, for every rejection reason: slug
shape (traversal included), naming no directory, or failing containment. An
empty value still means unset, which init-session.sh depends on. The rule went
into every resolver, because they disagreed otherwise: resolve-plan-dir.sh,
resolve-plan-dir.ps1, the inline resolver inside inject-plan.sh, and the
Hermes, OpenCode and Pi plugin resolvers. v3.14.0 had made the two plugin
resolvers fall through to match the shell; that parity was right and its target
was wrong.

Fixing the resolver alone would have moved the bug rather than removed it, so
every consumer was audited. check-complete.sh would have read the root plan's
completion state, which decides whether an autonomous run may stop.
ledger-summary.sh feeds phase counts into autonomous turns. phase-status.sh and
ledger-append.sh write into the directory they pick. The five Codex hooks call
the resolver directly and each fell through to the legacy root, stop.sh and
pre-compact.sh included. All of them now stop on a rejected selector, and the
once-per-turn routes print a notice, because a fail-closed path with no stated
cause would trade a wrong-plan bug for a silent one.

closes #238: a slug plan with no .mode bypassed the project's root .mode

A project makes attestation mandatory by committing a root .mode, a reviewed
project setting. inject-plan.sh read the slug's .mode and never the root's, and
init-session.sh writes no .mode unless --autonomous or --gated was passed, so
init-session.sh <name>, which /plan runs, produced a plan with no attestation
requirement and full injection. The policy was a flag the agent chose at plan
creation.

The root .mode is now a floor. Strictness-raising tokens count from either
file, so a slug may raise and never lower; the one strictness-lowering token,
plan-guard-off, needs the root's agreement. The same floor applies to the
completion gate in check-complete.sh and check-complete.ps1, which read .mode
the same way and had the same bypass, and to the Hermes and OpenCode plugins.
init-session.sh seeds a new slug from the root .mode so the effective policy is
visible in the plan directory, not only in the resolver. With no root .mode
present the effective token set is exactly the slug's, so existing projects are
unchanged.

closes #236: plan-doctor.sh reported PASS on a fully dark-hooks state

The injection check substring-matched five control strings against output that
carries the plan body verbatim inside the BEGIN-PWF-DATA fences. A plan whose
phase line read "fix the false PLAN TAMPERED warning in plan-doctor" reported a
hash mismatch while correctly attested. Worse, the PWF_PLAN_ROOT arm matched a
literal that is not a substring of what inject-plan.sh emits, so the arm was
dead code and execution fell to the success arm: PWF_PLAN_ROOT=/nonexistent
printed "PASS injection: emits plan context (123 bytes)" while nothing was
injected, counting the refusal notice's own bytes as plan context. The file's
own comment four lines above already warned against exactly that.

Classification now branches on the frame. Every refusal path exits before
frame_file runs, so a frame proves injection happened and unframed output is by
construction a notice. The default arm warns, so a reworded or translated
banner degrades noisily instead of silently. The stale literal is corrected
against the emitting echo.

Tests: Python suite 653 to 696, OpenCode Vitest 28 to 34. New files
test_plan_selector_binding.py, test_root_mode_floor.py and
test_plan_doctor_classification.py; new arms in test_resolver_parity.py,
test_resolve_plan_dir.py, test_resolver_plan_root_pin.py and
test_hermes_first_class.py. Five existing tests asserted the fall-through and
were rewritten to assert the refusal, keeping their original security
assertion. Each suite carries a control arm: two first drafts passed on
fixtures that could not have failed, a gate assertion over a plan with every
phase complete and a parallel-write-guard assertion over a plan with nothing
checked to lose.
2026-09-03 00:23:03 +02:00

171 lines
8.3 KiB
Bash
Executable File

#!/bin/sh
# planning-with-files: plan-doctor — one-pass self-check for the mechanisms
# that fail silently. Run from the project root:
#
# sh scripts/plan-doctor.sh
#
# Answers:
# - does plan resolution work here, and which plan wins?
# - does hook injection actually emit plan context?
# - is the canonicalizer producing comparable paths? (Windows-native
# coreutils emit C:\-style output; pwf versions before v3.6.0 went
# silently dark on such machines)
# - is the plan attested, and is the attestation file where hooks look?
# - which install surfaces exist on this machine?
# - what does one hook fire cost in wall-clock?
#
# Diagnostic only. Writes nothing except inject-plan.sh's own SHA cache.
# Always exits 0.
set -u
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR="."
ok() { printf 'PASS %s\n' "$1"; }
warn() { printf 'WARN %s\n' "$1"; }
fail() { printf 'FAIL %s\n' "$1"; }
info() { printf 'info %s\n' "$1"; }
echo '=== planning-with-files plan-doctor ==='
info "cwd: ${PWD}"
info "uname: $(uname -s 2>/dev/null || echo unknown)"
[ "${PLANNING_DISABLED:-}" = "1" ] && warn "PLANNING_DISABLED=1 is set — every hook exits immediately in this environment"
# --- [1] canonicalizer probe -------------------------------------------------
CANON="$(realpath . 2>/dev/null)" || CANON=""
[ -z "${CANON}" ] && { CANON="$(readlink -f . 2>/dev/null)" || CANON=""; }
case "${CANON}" in
'')
warn "no realpath/readlink canonicalizer answered — containment falls back to a python spawn per check"
;;
*\\*)
info "canonicalizer emits Windows-style paths (${CANON}) — handled since v3.6.0; OLDER pwf versions resolve nothing on this machine"
;;
*)
info "canonicalizer: ${CANON}"
;;
esac
# --- [2] plan resolution -----------------------------------------------------
RES=""
if [ -f "${SCRIPT_DIR}/resolve-plan-dir.sh" ]; then
RES="$(sh "${SCRIPT_DIR}/resolve-plan-dir.sh" 2>/dev/null)" || RES=""
if [ -n "${RES}" ]; then
ok "resolver: active plan dir = ${RES}"
elif [ -f task_plan.md ]; then
ok "resolver: legacy root plan (./task_plan.md)"
elif [ -d .planning ]; then
fail "resolver: .planning/ exists but nothing resolves — check .planning/.active_plan content and that plan dirs contain task_plan.md"
else
info "resolver: no plan in this directory (run init-session.sh to create one)"
fi
else
warn "resolve-plan-dir.sh not found next to plan-doctor — unexpected install layout"
fi
# --- [3] hook injection ------------------------------------------------------
INJ="${SCRIPT_DIR}/inject-plan.sh"
if [ -f "${INJ}" ]; then
OUT="$(sh "${INJ}" --context=userprompt 2>/dev/null)" || OUT=""
if [ -z "${OUT}" ]; then
if [ -n "${RES}" ] || [ -f task_plan.md ]; then
fail "injection: a plan resolves but inject-plan.sh emitted NOTHING — hooks are dark. Known silent causes: pre-v3.6.0 with a Windows-native realpath on PATH; PLANNING_DISABLED=1; a plan dir outside the project root; a stale .planning/sessions/ dir with no attached session (silences pretool/precompact fires entirely — the userprompt fire names it)."
else
ok "injection: silent because no plan exists here (correct behavior)"
fi
else
# Classify on the DATA FRAMING first, never on substrings of the whole
# blob (issue #236). ${OUT} carries the plan body VERBATIM inside
# ===BEGIN-PWF-DATA=== fences, so a bare substring test also matches
# plan prose: a phase line reading "fix the false PLAN TAMPERED
# warning" made the doctor report a hash mismatch on a correctly
# attested plan.
#
# Every refusal path in inject-plan.sh prints its banner and exits
# before frame_file runs, so a frame in the output proves injection
# happened and rules out every refusal. Output WITHOUT a frame is by
# construction a notice, which is why the banner arms sit under the
# else side and the default arm warns instead of passing. A banner
# whose wording drifts then degrades to a generic warning rather than
# to a silent PASS: that is exactly how the stale
# "PWF_PLAN_ROOT is not a directory" literal (which was never a
# substring of what inject-plan.sh emits) reported PASS on a fully
# dark-hooks state.
case "${OUT}" in
*'===BEGIN-PWF-DATA'*)
BYTES="$(printf '%s' "${OUT}" | wc -c | tr -d '[:space:]')"
ok "injection: emits plan context (${BYTES} bytes)"
;;
*'[PLAN TAMPERED'*)
warn "injection: plan is attested but the hash mismatches — run /plan-attest (or scripts/attest-plan.sh) to re-approve the current plan"
;;
*'requires attested plan'*)
warn "injection: v3 mode without attestation — run attest-plan once to arm injection"
;;
*'Session isolation is armed'*)
warn "injection: session isolation refuses this session — attach it with PWF_SESSION_ID=<id> plus .planning/sessions/<id>.attached, or delete the .planning/sessions/ dir (stale ones survive earlier Codex use and copied project trees) to turn isolation off"
;;
*'Ambiguous plan'*)
warn "injection: nested-plan ambiguity — a project directly below this cwd carries its own plan, so hooks refuse to guess. Pin the thread with PWF_PLAN_ROOT=<absolute project root> or PLAN_ID=<slug>"
;;
*'PWF_PLAN_ROOT is not a supported absolute local directory'*)
warn "injection: PWF_PLAN_ROOT points at something that is not an absolute local directory — fix or unset the pin; a broken pin fails closed and injects nothing"
;;
*'PLAN_ID does not name a plan directory'*)
warn "injection: PLAN_ID names no plan directory under .planning — fix or unset the pin; a set PLAN_ID is a binding and fails closed rather than selecting another plan"
;;
*)
BYTES="$(printf '%s' "${OUT}" | wc -c | tr -d '[:space:]')"
warn "injection: inject-plan.sh emitted ${BYTES} bytes but no ===BEGIN-PWF-DATA frame, so no plan context reached the model. This is a refusal notice this doctor does not recognize; read it directly with: sh scripts/inject-plan.sh --context=userprompt"
;;
esac
fi
else
warn "inject-plan.sh not found next to plan-doctor — this install route ships no hook payload (see the install matrix in docs/installation.md)"
fi
# --- [4] attestation ---------------------------------------------------------
ATT=""
if [ -n "${RES}" ] && [ -f "${RES}/.attestation" ]; then
ATT="${RES}/.attestation"
elif [ -f .plan-attestation ]; then
ATT=".plan-attestation"
fi
if [ -n "${ATT}" ]; then
info "attestation present: ${ATT}"
else
info "attestation: none (opt-in in legacy mode; default-on in v3 modes; run /plan-attest after approving the plan)"
fi
# --- [5] install surfaces ----------------------------------------------------
FOUND_SURFACE=0
for s in \
".claude/skills/planning-with-files" \
"${HOME:-}/.claude/skills/planning-with-files" \
".agents/skills/planning-with-files" \
"${HOME:-}/.agents/skills/planning-with-files"
do
[ -n "${s}" ] && [ -d "${s}" ] && { info "install surface present: ${s}"; FOUND_SURFACE=1; }
done
[ "${FOUND_SURFACE}" = "0" ] && info "no skill-dir install surface in project or home (plugin-route installs live under the plugin cache instead)"
info "route reminder: the plugin route ships commands/ + hooks; npx-skills ships the skill only. Hooks silent after a project-level skill install? Check project trust (hasTrustDialogAccepted) and the install matrix in docs/installation.md."
# --- [6] hook latency --------------------------------------------------------
if [ -f "${INJ}" ]; then
T0="$(date +%s%N 2>/dev/null)" || T0=""
sh "${INJ}" --context=userprompt >/dev/null 2>&1
T1="$(date +%s%N 2>/dev/null)" || T1=""
case "${T0}${T1}" in
''|*[!0-9]*)
info "hook latency: skipped (no nanosecond clock on this date binary)"
;;
*)
MS=$(( (T1 - T0) / 1000000 ))
info "one inject-plan.sh fire: ${MS}ms wall-clock"
;;
esac
fi
echo '=== plan-doctor done ==='
exit 0