Files
othmanadi__planning-with-files/scripts/ledger-summary.sh
T
OthmanAdi ef32a32464 fix: resolve the plan against an explicit root and refuse ambiguous shared cwds
Plan resolution was cwd relative with no notion of a thread: PLAN_ID, then
.planning/.active_plan, then the newest plan directory by mtime, every read
taken relative to the process cwd. A Codex thread sitting at a shared parent
such as /workspace therefore saw only the parent's pointer, and the plan of an
unrelated project was injected on every prompt and every matched tool call
while the nested project's own .active_plan was never consulted (#212).

PWF_PLAN_ROOT binds resolution to a project root by absolute path, which a cwd
relative PLAN_ID slug structurally cannot express. A pin that does not resolve
fails closed with a notice rather than falling back to the ambiguous plan the
caller was escaping. Containment is checked against the pin.

When the plan was chosen by a cwd guess (the shared pointer, newest by mtime,
or a legacy root task_plan.md) and a project one directory below carries its
own live plan, nothing is injected and the notice names both escape hatches.
An explicit PLAN_ID, a pin, or an attached session stays authoritative and
skips the check. Detection is one glob at depth one, which is the whole per
fire budget. A nested pointer that is empty or names a deleted directory does
not compete, so an abandoned subproject cannot kill injection at the root.

The session attachment guard from #146 now runs on this route too. It is not
silent: on hosts that never set PWF_SESSION_ID every session is unattached by
construction, so a stale .planning/sessions/ directory would otherwise kill
injection permanently with no symptom, and .planning/ is gitignored so the
state is invisible to review. The notice is turn scoped so it cannot become
per tool call spam.

Also fixed here, both found by the new tests:

- ledger-summary.sh now takes the resolved plan directory as an argument.
  Under a pin it previously re-resolved from the process cwd, so an autonomous
  loop could read the parent's "1/1 complete" while its own pinned plan had an
  open phase. With no plan directory determinable it says so instead of
  reporting a confident 0/0.
- GNU sha256sum prefixes its output line with a backslash when the filename
  needs escaping, which any Windows style pin triggers, so every attested
  pinned plan reported PLAN TAMPERED on every fire.

plan-doctor reports a refusal as its own state with the remedy. It previously
counted the refusal notice as bytes of plan context and printed PASS, so the
one tool a dark user is pointed at gave a green light.

The legacy invariant holds: with no pin, no sessions directory and no nested
competing plan, output is byte identical to the previous release across every
context and mode, verified by diffing captured stdout against HEAD.
2026-08-01 22:04:37 +02:00

163 lines
6.0 KiB
Bash
Executable File

#!/bin/sh
# planning-with-files: emit a fixed-shape, cache-stable run-ledger summary (v3).
#
# This replaces raw `tail -20 progress.md` injection in autonomous mode. The
# output is synthesized from the machine ledger and task_plan.md status counts
# only: NO free text from disk reaches the model context, and there are NO
# timestamps, so the injected block is KV-cache stable by construction
# (architecture C3 injection rule).
#
# Plan-dir resolution:
# 0. Explicit plan-dir argument (issue #212, see below)
# 1. $PLAN_ID env var -> ./.planning/$PLAN_ID/ (via resolve-plan-dir.sh)
# 2. ./.planning/.active_plan (via resolve-plan-dir.sh)
# 3. Newest ./.planning/<dir>/ by mtime (via resolve-plan-dir.sh)
# 4. Legacy: project root
#
# Usage:
# sh scripts/ledger-summary.sh [plan-dir]
#
# The optional argument is the caller's already-resolved plan directory and
# wins over self-resolution: inject-plan.sh passes the dir it resolved,
# because a cwd-based re-resolution here would pair a PWF_PLAN_ROOT-pinned
# plan's body with the PARENT project's phase counts and agent events — a
# false termination signal for an autonomous loop. No argument keeps the
# self-resolution above unchanged.
#
# Output block (stable shape):
# === RUN LEDGER ===
# entries: <N>
# phases: <complete>/<total> complete
# in_progress: <phase heading or none>
# agent <name>: <last event type>
# ...
# ==================
#
# When no plan directory is determinable at all (argument names a missing dir,
# or no argument AND resolve-plan-dir.sh is not next to this script), the block
# is replaced by a clearly marked unavailable state instead of a confident
# "phases: 0/0 complete" — see emit_unavailable below.
set -u
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
RESOLVER="${SCRIPT_DIR}/resolve-plan-dir.sh"
ARG_DIR="${1:-}"
# Loud degradation: when the counts below are NOT computable — the caller
# named a plan dir that is gone, or no dir was passed and the resolver is
# missing next to this script — emit a clearly marked unavailable block
# instead of a confident "phases: 0/0 complete" + "in_progress: none", which
# an autonomous loop would read as its termination signal. Fixed strings
# only, so the block stays byte-stable (no timestamps, no free text from
# disk). Exit 0: this feeds hook output and must never error the agent loop.
emit_unavailable() {
printf '=== RUN LEDGER ===\n'
printf 'ledger: unavailable (%s)\n' "$1"
printf '==================\n'
exit 0
}
PLAN_DIR=""
if [ -n "${ARG_DIR}" ]; then
# The caller already resolved the plan dir; never second-guess it with a
# cwd-based re-resolution (that is exactly the parent/child mismatch this
# argument exists to prevent). If the named dir is gone, say so.
[ -d "${ARG_DIR}" ] || emit_unavailable "plan dir argument does not exist"
PLAN_DIR="${ARG_DIR}"
elif [ -f "${RESOLVER}" ]; then
PLAN_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "${PLAN_DIR}" ] || [ ! -d "${PLAN_DIR}" ]; then
PLAN_DIR="."
fi
else
emit_unavailable "resolve-plan-dir.sh missing and no plan dir argument"
fi
if [ "${PLAN_DIR}" = "." ]; then
PLAN_FILE="./task_plan.md"
else
PLAN_FILE="${PLAN_DIR}/task_plan.md"
fi
# --- Phase counts: identical grep patterns to check-complete.sh ---
TOTAL=0
COMPLETE=0
IN_PROGRESS=0
IN_PROGRESS_HEADING="none"
if [ -f "${PLAN_FILE}" ]; then
TOTAL=$(grep -c "### Phase" "${PLAN_FILE}" 2>/dev/null || true)
COMPLETE=$(grep -cF "**Status:** complete" "${PLAN_FILE}" 2>/dev/null || true)
IN_PROGRESS=$(grep -cF "**Status:** in_progress" "${PLAN_FILE}" 2>/dev/null || true)
# Fallback to inline [status] format when **Status:** is absent.
if [ "${COMPLETE}" -eq 0 ] && [ "${IN_PROGRESS}" -eq 0 ]; then
c2=$(grep -c "\[complete\]" "${PLAN_FILE}" 2>/dev/null || true)
i2=$(grep -c "\[in_progress\]" "${PLAN_FILE}" 2>/dev/null || true)
: "${c2:=0}"
: "${i2:=0}"
if [ "${c2}" -gt 0 ] || [ "${i2}" -gt 0 ]; then
COMPLETE="${c2}"
IN_PROGRESS="${i2}"
fi
fi
# Heading of the FIRST phase whose status block is in_progress. We walk
# phase headings and look ahead for the status line so the summary names
# the active phase without leaking any plan body text beyond the heading.
heading=""
state=""
# shellcheck disable=SC2162
while IFS= read -r line; do
case "${line}" in
"### Phase"*)
heading="${line}"
;;
*"**Status:** in_progress"*)
if [ -n "${heading}" ]; then
IN_PROGRESS_HEADING="${heading}"
break
fi
;;
*"[in_progress]"*)
if [ -n "${heading}" ] && [ "${IN_PROGRESS_HEADING}" = "none" ]; then
IN_PROGRESS_HEADING="${heading}"
fi
;;
esac
done < "${PLAN_FILE}"
fi
: "${TOTAL:=0}"
: "${COMPLETE:=0}"
: "${IN_PROGRESS:=0}"
# --- Ledger stats: total entries + last event type per agent ---
TOTAL_ENTRIES=0
for f in "${PLAN_DIR}"/ledger-*.jsonl; do
[ -f "${f}" ] || continue
n=$(grep -c '"tick"' "${f}" 2>/dev/null || true)
: "${n:=0}"
TOTAL_ENTRIES=$((TOTAL_ENTRIES + n))
done
printf '=== RUN LEDGER ===\n'
printf 'entries: %s\n' "${TOTAL_ENTRIES}"
printf 'phases: %s/%s complete\n' "${COMPLETE}" "${TOTAL}"
printf 'in_progress: %s\n' "${IN_PROGRESS_HEADING}"
# Per-agent last event type. Agent name comes from the filename
# (ledger-<agent>.jsonl); the last event is parsed from the final line.
for f in "${PLAN_DIR}"/ledger-*.jsonl; do
[ -f "${f}" ] || continue
base="$(basename "${f}")"
agent="${base#ledger-}"
agent="${agent%.jsonl}"
last_line="$(tail -n 1 "${f}" 2>/dev/null)"
last_event="$(printf '%s' "${last_line}" | sed -n 's/.*"event"[[:space:]]*:[[:space:]]*"\([A-Za-z_]*\)".*/\1/p')"
[ -z "${last_event}" ] && last_event="none"
printf 'agent %s: %s\n' "${agent}" "${last_event}"
done
printf '==================\n'
exit 0