diff --git a/hooks/finish-flow-guard.sh b/hooks/finish-flow-guard.sh
new file mode 100755
index 0000000..600e01e
--- /dev/null
+++ b/hooks/finish-flow-guard.sh
@@ -0,0 +1,152 @@
+#!/usr/bin/env bash
+# Claude PreToolUse(Bash) hook: refuse `finish-detect-mode.sh pr` in an UNATTENDED run whose
+# originating invocation never carried a `pr` token.
+#
+# WHY: `/auto`'s worktree default is merge, and `pr` is opt-in. Three times a session added `pr`
+# on its own judgment — JA-390 (2026-08-19, `/full auto wt pr` from a bare `/loop /auto`), JA-367
+# (2026-08-20, dispatched `auto JA-367 merge` then ran mode detection with `pr`), and JA-415
+# (2026-08-20, `/auto ja-415` -> `Skill(full,"auto wt pr JA-415")`). Each cited the same false
+# evidence: "every recent issue shipped via PR." That history is a product of the INTERACTIVE
+# convention, so the reasoning is circular — the run reads its own prior output back as proof.
+#
+# The cost is not cosmetic: in pr mode the source branch does not advance until the PR merges, so
+# the next issue forks without its predecessor's code; `In Review` is a `started`-type state, so
+# `blocks` edges never release and dependents stay invisible to /next and /auto; and SHIPPED-PR
+# leaves the worktree behind.
+#
+# Prose already lost here once — jarvis CLAUDE.md 448ad05 stated the rule and JA-415 drifted hours
+# later, because a worktree session reads a CLAUDE.md snapshot predating the fix, keys a separate
+# memory namespace, and carries its own PR precedent through compaction. Per fleet-retro doctrine,
+# a rule that already existed and lost gets a mechanical guard, not more prose (precedents:
+# linear-create-state-guard.sh, no-blind-sleep.sh, auto-heartbeat.sh, full-continue.sh).
+#
+# SCOPE — interactive flows are deliberately untouched. The guard fires only when an `auto` token
+# is in the dispatch chain, so a hand-typed `/finish pr`, and a bare `/finish` that means pr by a
+# project's own convention, both pass. `/loop /auto pr` passes too: the user typed the token.
+
+set -uo pipefail
+
+INPUT=$(cat)
+COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
+[[ -z "${COMMAND:-}" ]] && exit 0
+
+# The overwhelming majority of Bash calls are not mode detection — bail before any parsing work.
+grep -q 'finish-detect-mode' <<<"$COMMAND" || exit 0
+
+TRANSCRIPT_PATH=$(jq -r '.transcript_path // empty' <<<"$INPUT" 2>/dev/null || true)
+TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
+
+VERDICT=$(COMMAND="$COMMAND" TRANSCRIPT="${TRANSCRIPT_PATH:-}" python3 -c '
+import json, os, re, sys
+
+cmd = os.environ["COMMAND"]
+
+# Quoted text is normally DATA and must not be read as an invocation, so a `grep
+# "finish-detect-mode.sh pr" skills/` never trips this guard. But under an executor the quoted text
+# IS the code, and stripping it would be a trivial bypass. Same rule as linear-create-state-guard.sh.
+EXECUTOR = r"\b(?:ba|z|k)?sh\s+-[A-Za-z]*c\b|\beval\b|\bxargs\b"
+scan = cmd if re.search(EXECUTOR, cmd) else re.sub(r"\x27[^\x27]*\x27|\"[^\"]*\"", " ", cmd)
+
+# Only a real invocation carrying a pr argument. Stop the arg capture at a statement boundary so a
+# later `; echo pr` cannot manufacture a match.
+m = re.search(r"finish-detect-mode\.sh([^;&|\n]*)", scan)
+if not m:
+ sys.exit(0)
+
+# Token boundaries must treat a QUOTE as a delimiter: under `bash -c "... pr"` the argument ends
+# at the closing quote, and a whitespace-anchored match would sail straight past it. Excluding
+# only [\w-] keeps `pr-deploy` and `autocompact` from counting as the bare tokens.
+PR = re.compile(r"(? 64 * 1024 * 1024:
+ data = data[-64 * 1024 * 1024:]
+lines = data.split(b"\n")
+
+CMDNAME = re.compile(rb"/?([A-Za-z-]+)")
+CMDARGS = re.compile(rb"(.*?)", re.S)
+WANT = ("auto", "full", "finish", "loop")
+
+# Walk backwards to the most recent GENUINE slash-command invocation. A real one is a user entry
+# whose message content is a plain STRING opening with ; a tool_result that merely
+# echoes transcript text (this hook has been developed in exactly such a session) carries a LIST
+# content and is skipped, so transcript-analysis work cannot spoof intent.
+idx, name, args = -1, None, ""
+for i in range(len(lines) - 1, -1, -1):
+ mm = CMDNAME.search(lines[i])
+ if not mm or mm.group(1).decode().lower() not in WANT:
+ continue
+ try:
+ d = json.loads(lines[i])
+ except Exception:
+ continue
+ if d.get("type") != "user":
+ continue
+ content = (d.get("message") or {}).get("content")
+ if not isinstance(content, str) or not content.lstrip().startswith(""):
+ continue
+ idx = i
+ name = mm.group(1).decode().lower()
+ am = CMDARGS.search(lines[i])
+ args = am.group(1).decode("utf-8", "replace") if am else ""
+ break
+
+if idx < 0:
+ sys.exit(0) # fail OPEN: no invocation found (compaction, odd shape)
+
+user_pr = bool(PR.search(args))
+
+# Unattended when the invocation itself is /auto, names auto among its args, or is a /loop wrapping
+# one; failing those, when any skill dispatched SINCE that invocation carried an auto token.
+chain_auto = name == "auto" or bool(AUTO.search(args)) or (name == "loop" and "auto" in args.lower())
+if not chain_auto:
+ SKILLARGS = re.compile(rb"\"name\"\s*:\s*\"Skill\".*?\"args\"\s*:\s*\"([^\"]*)\"", re.S)
+ for ln in lines[idx + 1:]:
+ if any(AUTO.search(g.decode("utf-8", "replace")) for g in SKILLARGS.findall(ln)):
+ chain_auto = True
+ break
+
+if chain_auto and not user_pr:
+ print("/%s %s" % (name, args.strip()))
+ sys.exit(1)
+sys.exit(0)
+' 2>/dev/null)
+RC=$?
+
+# Block ONLY on a non-zero exit that also produced a verdict — any breakage in the analyzer above
+# (python missing, regex error, unreadable transcript) exits non-zero with empty stdout and must
+# fail OPEN. A false ALLOW costs one PR to close; a false BLOCK breaks a real ship mid-flight.
+if [[ $RC -ne 0 && -n "${VERDICT:-}" ]]; then
+ cat >&2 <, or /loop /auto pr.
+EOF
+ exit 2
+fi
+
+exit 0
diff --git a/hooks/finish-flow-guard.test.sh b/hooks/finish-flow-guard.test.sh
new file mode 100755
index 0000000..9d18abc
--- /dev/null
+++ b/hooks/finish-flow-guard.test.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+# Regression harness for finish-flow-guard.sh. Builds synthetic transcripts, feeds them with a Bash
+# command, and asserts BLOCK/ALLOW.
+#
+# Cases 1-3 are the three real drifts the hook exists to stop — JA-415, JA-367, JA-390 — each
+# reconstructed from its actual transcript.
+#
+# The ALLOW half matters MORE than the BLOCK half: a false allow costs one PR to close, a false
+# block breaks a real ship mid-flight. Every interactive shape, every user-typed `pr`, and every
+# unreadable-intent case must survive.
+#
+# GROW THIS SUITE, NEVER PRUNE IT — same contract as its hook siblings.
+
+set -uo pipefail
+cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1
+
+TMP=$(mktemp -d) || exit 1
+trap 'rm -rf "$TMP"' EXIT
+
+PASS=0
+FAIL=0
+
+usercmd() { python3 -c '
+import json, sys
+n, a = sys.argv[1], sys.argv[2]
+print(json.dumps({"type": "user", "message": {"content":
+ "%s\n/%s\n%s" % (n, n, a)}}))
+' "$1" "$2"; }
+
+skill() { python3 -c '
+import json, sys
+print(json.dumps({"type": "assistant", "message": {"content":
+ [{"type": "tool_use", "name": "Skill", "input": {"skill": sys.argv[1], "args": sys.argv[2]}}]}}))
+' "$1" "$2"; }
+
+# A tool_result that ECHOES transcript text — list content, so it must never read as an invocation.
+echoed() { python3 -c '
+import json, sys
+print(json.dumps({"type": "user", "message": {"content":
+ [{"type": "tool_result", "content": sys.argv[1]}]}}))
+' "$1"; }
+
+t() { # want desc command [transcript]
+ local want="$1" desc="$2" cmd="$3" tr="${4:-}" out rc got
+ out=$(jq -n --arg c "$cmd" --arg t "$tr" '{tool_input:{command:$c},transcript_path:$t}' | ./finish-flow-guard.sh 2>&1)
+ rc=$?
+ got=$([[ $rc -eq 2 ]] && echo BLOCK || echo ALLOW)
+ if [[ "$got" == "$want" ]]; then
+ PASS=$((PASS + 1)); printf ' ok %-6s %s\n' "$got" "$desc"
+ else
+ FAIL=$((FAIL + 1)); printf ' FAIL want=%s got=%s %s\n %s\n' "$want" "$got" "$desc" "$(head -1 <<<"$out")"
+ fi
+}
+
+# --- transcripts -------------------------------------------------------------------------------
+JA415="$TMP/ja415.jsonl"
+{ usercmd auto "ja-415"; skill full "auto wt pr JA-415"; skill start "auto wt JA-415"; } >"$JA415"
+
+JA367="$TMP/ja367.jsonl"
+{ usercmd loop "/auto"; skill full "auto wt JA-367"; skill finish "auto JA-367 merge"; } >"$JA367"
+
+JA390="$TMP/ja390.jsonl"
+{ usercmd loop "/auto"; skill full "auto wt pr JA-390"; } >"$JA390"
+
+FULLAUTO="$TMP/fullauto.jsonl"
+{ usercmd full "auto wt JA-1"; skill start "auto wt JA-1"; } >"$FULLAUTO"
+
+AUTOPR="$TMP/autopr.jsonl"
+{ usercmd auto "pr JA-1"; skill full "auto wt pr JA-1"; } >"$AUTOPR"
+
+LOOPPR="$TMP/looppr.jsonl"
+{ usercmd loop "/auto pr"; skill full "auto wt pr JA-1"; } >"$LOOPPR"
+
+BAREFIN="$TMP/barefin.jsonl"
+{ usercmd finish ""; } >"$BAREFIN"
+
+INTERPR="$TMP/interpr.jsonl"
+{ usercmd finish "pr JA-1"; } >"$INTERPR"
+
+NOCMD="$TMP/nocmd.jsonl"
+{ skill full "auto wt pr JA-1"; } >"$NOCMD"
+
+# Genuine `/auto ja-1`, then a tool_result echoing an interactive `/finish pr` invocation. If the
+# echo were mistaken for intent the guard would wrongly ALLOW.
+SPOOF="$TMP/spoof.jsonl"
+{ usercmd auto "ja-1"
+ skill full "auto wt pr JA-1"
+ echoed 'finish
+/finish
+pr JA-1'; } >"$SPOOF"
+
+echo "finish-flow-guard.sh —"
+echo " must BLOCK:"
+t BLOCK "1 JA-415: /auto ja-415 -> pr injected at /full" '~/.claude/scripts/finish-detect-mode.sh pr 2>&1; echo "EXIT=$?"' "$JA415"
+t BLOCK "2 JA-367: dispatched merge, ran pr" '~/.claude/scripts/finish-detect-mode.sh pr; echo "DETECT_EXIT=$?"' "$JA367"
+t BLOCK "3 JA-390: pr injected, stderr redirect form" '~/.claude/scripts/finish-detect-mode.sh pr 2>tmp/finish-detect-ja-390.err; echo "EXIT=$?"' "$JA390"
+t BLOCK "4 /full auto wt with no pr token" '~/.claude/scripts/finish-detect-mode.sh pr' "$FULLAUTO"
+t BLOCK "5 echoed invocation must not spoof intent" '~/.claude/scripts/finish-detect-mode.sh pr' "$SPOOF"
+t BLOCK "6 quotes are not a bypass under bash -c" 'bash -c "~/.claude/scripts/finish-detect-mode.sh pr"' "$JA415"
+
+echo " must ALLOW:"
+t ALLOW "7 user typed /auto pr" '~/.claude/scripts/finish-detect-mode.sh pr' "$AUTOPR"
+t ALLOW "8 user typed /loop /auto pr" '~/.claude/scripts/finish-detect-mode.sh pr' "$LOOPPR"
+t ALLOW "9 bare interactive /finish (no auto in chain)" '~/.claude/scripts/finish-detect-mode.sh pr' "$BAREFIN"
+t ALLOW "10 interactive /finish pr" '~/.claude/scripts/finish-detect-mode.sh pr' "$INTERPR"
+t ALLOW "11 merge under an auto chain" '~/.claude/scripts/finish-detect-mode.sh merge; echo "EXIT=$?"' "$JA415"
+t ALLOW "12 no action token under an auto chain" '~/.claude/scripts/finish-detect-mode.sh' "$JA415"
+t ALLOW "13 missing transcript_path fails open" '~/.claude/scripts/finish-detect-mode.sh pr' ""
+t ALLOW "14 nonexistent transcript fails open" '~/.claude/scripts/finish-detect-mode.sh pr' "$TMP/absent.jsonl"
+t ALLOW "15 no invocation in transcript fails open" '~/.claude/scripts/finish-detect-mode.sh pr' "$NOCMD"
+t ALLOW "16 grep for the command is data, not an invocation" 'grep -rn "finish-detect-mode.sh pr" ~/.claude/skills/' "$JA415"
+t ALLOW "17 unrelated command" 'git status --short' "$JA415"
+t ALLOW "18 pr only after a statement boundary" '~/.claude/scripts/finish-detect-mode.sh merge; echo pr' "$JA415"
+t ALLOW "19 docs edit quoting the anti-pattern" "echo 'never finish-detect-mode.sh pr in auto' >> tmp/notes.md" "$JA415"
+
+echo
+echo "passed: $PASS failed: $FAIL"
+[[ "$FAIL" -eq 0 ]]
diff --git a/settings.json b/settings.json
index 047a241..2a61223 100644
--- a/settings.json
+++ b/settings.json
@@ -178,6 +178,10 @@
"type": "command",
"command": "~/.claude/hooks/linear-create-state-guard.sh"
},
+ {
+ "type": "command",
+ "command": "~/.claude/hooks/finish-flow-guard.sh"
+ },
{
"type": "command",
"command": "~/.claude/hooks/auto-deadline-gate.sh"