diff --git a/scripts/test-root-citations.sh b/scripts/test-root-citations.sh new file mode 100755 index 0000000..7477ddb --- /dev/null +++ b/scripts/test-root-citations.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Fixture harness for the shared-reference citation check (scripts/validate.sh +# check 5a.). Extracts that check's Python body from validate.sh, runs it +# against a disposable mktemp -d tree holding one skill line per case, and +# asserts pass or fail. Never mutates the real skills/ or references/ files. +# +# Manual invocation only: not wired into scripts/validate.sh or any CI. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FAIL_COUNT=0 + +CHECK="$(awk '/^# 5a\./{found=1; next} found && /<<'\''PY'\''/{body=1; next} body && /^PY$/{exit} body' "$ROOT/scripts/validate.sh")" +if [[ -z "$CHECK" ]]; then + echo "harness error: check 5a body not found in scripts/validate.sh" >&2 + exit 1 +fi + +run_line() { + local line="$1" dir out code + dir="$(mktemp -d 2>&1)" || { echo " harness error: mktemp -d failed: $dir" >&2; return 2; } + mkdir -p "$dir/references" "$dir/skills/demo" + : > "$dir/references/dispatch-degradation.md" + : > "$dir/references/question-tools.md" + printf '%s\n' "$line" > "$dir/skills/demo/SKILL.md" + out="$(python3 - "$dir" <<<"$CHECK" 2>&1)"; code=$? + rm -rf "$dir" + printf '%s\n' "$out" + return $code +} + +expect() { + local want="$1" label="$2" line="$3" out code + out="$(run_line "$line")"; code=$? + if [[ $code -eq 2 ]]; then + echo "Case $label: FAIL (harness)"; FAIL_COUNT=$((FAIL_COUNT + 1)); return + fi + if [[ "$want" == pass && $code -eq 0 ]] || [[ "$want" == fail && $code -ne 0 && "$out" == *"without naming the plugin root"* ]]; then + echo "Case $label: pass" + else + echo "Case $label: FAIL (expected $want, exit $code)" + printf ' %s\n' "$out" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +expect pass "at the plugin root" 'Follow `references/question-tools.md` at the plugin root.' +expect pass "(plugin root)" 'Follow `references/question-tools.md` (plugin root) for the table.' +expect pass "(plugin root; ...)" 'Per `references/dispatch-degradation.md` (plugin root; native parallel first).' +expect pass "(repo root, shared)" 'Per `references/dispatch-degradation.md` (repo root, shared): dispatch.' +expect pass "(repo-root shared ...)" 'Follow `references/dispatch-degradation.md` (repo-root shared reference) unmodified.' +expect pass "./ form qualified" 'Follow `./references/question-tools.md` at the plugin root.' +expect pass "both qualified" 'See `references/question-tools.md` (plugin root) and `references/dispatch-degradation.md` (plugin root).' +expect pass "longer path not a cite" 'The copy at `skills/demo/references/question-tools.md` is removed.' +expect fail "bare" 'Follow `references/question-tools.md` for the table.' +expect fail "./ form bare" 'Follow `./references/question-tools.md` for the table.' +expect fail "marker elsewhere on line" 'Follow `references/question-tools.md`; the plugin root holds other files.' +expect fail "one of two qualified" 'See `references/question-tools.md` and `references/dispatch-degradation.md` (plugin root).' + +echo +if [[ $FAIL_COUNT -eq 0 ]]; then + echo "ALL CASES PASSED" +else + echo "$FAIL_COUNT CASE(S) FAILED" + exit 1 +fi diff --git a/scripts/validate.sh b/scripts/validate.sh index 25d907f..f8f0b26 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -105,8 +105,9 @@ import sys, re, pathlib root = pathlib.Path(sys.argv[1]) shared = sorted(p.name for p in (root / "references").glob("*.md")) # A bare `references/` inside a skill reads as skill-local; these must say where it lives. -root_qualified = ["dispatch-degradation.md"] -markers = ("plugin root", "repo root", "repo-root") +root_qualified = ["dispatch-degradation.md", "question-tools.md"] +# The marker must qualify the citation itself: "`references/x.md` at the plugin root" or "(plugin root". +qualifier = re.compile(r"`?\s*(?:\(\s*|at the )(?:plugin root|repo root|repo-root)") bad = [] for name in root_qualified: if name not in shared: @@ -122,9 +123,9 @@ for f in sorted(root.glob("skills/**/*.md")): continue for n, line in enumerate(lines, 1): for name in root_qualified: - cited = re.search(r"(?`. Use the harness's blocking question tool per -`references/question-tools.md`. Ask one single-select question with these +`references/question-tools.md` at the plugin root. Ask one single-select question with these distinct outcomes: **Approve this exact release** (recommended), **Revise the draft or version**, and **Cancel the release**. A revision returns to Draft or Version and presents a new complete packet; rewrite `.release/draft.md` before diff --git a/skills/shipping/SKILL.md b/skills/shipping/SKILL.md index 3984e4a..4452f4d 100644 --- a/skills/shipping/SKILL.md +++ b/skills/shipping/SKILL.md @@ -224,7 +224,7 @@ gh pr merge --squash --delete-branch [--auto] Runs only for the **merge** and **discard** outcomes -- "keep as-is" and "PR open for iteration" always preserve the worktree, since the user needs it alive. -Merge ordering invariant, never reordered: **merge -> verify tests on the merged result -> run every eligible approved-plan pre-removal transition -> remove worktree -> delete branch.** This transition path applies only to a chosen merge outcome. A transition is eligible only when `shipping` is invoked by `release-loop` and the approved, body-sealed plan contains a `## Release-loop Ship-cleanup transition R:` section with an explicit owner, executable acceptance, and matching mutation/failure-state matrix row. Revalidate the plan `body_seal` immediately before running it. Execute eligible transitions in heading order after merged-result verification and before worktree removal; any failure blocks cleanup. A local transition may run headlessly only when its matrix explicitly permits that outcome and its boundary proof makes every outward target unreachable; an outward transition still requires first-hand confirmation at the point of risk. Only remove a worktree this tooling created (path under `.worktrees/` or `worktrees/`); anything else is harness- or user-owned -- leave it. The typed `discard` path executes no approved-plan transition: after `references/question-tools.md` confirmation its separate order is **typed discard confirmation -> remove worktree -> delete branch**, with force-delete (`git branch -D`) only after worktree removal succeeds. +Merge ordering invariant, never reordered: **merge -> verify tests on the merged result -> run every eligible approved-plan pre-removal transition -> remove worktree -> delete branch.** This transition path applies only to a chosen merge outcome. A transition is eligible only when `shipping` is invoked by `release-loop` and the approved, body-sealed plan contains a `## Release-loop Ship-cleanup transition R:` section with an explicit owner, executable acceptance, and matching mutation/failure-state matrix row. Revalidate the plan `body_seal` immediately before running it. Execute eligible transitions in heading order after merged-result verification and before worktree removal; any failure blocks cleanup. A local transition may run headlessly only when its matrix explicitly permits that outcome and its boundary proof makes every outward target unreachable; an outward transition still requires first-hand confirmation at the point of risk. Only remove a worktree this tooling created (path under `.worktrees/` or `worktrees/`); anything else is harness- or user-owned -- leave it. The typed `discard` path executes no approved-plan transition: after `references/question-tools.md` (plugin root) confirmation its separate order is **typed discard confirmation -> remove worktree -> delete branch**, with force-delete (`git branch -D`) only after worktree removal succeeds. For **every merge outcome**, merged-result verification is an executable prerequisite for cleanup: