Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changes/planning/fix-planning-agent-repin-4b29645c.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- **`interview-defenses.test.sh` digest re-pins no longer need a person.** Any session, a lane included, may re-pin a digest after reading the diff of the guarded text: `tests/repin.sh <pin-label> <reason>` runs with no terminal and no y/N prompt, rewrites the one sha256 literal, adds the reason above the pin, and prints `<label>: <old> -> <new>` for the commit message. The suite's failure messages and header drop the attended-only rule; the commit carries old -> new and why the defense holds, and the PR review judges it.
28 changes: 15 additions & 13 deletions plugins/planning/tests/interview-defenses.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@
# Rewording, extending, or qualifying a pinned line is EXPECTED to fail, including a typo
# fix, and so is any edit inside a digested region. That is the contract for a load-bearing
# behavioral rule with no runner behind it: re-read the defense, confirm it still holds,
# then update the skill body and this suite in one change. Recomputing a digest or pin here is
# attended-only: an unattended run that hits a failing digest reports the pin and the git diff,
# then stops, and never updates it.
# then update the skill body and this suite in one change. Any session, a lane included, may
# re-pin a digest after that re-read: tests/repin.sh rewrites one sha256 literal, and the commit
# records the old and new digest and why the defense still holds, so the PR review judges the
# change against the text it guards.
Comment thread
kyle-sexton marked this conversation as resolved.
#
# Relationship to the older cases. evals.json ships three narrative cases over the same two
# defenses — `auto-guard-never-folds-user-choice` (2), `lock-mode-does-not-fudge-gap` (3),
Expand All @@ -100,10 +101,10 @@ AUDIT="$PLUGIN_DIR/skills/audit-answers/SKILL.md"
EVALS="$PLUGIN_DIR/skills/interview/evals/evals.json"
FIXTURES="$PLUGIN_DIR/skills/interview/evals/fixtures"

ATTENDED_ONLY="Recomputing a pin or digest is attended-only: an unattended run reports this pin and the git diff, then stops."
UPDATE_RULE="Read the git diff of this text first; the commit that updates the pin states, for each pin, old -> new and why the defense still holds."
# Appended to the digest-pin failures only: repin.sh rewrites a sha256 literal, so the exact-line
# pins and the boundary failure have nothing for it to recompute.
REPIN_HINT="After reading the diff, a person re-pins this digest by running, in their own terminal (a Claude Code ! line has no terminal): bash plugins/planning/tests/repin.sh '<pin label text>' '<justification>'"
REPIN_HINT="Re-pin with: bash plugins/planning/tests/repin.sh '<pin label text>' '<why the defense still holds>'"
PASS=0
FAIL=0
fail() {
Expand Down Expand Up @@ -176,7 +177,7 @@ pin_exact() {
if grep -Fxq -- "$line" "$file"; then
ok "$label"
else
fail "$label — no line in ${file#"$PLUGIN_DIR/"} matches the pinned defense text EXACTLY. The rule was reworded, extended, or qualified. Re-read it, confirm the defense still holds, then update the pin. Pinned: $line ${ATTENDED_ONLY}"
fail "$label — no line in ${file#"$PLUGIN_DIR/"} matches the pinned defense text EXACTLY. The rule was reworded, extended, or qualified. Re-read it, confirm the defense still holds, then update the pin. Pinned: $line ${UPDATE_RULE}"
fi
}

Expand Down Expand Up @@ -243,7 +244,7 @@ pin_section() {
n_open="$(grep -Fxc -- "$open" "$file" || true)"
n_close="$(grep -Fxc -- "$close" "$file" || true)"
if [[ "$n_open" != "1" || "$n_close" != "1" ]]; then
fail "$label — the section boundaries are no longer unique whole lines in ${file#"$PLUGIN_DIR/"} (\"$open\" x$n_open, \"$close\" x$n_close). A duplicated or renamed boundary moves the digested region, which is how a region pin gets silently emptied. Restore the headings, or re-anchor the pin and update the digest. ${ATTENDED_ONLY}"
fail "$label — the section boundaries are no longer unique whole lines in ${file#"$PLUGIN_DIR/"} (\"$open\" x$n_open, \"$close\" x$n_close). A duplicated or renamed boundary moves the digested region, which is how a region pin gets silently emptied. Restore the headings, or re-anchor the pin and update the digest. ${UPDATE_RULE}"
return
fi
got="$(section_digest "$file" "$open" "$close")"
Expand All @@ -252,7 +253,7 @@ pin_section() {
elif [[ "$got" == "$want" ]]; then
ok "$label"
else
fail "$label — the section between \"$open\" and \"$close\" in ${file#"$PLUGIN_DIR/"} changed (want $want, got $got). Anything added inside this section can qualify the defense it houses WITHOUT touching a pinned line. Re-read the section, confirm neither defense was weakened, then update the digest. ${ATTENDED_ONLY} ${REPIN_HINT}"
fail "$label — the section between \"$open\" and \"$close\" in ${file#"$PLUGIN_DIR/"} changed (want $want, got $got). Anything added inside this section can qualify the defense it houses WITHOUT touching a pinned line. Re-read the section, confirm neither defense was weakened, then update the digest. ${UPDATE_RULE} ${REPIN_HINT}"
fi
}

Expand All @@ -272,7 +273,7 @@ pin_frontmatter() {
elif [[ "$got" == "$want" ]]; then
ok "$label"
else
fail "$label — the YAML frontmatter of ${file#"$PLUGIN_DIR/"} changed (want $want, got $got). Frontmatter is loaded before any section, so a qualifier in ANY key here reaches every invocation. Re-read it, confirm neither defense was weakened, then update the digest. ${ATTENDED_ONLY} ${REPIN_HINT}"
fail "$label — the YAML frontmatter of ${file#"$PLUGIN_DIR/"} changed (want $want, got $got). Frontmatter is loaded before any section, so a qualifier in ANY key here reaches every invocation. Re-read it, confirm neither defense was weakened, then update the digest. ${UPDATE_RULE} ${REPIN_HINT}"
fi
}

Expand All @@ -290,7 +291,7 @@ pin_case_digest() {
elif [[ "$got" == "$want" ]]; then
ok "$label"
else
fail "$label — eval case \"$name\" changed (want $want, got $got). A criterion may have been added that contradicts one already pinned. Re-read the case, confirm it still rejects the silent capture, then update the digest. ${ATTENDED_ONLY} ${REPIN_HINT}"
fail "$label — eval case \"$name\" changed (want $want, got $got). A criterion may have been added that contradicts one already pinned. Re-read the case, confirm it still rejects the silent capture, then update the digest. ${UPDATE_RULE} ${REPIN_HINT}"
fi
}

Expand All @@ -307,7 +308,7 @@ pin_file() {
elif [[ "$got" == "$want" ]]; then
ok "$label"
else
fail "$label — ${file#"$PLUGIN_DIR/"} changed (want $want, got $got). This fixture IS the case's plant: if it no longer plants a genuinely-open decision (case A) or a resolved-but-one context (case B), the case grades nothing. Re-read it, confirm the plant survives, then update the digest. ${ATTENDED_ONLY} ${REPIN_HINT}"
fail "$label — ${file#"$PLUGIN_DIR/"} changed (want $want, got $got). This fixture IS the case's plant: if it no longer plants a genuinely-open decision (case A) or a resolved-but-one context (case B), the case grades nothing. Re-read it, confirm the plant survives, then update the digest. ${UPDATE_RULE} ${REPIN_HINT}"
fi
}

Expand All @@ -327,7 +328,7 @@ pin_case_set() {
elif [[ "$got" == "$want" ]]; then
ok "$label"
else
fail "$label — the eval-case roster in ${EVALS#"$PLUGIN_DIR/"} changed (want $want, got $got). Adding a case is fine; adding one that contradicts case 15 or 16 is not. Confirm no new case licenses the silent capture or a fudged gap, then update the digest. ${ATTENDED_ONLY} ${REPIN_HINT}"
fail "$label — the eval-case roster in ${EVALS#"$PLUGIN_DIR/"} changed (want $want, got $got). Adding a case is fine; adding one that contradicts case 15 or 16 is not. Confirm no new case licenses the silent capture or a fudged gap, then update the digest. ${UPDATE_RULE} ${REPIN_HINT}"
fi
}

Expand Down Expand Up @@ -430,7 +431,8 @@ declares_both_fixtures B "$CASE_B"
#
# Update procedure when one of these fires: read `git diff` for the named section or case,
# confirm in words that neither defense was weakened, qualified, or contradicted by the
# change, then recompute and replace the digest below IN THE SAME COMMIT.
# change, then re-pin the digest below IN THE SAME COMMIT (tests/repin.sh), with old -> new
# and that reason in the commit message.
#
# The boundary tests below MUST match `section_digest`'s — whole-line equality, not a
# prefix match. They agree on today's content and diverge in exactly the crafted-heading
Expand Down
40 changes: 11 additions & 29 deletions plugins/planning/tests/repin.sh
Original file line number Diff line number Diff line change
@@ -1,28 +1,26 @@
#!/usr/bin/env bash
# Attended re-pin for one digest pin of interview-defenses.test.sh.
# Re-pin one digest pin of interview-defenses.test.sh.
#
# repin.sh [--test-file <path>] <pin-label> <justification>
#
# A person runs this after reading the git diff of the guarded text and confirming
# the defense still holds. It refuses to write unless stdin and stdout are both
# terminals, and then writes only after the person answers y to the shown old and
# new digest and diff stat. There is no flag or variable to skip either check. It
# Run it after reading the diff of the guarded text and confirming the defense still holds;
# the commit carries the printed old -> new line and the reason, and the PR review judges the
# change. It
# 1. finds the one digest pin (pin_section, pin_frontmatter, pin_case_digest,
# pin_case_set or pin_file) whose call line contains <pin-label> (backslashes
# ignored, so a label with backticks is typed plainly),
# 2. runs the test and reads that pin's `want X, got Y` failure,
# 3. checks for a terminal, shows the old and new digest and `git diff --stat`
# of the plugin, and asks y/N (default N),
# 4. rewrites only that pin's sha256 literal to Y and inserts `# <justification>`
# 3. rewrites only that pin's sha256 literal to Y and inserts `# <justification>`
# on the line above the call, beside the existing re-pin comments,
# 4. prints `<label>: <old> -> <new>` for the commit message,
# 5. reruns the test and prints PASS or FAIL.
# Every other line of the file stays byte-identical.
#
# Exit: 0 re-pinned and the test passes; 1 re-pinned but the test still fails;
# 2 usage or an unreadable test file; 3 the label matches no digest pin, or more
# than one; 4 nothing changed: the digest already matches, or the pin fails for a
# reason a new digest does not fix (such as a section boundary that is no longer a
# unique heading line); 5 stdin or stdout is not a terminal; 6 the person declined.
# unique heading line).
set -uo pipefail

self="$(basename "$0")"
Expand Down Expand Up @@ -87,29 +85,13 @@ run_test() { bash "$test_file" 2>&1; }

out="$(run_test)"
new="$(printf '%s\n' "$out" | tr -d '\r' | sed -n "s/.*(want $old, got \([0-9a-f]\{64\}\)).*/\1/p" | head -1)"
# The label is the call's first argument, backslashes dropped; it ends at the line's last quote.
pin_label="$(sed -n "${call_line}p" "$test_file" | tr -d "\\\\" | sed 's/^pin_[a-z_]* "\(.*\)"[[:space:]]*$/\1/')"
if [[ -z "$new" ]]; then
stripped="$(sed -n "${call_line}p" "$test_file" | tr -d "\\\\")"
echo "$self: nothing to change for ${stripped}: the digest already matches, or the pin fails for a reason a new digest does not fix." >&2
echo "$self: nothing to change for ${pin_label}: the digest already matches, or the pin fails for a reason a new digest does not fix." >&2
exit 4
fi

if ! [[ -t 0 && -t 1 ]]; then
echo "$self: refusing to re-pin: stdin and stdout must both be a terminal. A person runs this in their own terminal after reading the diff; a Claude Code ! line has no terminal, and an unattended run reports the failing pin and stops." >&2
exit 5
fi
echo "Pin: $(sed -n "${call_line}p" "$test_file" | tr -d "\\\\")"
echo "Old digest: $old"
echo "New digest: $new"
plugin_dir="$(cd "$(dirname "$test_file")/.." && pwd)"
echo "Changes under $plugin_dir since HEAD:"
# --no-pager: a pager would read the y/N answer from the terminal.
git --no-pager -C "$plugin_dir" diff --stat HEAD -- . 2>/dev/null || echo "(no git diff available)"
read -r -p "Write the new digest? [y/N] " answer || answer=""
if [[ "$answer" != [yY] && "$answer" != [yY][eE][sS] ]]; then
echo "$self: not re-pinned." >&2
exit 6
fi

tmp="$(mktemp "${TMPDIR:-/tmp}/repin.XXXXXX")" || exit 2
trap 'rm -f "$tmp"' EXIT
REPIN_NOTE="$justification" awk -v call="$call_line" -v shaln="$sha_line" -v new="$new" '
Expand All @@ -121,7 +103,7 @@ REPIN_NOTE="$justification" awk -v call="$call_line" -v shaln="$sha_line" -v new
# Write through the existing file so its mode and ownership stay.
cat "$tmp" >"$test_file" || exit 2

echo "$self: re-pinned line $sha_line: $old -> $new"
echo "${pin_label}: $old -> $new"
if run_test >/dev/null; then
echo "PASS"
exit 0
Expand Down
Loading
Loading