diff --git a/eng/.claude-plugin/plugin.json b/eng/.claude-plugin/plugin.json
index aab43af..c7c8172 100644
--- a/eng/.claude-plugin/plugin.json
+++ b/eng/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "eng",
- "version": "2.8.1",
- "description": "Engineer Ernie, the engineering persona. eng:cr is his master code-review skill and the single local review path the ~/dev merge gate keys on: it risk-tiers depth, runs the pr-review-toolkit lenses, and mints the merge-clearance stamp. It routes to cr-teammate (review someone else's PR and post a comment) and to address-pr-feedback / pr-watcher (respond to review feedback). He also spikes the riskiest unknown before building, generates CodeRabbit config, and builds macOS Shortcuts. The plugin also SHIPS Ernie's PR-lifecycle enforcement hooks (hooks/hooks.json): the ship-PR gate (PRs only via /ship), the merge-clearance gate (no merge without the cleared gauntlet AND a /land-and-deploy sentinel, making /land-and-deploy the single CLI merge path), the /ship and /land-and-deploy sentinels, the review stamp recorder, and an after-ship CodeRabbit-watcher nudge (after a genuine /ship opens a PR, points the agent at /eng:pr-watcher, or when CodeRabbit is rate-limited routes to /land-and-deploy if a current /eng:cr review backstops the head, else to /eng:cr and then /land-and-deploy), active in opted-in repos (.ship-gate.json / .merge-clearance.json) under ~/dev. Skills: eng:cr, eng:cr-teammate, eng:address-pr-feedback, eng:pr-watcher, eng:spike, eng:coderabbit-config, eng:shortcut.",
+ "version": "2.9.0",
+ "description": "Engineer Ernie, the engineering persona. eng:cr is his master code-review skill and the single local review path the ~/dev merge gate keys on: it risk-tiers depth, runs the pr-review-toolkit lenses, and mints the merge-clearance stamp. It routes to cr-teammate (review someone else's PR and post a comment) and to address-pr-feedback / pr-watcher (respond to review feedback). He also spikes the riskiest unknown before building, generates CodeRabbit config, and builds macOS Shortcuts. The plugin also SHIPS Ernie's PR-lifecycle enforcement hooks (hooks/hooks.json): the ship-PR gate (PRs only via /ship), the merge-clearance gate (no merge without the cleared gauntlet AND a /land-and-deploy sentinel, making /land-and-deploy the single CLI merge path), the deploy gate (no deploy outside a ceremony: it blocks both the repo's declared deploy entrypoint and the hand-rolled ssh build/restart shape unless /land-and-deploy or /eng:deploy armed the session), the /ship and /land-and-deploy sentinels, the review stamp recorder, and an after-ship CodeRabbit-watcher nudge (after a genuine /ship opens a PR, points the agent at /eng:pr-watcher, or when CodeRabbit is rate-limited routes to /land-and-deploy if a current /eng:cr review backstops the head, else to /eng:cr and then /land-and-deploy), active in opted-in repos (.ship-gate.json / .merge-clearance.json / .deploy-gate.json) under ~/dev. Skills: eng:cr, eng:cr-teammate, eng:deploy, eng:address-pr-feedback, eng:pr-watcher, eng:spike, eng:coderabbit-config, eng:shortcut.",
"author": {
"name": "Mujtaba Badat",
"email": "mj@belalbadat.com"
diff --git a/eng/hooks/hooks.json b/eng/hooks/hooks.json
index 8efb236..ad46ad0 100644
--- a/eng/hooks/hooks.json
+++ b/eng/hooks/hooks.json
@@ -19,6 +19,10 @@
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/scripts/ship-pr-gate.sh"
+ },
+ {
+ "type": "command",
+ "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/scripts/deploy-gate.sh"
}
]
},
@@ -32,6 +36,10 @@
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/scripts/land-deploy-sentinel.sh"
+ },
+ {
+ "type": "command",
+ "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/scripts/deploy-gate.sh"
}
]
}
@@ -46,6 +54,10 @@
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/scripts/land-deploy-sentinel.sh"
+ },
+ {
+ "type": "command",
+ "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/scripts/deploy-gate.sh"
}
]
}
diff --git a/eng/hooks/scripts/deploy-gate.sh b/eng/hooks/scripts/deploy-gate.sh
new file mode 100755
index 0000000..e35d17b
--- /dev/null
+++ b/eng/hooks/scripts/deploy-gate.sh
@@ -0,0 +1,297 @@
+#!/bin/bash
+# deploy-gate - make a deploy ceremony the only way code reaches a host.
+#
+# The merge side of this plugin has been gated for a while: pr-merge-gate.sh
+# refuses a bare `gh pr merge`, so /land-and-deploy is the single CLI merge path.
+# The DEPLOY side had nothing. Every PreToolUse guard on the machine matched
+# `gh pr merge` or `gh pr create`; a grep for deploy.sh / kickstart / deploy-mini
+# across every hooks/scripts dir returned zero files. So "/land-and-deploy is the
+# only way a PR reaches main AND production" was one sentence covering two things,
+# only the first of which was enforced.
+#
+# The cost, 2026-07-24: a hand-rolled ssh deploy to the Mac mini skipped the
+# upgrade-marker stamp, tripped nanoclaw's version tripwire, and the host
+# crash-looped behind a 900s circuit breaker for 16h46m across 72 failed starts.
+# Nothing was bypassed, because nothing was in the path. The agent was in fact
+# FOLLOWING the repo's own CLAUDE.md, which documented the bare sequence.
+#
+# This hook is both the sentinel and the gate (like land-deploy-sentinel.sh it is
+# wired to three events, and the payload says which):
+# - PreToolUse on Skill (ARM) : /land-and-deploy or /eng:deploy invoked.
+# - UserPromptSubmit (ARM) : the user typed one of those at the prompt.
+# - PreToolUse on Bash (GATE): block a deploy-shaped command unless this
+# session is armed; slide the window if it is.
+#
+# It arms its OWN kind ("deploy") rather than reusing land-deploy-sentinel.sh's
+# "land". That separation is load-bearing in one direction: a /eng:deploy must
+# never be able to authorize a `gh pr merge`. Arming "land" here would have let
+# the merge gate's Bash-mint path fire off a deploy-only ceremony.
+#
+# WHY /eng:deploy exists at all: gstack has no /deploy, and /land-and-deploy
+# hard-stops once a PR is merged ("nothing to deploy, run /canary"). Retry after a
+# failed deploy, recovery after a host wedge, and a --rebuild-base rerun are all
+# real and all PR-less. Without a second ceremony this gate would have no path for
+# them and the override below would become the routine deploy path, which is just
+# the ungated state with extra typing.
+#
+# Opt-in: enforces only in a repo carrying a `.deploy-gate.json` marker at its
+# root. Every other ~/dev repo is untouched, so a bug here cannot brick deploys
+# fleet-wide.
+#
+# Output protocol (Claude Code PreToolUse hook):
+# exit 0 + empty stdout -> allow
+# stdout JSON {"decision":"block","reason":...} -> block, reason shown to Claude
+#
+# Fail-open posture, matching every sibling gate: a missing dependency (jq/git),
+# an unreadable marker, or an unresolvable repo leaves the command ALLOWED. A
+# local gate that fails closed on its own bug trains the human to rip it out. This
+# is an accident-guard, not an adversary-proof sandbox: a plain terminal outside
+# Claude Code is invisible to any PreToolUse hook, and the arm window slides while
+# a session stays active in ~/dev.
+
+set -u
+
+PAYLOAD=$(cat)
+command -v jq >/dev/null 2>&1 || exit 0
+
+# Shared libs, resolved relative to THIS script so the executing copy binds its own
+# deps. Missing -> exit quietly (fail open), like every other unmet dependency.
+LIBDIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+RLIB="$LIBDIR/ship-gate-repo-lib.sh"
+ALIB="$LIBDIR/ship-gate-arm-lib.sh"
+{ [ -f "$RLIB" ] && [ -f "$ALIB" ]; } || exit 0
+# shellcheck source=/dev/null
+. "$RLIB"
+# shellcheck source=/dev/null
+. "$ALIB"
+
+EVENT=$(printf '%s' "$PAYLOAD" | jq -r '.hook_event_name // empty')
+CWD=$(printf '%s' "$PAYLOAD" | jq -r '.cwd // empty')
+[ -n "$CWD" ] && [ -d "$CWD" ] || CWD="$PWD"
+SESSION=$(printf '%s' "$PAYLOAD" | jq -r '.session_id // empty')
+
+# Same idle budget and sliding behavior as the merge sentinel. A real
+# /land-and-deploy runs long (merge -> wait on CI -> deploy), and only the Skill /
+# prompt events can arm, so without sliding the window would lapse before the
+# deploy step ever ran. Only an idle gap longer than ARM_TTL lapses it.
+ARM_TTL=1800
+ARM_DIR="${TMPDIR:-/tmp}"
+arm_session() { ga_arm "deploy" "$SESSION" "$ARM_DIR" "$(date +%s)"; }
+deploy_armed_fresh() { ga_armed_fresh "deploy" "$SESSION" "$ARM_DIR" "$(date +%s)" "$ARM_TTL"; }
+
+# Escape a literal string for use inside an ERE (hosts and script names carry dots
+# and dashes; an unescaped dot would match any character).
+ere_escape() { printf '%s' "$1" | sed -e 's/[][\.^$*+?(){}|\\/]/\\&/g'; }
+
+case "$EVENT" in
+ PreToolUse)
+ TOOL=$(printf '%s' "$PAYLOAD" | jq -r '.tool_name // empty')
+ ;;
+ UserPromptSubmit)
+ TOOL=""
+ ;;
+ *)
+ exit 0 ;;
+esac
+
+# ---------------------------------------------------------------- ARM branches --
+# Never block; they only record that a deploy ceremony is in flight this session.
+
+if [ "$EVENT" = "PreToolUse" ] && [ "$TOOL" = "Skill" ]; then
+ # Match the basename (exact, namespaced, or path form). Liberal on purpose: a
+ # missed match false-BLOCKS a real ceremony at deploy time, the costlier error.
+ SKILL=$(printf '%s' "$PAYLOAD" | jq -r '
+ (.tool_input.skill // .tool_input.name // .tool_input.command // "") | ascii_downcase' 2>/dev/null)
+ case "$SKILL" in
+ land-and-deploy|*:land-and-deploy|*/land-and-deploy) arm_session ;;
+ deploy|*:deploy|*/deploy) arm_session ;;
+ esac
+ exit 0
+fi
+
+if [ "$EVENT" = "UserPromptSubmit" ]; then
+ PROMPT=$(printf '%s' "$PAYLOAD" | jq -r '.prompt // empty')
+ # Require the prompt to START with the command as a whole token, so
+ # "/land-and-deployer" or prose like "should I /eng:deploy?" does not arm.
+ if printf '%s' "$PROMPT" | grep -Eiq '^[[:space:]]*/(land-and-deploy|eng:deploy)([[:space:]]|$)'; then
+ arm_session
+ fi
+ exit 0
+fi
+
+# ----------------------------------------------------------------- GATE branch --
+
+[ "$TOOL" = "Bash" ] || exit 0
+CMD=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.command // empty')
+[ -n "$CMD" ] || exit 0
+
+# Resolve the repo the command actually targets, the SAME way pr-merge-gate.sh and
+# land-deploy-sentinel.sh do, so the three can never disagree about which repo a
+# `cd
&& ...` refers to. Out of ~/dev scope -> allow.
+WORKDIR=$(sg_workdir_from_cmd "$CMD" "$CWD")
+RESOLVED=$(sg_dev_repo_gitdir "$WORKDIR") || exit 0
+TOP=${RESOLVED%%$'\t'*}
+
+# Armed -> allow, and SLIDE the window forward so an actively-working ceremony
+# keeps itself armed for its whole run. Checked before the matcher because an
+# armed session allows everything anyway, and sliding should track real activity
+# in the repo rather than only deploy commands.
+if deploy_armed_fresh; then
+ arm_session
+ exit 0
+fi
+
+# Opt-in marker. Absent -> this repo is not gated -> allow.
+MARKER="$TOP/.deploy-gate.json"
+[ -f "$MARKER" ] || exit 0
+
+# ---- Tier 1: the repo's declared deploy entrypoint --------------------------
+# Basenames come from the marker's `deploy_commands` when set, else are derived
+# from deploy.json's `.deploy.command` (the same field `devops lad-config` reads)
+# plus the conventional scripts/deploy*.sh family. Deriving rather than hardcoding
+# keeps the gate correct for repos whose entrypoint is named something else.
+NAMES=$(jq -r '(.deploy_commands // []) | .[]' "$MARKER" 2>/dev/null)
+if [ -z "$NAMES" ]; then
+ NAMES=$(jq -r '.deploy.command // "scripts/deploy.sh"' "$TOP/deploy.json" 2>/dev/null || echo "scripts/deploy.sh")
+fi
+ALT=""
+while IFS= read -r n; do
+ [ -n "$n" ] || continue
+ ALT="${ALT:+$ALT|}$(ere_escape "$(basename "$n")")"
+done </dev/null)
+HOST_ALT=""
+while IFS= read -r h; do
+ [ -n "$h" ] || continue
+ HOST_ALT="${HOST_ALT:+$HOST_ALT|}$(ere_escape "$h")"
+done < : echo "\t" for an ssh invocation,
+# or return 1. The remote command is the QUOTED argument when there is one, so a
+# locally chained command after a read-only ssh (`ssh host 'tail log' ; git pull`)
+# cannot masquerade as a hand-rolled deploy. Scoping the host this way also stops
+# a host name appearing incidentally elsewhere in the command from matching.
+ssh_payload() {
+ local after host rest
+ after=$(printf '%s' "$1" | sed -nE 's/^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*([^[:space:]]*\/)?ssh[[:space:]]+(.*)$/\3/p')
+ [ -n "$after" ] || return 1
+ # Skip ssh flags to reach the host token. A flag taking a value consumes it too.
+ while :; do
+ case "$after" in
+ -[pilo]*[[:space:]]*) after=${after#* }; after=${after#* } ;;
+ -*) after=${after#* } ;;
+ *) break ;;
+ esac
+ [ -n "$after" ] || return 1
+ done
+ host=${after%% *}
+ rest=${after#* }
+ [ -n "$host" ] || return 1
+ [ "$rest" = "$after" ] && rest="" # host with no remote command
+ case "$rest" in
+ \'*) rest=${rest#\'}; rest=${rest%%\'*} ;;
+ \"*) rest=${rest#\"}; rest=${rest%%\"*} ;;
+ esac
+ printf '%s\t%s' "$host" "$rest"
+}
+
+SHAPE=""
+DEPLOY_SEG=0
+while IFS= read -r seg; do
+ [ -n "$seg" ] || continue
+ # Tier 1: this segment invokes the declared entrypoint.
+ if printf '%s' "$seg" | grep -Eq "$T1_RE"; then
+ DEPLOY_SEG=1
+ # A read-only invocation is never gated: a retry follows a failure, and
+ # diagnosing that failure must not require a ceremony. The flag has to be in
+ # THIS invocation's own arguments.
+ printf '%s' "$seg" | grep -Eq "$RO_RE" || { SHAPE="entrypoint"; break; }
+ continue
+ fi
+ # Tier 2: this segment is an ssh to a listed host whose REMOTE command mutates.
+ [ -n "$HOST_ALT" ] || continue
+ hp=$(ssh_payload "$seg") || continue
+ h=${hp%%$'\t'*}; rc=${hp#*$'\t'}
+ printf '%s' "$h" | grep -Eq "(${HOST_ALT})" || continue
+ printf '%s' "$rc" | grep -Eq "$MUTATING_RE" || continue
+ SHAPE="hand-rolled"
+ break
+done </dev/null 2>&1 || exit 0
diff --git a/eng/hooks/scripts/ship-watch-nudge-lib.sh b/eng/hooks/scripts/ship-watch-nudge-lib.sh
index dc61a2d..1fb1546 100755
--- a/eng/hooks/scripts/ship-watch-nudge-lib.sh
+++ b/eng/hooks/scripts/ship-watch-nudge-lib.sh
@@ -44,7 +44,7 @@
# output), so the loose match is acceptable and kept byte-identical to the gate on
# purpose. Echo "no" otherwise.
swn_is_pr_create() {
- printf '%s' "$1" | grep -Eq '(^|[;&|(])[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+)*([^[:space:];&|]*/)?gh[[:space:]]+pr[[:space:]]+create([[:space:]]|$)' \
+ printf '%s' "$1" | grep -Eq '(^|[;&|(])[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*([^[:space:];&|]*/)?gh[[:space:]]+pr[[:space:]]+create([[:space:]]|$)' \
&& { echo "yes"; return 0; }
echo "no"; return 1
}
diff --git a/eng/hooks/tests/deploy-gate.bats b/eng/hooks/tests/deploy-gate.bats
new file mode 100644
index 0000000..a7f769b
--- /dev/null
+++ b/eng/hooks/tests/deploy-gate.bats
@@ -0,0 +1,424 @@
+#!/usr/bin/env bats
+# Integration tests for the deploy-gate.sh PreToolUse hook. Drives the real hook
+# with sample payloads and asserts allow (silent, exit 0) vs block (decision JSON
+# on stdout), plus the two ARM events. Temp repos must live under ~/dev because
+# the gate is scoped to that tree; they are removed in teardown.
+#
+# The load-bearing case is "blocks the 2026-07-24 command": the hand-rolled ssh
+# deploy that skipped the upgrade-marker stamp and crash-looped the mini for
+# 16h46m. If that test ever goes green-by-allowing, the gate has lost its point.
+
+setup() {
+ GATE="$BATS_TEST_DIRNAME/../scripts/deploy-gate.sh"
+ mkdir -p "$HOME/dev" # hermetic on clean runners: the gate scopes to ~/dev
+ REPO=$(mktemp -d "$HOME/dev/.dgtest.XXXXXX")
+ git -C "$REPO" init -q
+ git -C "$REPO" config user.name "Test User"
+ git -C "$REPO" config user.email "t@example.com"
+ git -C "$REPO" commit -q --allow-empty -m init
+ SESSION="dgtest-$$-${BATS_TEST_NUMBER:-0}"
+ ARMFILE="${TMPDIR:-/tmp}/gstack-deploy-armed-${SESSION}"
+ LANDARM="${TMPDIR:-/tmp}/gstack-land-armed-${SESSION}"
+ PFILE="$REPO/.payload.json"
+}
+
+teardown() {
+ rm -rf "$REPO"
+ rm -f "$ARMFILE" "$LANDARM" 2>/dev/null || true
+}
+
+# --- payload builders -------------------------------------------------------
+# Built with jq, not printf: the commands under test carry single quotes, nested
+# quotes, and `&&`, which a hand-rolled printf template mangles into a payload
+# that no longer represents the command (an early version of this file silently
+# tested backslash-mangled strings and "passed" the ssh cases by allowing them).
+bash_payload() {
+ jq -nc --arg cmd "$1" --arg cwd "${2:-$REPO}" --arg sid "$SESSION" \
+ '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$cmd},cwd:$cwd,session_id:$sid}'
+}
+skill_payload() {
+ jq -nc --arg s "$1" --arg cwd "$REPO" --arg sid "$SESSION" \
+ '{hook_event_name:"PreToolUse",tool_name:"Skill",tool_input:{skill:$s},cwd:$cwd,session_id:$sid}'
+}
+prompt_payload() {
+ jq -nc --arg p "$1" --arg cwd "$REPO" --arg sid "$SESSION" \
+ '{hook_event_name:"UserPromptSubmit",prompt:$p,cwd:$cwd,session_id:$sid}'
+}
+
+# --- fixtures ---------------------------------------------------------------
+opt_in() { printf '{"hosts":["mutwos-mac-mini","mini"]}' > "$REPO/.deploy-gate.json"; }
+opt_in_nohost() { printf '{"hosts":[]}' > "$REPO/.deploy-gate.json"; }
+deploy_json() { printf '{"deploy":{"command":"scripts/deploy.sh"}}' > "$REPO/deploy.json"; }
+
+# Pipe from a file so no shell quoting layer sits between the payload and the hook.
+feed() { printf '%s' "$1" > "$PFILE"; run bash -c "bash '$2' < '$PFILE'"; }
+run_gate() { feed "$1" "$GATE"; }
+assert_allow() { [ "$status" -eq 0 ]; [ -z "$output" ]; }
+assert_block() { [ "$status" -eq 0 ]; echo "$output" | grep -q '"decision":"block"'; }
+
+# =========================== allow: out of scope =============================
+
+@test "allow: a non-deploy command is ignored" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "git status")"
+ assert_allow
+}
+
+@test "allow: deploy command in a non-dev repo (out of scope)" {
+ run_gate "$(bash_payload "cd /tmp && scripts/deploy.sh" "/tmp")"
+ assert_allow
+}
+
+@test "allow: ~/dev repo without the opt-in marker (fails open)" {
+ deploy_json
+ run_gate "$(bash_payload "scripts/deploy.sh")"
+ assert_allow
+}
+
+# =========================== block: tier 1, entrypoint =======================
+
+@test "block: opted-in repo, bare scripts/deploy.sh, unarmed" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "scripts/deploy.sh")"
+ assert_block
+}
+
+@test "block: ./scripts/deploy.sh with flags, unarmed" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "./scripts/deploy.sh --rebuild-base --rederive-all")"
+ assert_block
+}
+
+@test "block: deploy-mini.sh invoked directly, unarmed" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "bash scripts/deploy-mini.sh")"
+ assert_block
+}
+
+@test "block: entrypoint after a leading cd into the repo" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "cd $REPO && scripts/deploy.sh" "/tmp")"
+ assert_block
+}
+
+# =========================== allow: read-only entrypoint =====================
+
+@test "allow: scripts/deploy.sh check, even unarmed" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "scripts/deploy.sh check")"
+ assert_allow
+}
+
+@test "allow: scripts/deploy.sh --status, even unarmed" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "scripts/deploy.sh --status")"
+ assert_allow
+}
+
+@test "allow: a read-only flag later in the same invocation" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "scripts/deploy.sh --force --dry-run")"
+ assert_allow
+}
+
+# --- regressions: the read-only escape must not be a bypass ------------------
+# Caught in review. The escape originally tested for the bare word `check`
+# ANYWHERE in the command, so chaining a real deploy to a check read as
+# read-only and deployed straight past the gate.
+
+@test "block: a real deploy chained to a check is NOT read-only" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "scripts/deploy.sh && devops check")"
+ assert_block
+}
+
+@test "block: a real deploy followed by a check after a semicolon" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "scripts/deploy.sh; devops check")"
+ assert_block
+}
+
+@test "block: the word check in a trailing comment is not read-only" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "scripts/deploy.sh --force # check the mini after")"
+ assert_block
+}
+
+# --- regressions: the trailing boundary must accept shell separators ---------
+# Also caught in review. With a whitespace-or-end-only boundary, a deploy
+# immediately followed by a separator was not recognized as a deploy at all.
+
+@test "block: entrypoint immediately followed by a semicolon" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "scripts/deploy.sh;echo done")"
+ assert_block
+}
+
+@test "block: entrypoint immediately followed by && (no space)" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "scripts/deploy.sh&&echo done")"
+ assert_block
+}
+
+@test "block: entrypoint inside a subshell" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "(scripts/deploy.sh)")"
+ assert_block
+}
+
+# =========================== degraded inputs still gate ======================
+# The gate fails OPEN on missing dependencies, but a malformed or thin config is
+# not a missing dependency: it must still gate, or a corrupt marker would
+# silently disarm the repo.
+
+@test "block: a malformed marker still gates (falls back to deploy.json)" {
+ printf 'NOT JSON AT ALL' > "$REPO/.deploy-gate.json"
+ deploy_json
+ run_gate "$(bash_payload "scripts/deploy.sh")"
+ assert_block
+}
+
+@test "block: no deploy.json still gates (falls back to scripts/deploy.sh)" {
+ opt_in
+ run_gate "$(bash_payload "scripts/deploy.sh")"
+ assert_block
+}
+
+@test "gates a custom entrypoint declared in deploy.json, and only that one" {
+ opt_in
+ printf '{"deploy":{"command":"bin/ship-it"}}' > "$REPO/deploy.json"
+ run_gate "$(bash_payload "bin/ship-it --now")"
+ assert_block
+ # A repo that declares bin/ship-it should not also gate an unrelated
+ # scripts/deploy.sh it does not use.
+ run_gate "$(bash_payload "scripts/deploy.sh")"
+ assert_allow
+}
+
+@test "the allow path writes nothing to stdout (hook protocol)" {
+ # Any stray stdout would be parsed as a hook decision. This fires on every
+ # Bash call in every ~/dev repo, so a single stray byte is a fleet-wide bug.
+ #
+ # Assert on FILE SIZE, not on captured output: bats strips trailing newlines
+ # from $output, so a lone stray "\n" would read as empty. Piping through `od`
+ # is also wrong here, GNU od prints an offset line for empty input while BSD od
+ # prints nothing, which passed on macOS and failed in CI.
+ opt_in; deploy_json
+ printf '%s' "$(bash_payload "git status")" > "$PFILE"
+ bash "$GATE" < "$PFILE" > "$REPO/.stdout" 2>/dev/null
+ [ ! -s "$REPO/.stdout" ]
+}
+
+# =========================== block: tier 2, the hand-roll ====================
+
+@test "block: the 2026-07-24 hand-rolled ssh deploy" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "ssh mutwos-mac-mini 'cd ~/nanoclaw && git pull --ff-only && pnpm run build'")"
+ assert_block
+}
+
+@test "block: ssh + launchctl kickstart" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "ssh mini 'launchctl kickstart -k gui/501/com.nanoclaw'")"
+ assert_block
+}
+
+@test "block: ssh + systemctl --user restart" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "ssh mutwos-mac-mini 'systemctl --user restart nanoclaw'")"
+ assert_block
+}
+
+# =========================== allow: read-only ssh ============================
+
+@test "allow: ssh + launchctl list (read-only verb)" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "ssh mutwos-mac-mini 'launchctl list | grep com.nanoclaw'")"
+ assert_allow
+}
+
+@test "allow: ssh + tail logs (read-only verb)" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "ssh mutwos-mac-mini 'tail -50 ~/nanoclaw/logs/nanoclaw.log'")"
+ assert_allow
+}
+
+@test "allow: a local build with no ssh is not a deploy" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "pnpm run build")"
+ assert_allow
+}
+
+@test "allow: ssh to a host the marker does not list" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "ssh some-other-box 'cd ~/app && git pull && pnpm run build'")"
+ assert_allow
+}
+
+@test "allow: tier 2 disabled when hosts is empty (entrypoint still gated)" {
+ opt_in_nohost; deploy_json
+ run_gate "$(bash_payload "ssh mutwos-mac-mini 'cd ~/nanoclaw && git pull && pnpm run build'")"
+ assert_allow
+ run_gate "$(bash_payload "scripts/deploy.sh")"
+ assert_block
+}
+
+# --- regressions from the CodeRabbit review on #64 ---------------------------
+# All three were verified failing against the real mutwo checkout before the fix.
+# Note CodeRabbit marked the first two "addressed" on commit activity alone while
+# they were still live, so each of these asserts the behavior, not the label.
+
+@test "block: an override string embedded in a quoted payload is NOT a bypass" {
+ # The gate defeat: as a bare substring search, the literal text
+ # DEPLOY_GATE_OVERRIDE=x anywhere in the command authorized the bypass with no
+ # variable ever being set, including inside the exact hand-rolled ssh this gate
+ # exists to block.
+ opt_in; deploy_json
+ run_gate "$(bash_payload "ssh mutwos-mac-mini \"git commit -m 'DEPLOY_GATE_OVERRIDE=oops' && git pull && pnpm run build\"")"
+ assert_block
+}
+
+@test "block: a real deploy chained after a read-only one" {
+ # Judged per invocation. A single regex over the whole command read this as
+ # read-only on the strength of the FIRST invocation's flag.
+ opt_in; deploy_json
+ run_gate "$(bash_payload "scripts/deploy.sh --dry-run && scripts/deploy.sh --force")"
+ assert_block
+}
+
+@test "allow: a verb-prefixed word is not a mutating verb" {
+ # `pnpm run builder` / `git pull-request` are not `pnpm run build` / `git pull`.
+ opt_in; deploy_json
+ run_gate "$(bash_payload "ssh mutwos-mac-mini 'pnpm run builder'")"
+ assert_allow
+ run_gate "$(bash_payload "ssh mutwos-mac-mini 'echo git pull-request'")"
+ assert_allow
+}
+
+@test "allow: a local command chained after a read-only ssh" {
+ # The mutating verb must run ON THE HOST. Here `git pull` is local, and the ssh
+ # payload is read-only, so this is not a hand-rolled deploy.
+ opt_in; deploy_json
+ run_gate "$(bash_payload "ssh mutwos-mac-mini 'tail -50 log' ; git pull")"
+ assert_allow
+}
+
+@test "block: the quoted ssh payload survives segment splitting" {
+ # The separators inside the quotes are part of the REMOTE command. A
+ # quote-blind splitter cut there and lost the mutating verb.
+ opt_in; deploy_json
+ run_gate "$(bash_payload "ssh mutwos-mac-mini 'cd ~/nanoclaw && git pull --ff-only && pnpm run build'")"
+ assert_block
+}
+
+@test "block: ssh flags before the host still resolve the host" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "ssh -p 22 mutwos-mac-mini 'git pull'")"
+ assert_block
+}
+
+@test "allow: a listed host name appearing outside the ssh target" {
+ # The host is read from the ssh invocation's own target token, not searched for
+ # across the whole command.
+ opt_in; deploy_json
+ run_gate "$(bash_payload "ssh some-other-box 'cd /srv/mutwos-mac-mini && git pull && pnpm run build'")"
+ assert_allow
+}
+
+# =========================== arming ==========================================
+
+@test "arm: a Skill invocation of land-and-deploy allows the entrypoint" {
+ opt_in; deploy_json
+ printf '%s' "$(skill_payload "land-and-deploy")" | bash "$GATE"
+ [ -f "$ARMFILE" ]
+ run_gate "$(bash_payload "scripts/deploy.sh")"
+ assert_allow
+}
+
+@test "arm: a Skill invocation of eng:deploy allows the hand-rolled shape" {
+ opt_in; deploy_json
+ printf '%s' "$(skill_payload "eng:deploy")" | bash "$GATE"
+ [ -f "$ARMFILE" ]
+ run_gate "$(bash_payload "ssh mutwos-mac-mini 'cd ~/nanoclaw && git pull && pnpm run build'")"
+ assert_allow
+}
+
+@test "arm: a typed /eng:deploy prompt arms the session" {
+ opt_in; deploy_json
+ printf '%s' "$(prompt_payload "/eng:deploy")" | bash "$GATE"
+ [ -f "$ARMFILE" ]
+ run_gate "$(bash_payload "scripts/deploy.sh")"
+ assert_allow
+}
+
+@test "arm: prose mentioning the skill does NOT arm" {
+ opt_in; deploy_json
+ printf '%s' "$(prompt_payload "should I /eng:deploy this?")" | bash "$GATE"
+ [ ! -f "$ARMFILE" ]
+ run_gate "$(bash_payload "scripts/deploy.sh")"
+ assert_block
+}
+
+@test "arm: an unrelated skill does NOT arm" {
+ opt_in; deploy_json
+ printf '%s' "$(skill_payload "eng:cr")" | bash "$GATE"
+ [ ! -f "$ARMFILE" ]
+ run_gate "$(bash_payload "scripts/deploy.sh")"
+ assert_block
+}
+
+@test "arm: a stale arm marker does not authorize a deploy" {
+ opt_in; deploy_json
+ printf '%s\n' "$(( $(date +%s) - 99999 ))" > "$ARMFILE"
+ run_gate "$(bash_payload "scripts/deploy.sh")"
+ assert_block
+}
+
+# =========================== break-glass =====================================
+
+@test "allow: DEPLOY_GATE_OVERRIDE with a reason" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "DEPLOY_GATE_OVERRIDE='host wedged, emergency' scripts/deploy.sh")"
+ assert_allow
+}
+
+@test "block: DEPLOY_GATE_OVERRIDE with an empty reason is not an override" {
+ opt_in; deploy_json
+ run_gate "$(bash_payload "DEPLOY_GATE_OVERRIDE= scripts/deploy.sh")"
+ assert_block
+}
+
+# =========================== no merge-authority leak =========================
+
+@test "the deploy arm mints no merge clearance (no authority leak)" {
+ # The whole reason deploy-gate arms its OWN kind: a deploy-only ceremony must
+ # never be able to authorize `gh pr merge`. The merge gate keys on the
+ # land-deploy-clearance sentinel, which land-deploy-sentinel.sh mints on a Bash
+ # `gh pr merge` ONLY while a "land" arm is fresh. So the precise assertion is:
+ # after /eng:deploy, the deploy arm exists, the land arm does not, and driving
+ # the real sentinel with a merge command mints nothing.
+ #
+ # Asserting on the sentinel rather than on pr-merge-gate's verdict is
+ # deliberate: that gate fails OPEN when it cannot resolve a PR, so against a
+ # fixture repo with no remote it would return "allow" for reasons unrelated to
+ # arming, and the test would prove nothing.
+ SENTINEL_WRITER="$BATS_TEST_DIRNAME/../scripts/land-deploy-sentinel.sh"
+ GITDIR=$(git -C "$REPO" rev-parse --absolute-git-dir)
+
+ printf '%s' "$(skill_payload "eng:deploy")" | bash "$GATE"
+ [ -f "$ARMFILE" ]
+ [ ! -f "$LANDARM" ]
+
+ feed "$(bash_payload "cd $REPO && gh pr merge")" "$SENTINEL_WRITER"
+ [ "$status" -eq 0 ]
+ [ ! -f "$GITDIR/land-deploy-clearance" ]
+}
+
+@test "a land-and-deploy skill arms BOTH kinds (it does deploy)" {
+ # The converse: /land-and-deploy legitimately deploys, so it must arm the
+ # deploy kind too, or the gate would block the ceremony's own deploy step.
+ printf '%s' "$(skill_payload "land-and-deploy")" | bash "$GATE"
+ printf '%s' "$(skill_payload "land-and-deploy")" | bash "$BATS_TEST_DIRNAME/../scripts/land-deploy-sentinel.sh"
+ [ -f "$ARMFILE" ]
+ [ -f "$LANDARM" ]
+}
diff --git a/eng/hooks/tests/hooks-json.bats b/eng/hooks/tests/hooks-json.bats
index 70b5786..1b63997 100644
--- a/eng/hooks/tests/hooks-json.bats
+++ b/eng/hooks/tests/hooks-json.bats
@@ -28,12 +28,15 @@ tuples() {
expected=$(printf '%s\n' \
'PostToolUse|Bash|review-skill-stamp.sh' \
'PostToolUse|Bash|ship-watch-nudge.sh' \
+ 'PreToolUse|Bash|deploy-gate.sh' \
'PreToolUse|Bash|land-deploy-sentinel.sh' \
'PreToolUse|Bash|pr-merge-gate.sh' \
'PreToolUse|Bash|ship-gate-sentinel.sh' \
'PreToolUse|Bash|ship-pr-gate.sh' \
+ 'PreToolUse|Skill|deploy-gate.sh' \
'PreToolUse|Skill|land-deploy-sentinel.sh' \
'PreToolUse|Skill|ship-gate-sentinel.sh' \
+ 'UserPromptSubmit||deploy-gate.sh' \
'UserPromptSubmit||land-deploy-sentinel.sh' \
'UserPromptSubmit||ship-gate-sentinel.sh' \
| sort)
diff --git a/eng/hooks/tests/pr-merge-gate.bats b/eng/hooks/tests/pr-merge-gate.bats
index e4f491b..ecb0512 100644
--- a/eng/hooks/tests/pr-merge-gate.bats
+++ b/eng/hooks/tests/pr-merge-gate.bats
@@ -106,6 +106,18 @@ ld_sentinel() { printf '{"set_at_epoch":%s,"ttl_seconds":1800,"repo":"owner/name
echo "$output" | grep -q '"decision":"block"'
}
+@test "block: an EMPTY env-var prefix before gh is still caught (no stamp)" {
+ # Regression. The command-position prefix required a non-empty assignment
+ # value ([^[:space:]]+), so `FOO= gh pr merge` matched neither the bare form
+ # (no shell separator before gh) nor the env-prefixed one, and sailed through.
+ # Found while building deploy-gate.sh, which copied this same prefix; the
+ # quantifier is now `*` in every gate that carries it.
+ opt_in
+ run bash -c "printf '%s' '$(payload "cd $REPO && FOO= gh pr merge")' | bash '$GATE'"
+ [ "$status" -eq 0 ]
+ echo "$output" | grep -q '"decision":"block"'
+}
+
@test "merge gate: sibling lib resolution ignores a bogus CLAUDE_PLUGIN_ROOT (env-independence)" {
opt_in
run bash -c "cd '$REPO' && CLAUDE_PLUGIN_ROOT=/nonexistent/other-plugin printf '%s' '$(payload "gh pr merge 1 --squash")' | bash '$GATE'"
diff --git a/eng/hooks/tests/ship-pr-gate.bats b/eng/hooks/tests/ship-pr-gate.bats
index 20d074c..8d1340d 100644
--- a/eng/hooks/tests/ship-pr-gate.bats
+++ b/eng/hooks/tests/ship-pr-gate.bats
@@ -523,3 +523,15 @@ sentinel_bash_payload() { printf '{"hook_event_name":"PreToolUse","tool_name":"B
run bash -c "cd /tmp && printf '%s' '$(bash_payload "gh pr create --base main")' | bash '$GATE'"
[ "$status" -eq 0 ]; [ -z "$output" ]
}
+
+@test "block: an EMPTY env-var prefix before gh pr create is still caught" {
+ # Regression, paired with the same case in pr-merge-gate.bats. The shared
+ # command-position prefix required a non-empty assignment value, so
+ # `FOO= gh pr create` matched neither the env-prefixed branch nor the bare one
+ # and sailed past every gate carrying that matcher. Each consumer gets its own
+ # case so copy drift cannot reopen the bypass in only one of them.
+ opt_in
+ run bash -c "printf '%s' '$(bash_payload "cd $REPO && FOO= gh pr create")' | bash '$GATE'"
+ [ "$status" -eq 0 ]
+ echo "$output" | grep -q '"decision":"block"'
+}
diff --git a/eng/skills/deploy/SKILL.md b/eng/skills/deploy/SKILL.md
new file mode 100644
index 0000000..1e74d88
--- /dev/null
+++ b/eng/skills/deploy/SKILL.md
@@ -0,0 +1,161 @@
+---
+name: deploy
+description: Deploy what is already on main, as a verified ceremony. Engineer Ernie's deploy-only counterpart to /land-and-deploy, for the PR-less cases that skill structurally cannot serve: retrying after a failed deploy, recovering a wedged host, and re-running with --rebuild-base / --rederive-all. It asserts the tree is clean, on main, and synced; refuses when the branch has an open unmerged PR (that is /land-and-deploy's job); shows the delta between what the host is running and what main holds; runs the repo's declared deploy command from deploy.json; and gates the result on `devops check`, so the deploy is not "done" until the checks pass. It also arms the deploy gate, which is what makes it a sanctioned path rather than a hand-roll. Trigger when the user says "/eng:deploy", "deploy", "deploy it", "deploy main", "redeploy", "deploy to the mini", "retry the deploy", "the deploy failed, run it again", "rebuild and deploy", "push this live", or otherwise asks to deploy code that is already merged. Use /land-and-deploy instead when there is an open PR to merge first.
+---
+
+## Update check (run first)
+
+Before the skill body, check whether the gstack-extensions repo has merged updates this clone has not pulled. Silent unless an upgrade is available; never changes anything:
+
+```bash
+~/dev/gstack-extensions/bin/gstack-extensions-update-check 2>/dev/null || true
+```
+
+If there is no output, proceed straight to the skill body. If it prints `UPGRADE_AVAILABLE `, tell the user via AskUserQuestion that gstack-extensions is `` commit(s) behind `origin/main` and offer:
+
+- **Upgrade now (recommended)**: run `~/dev/gstack-extensions/bin/gstack-extensions-upgrade`, then continue. It fast-forwards `main` and refreshes the installed plugins, and refuses safely (printing why) if the clone is not on a clean `main`; relay that message and continue without upgrading if so.
+- **Skip this time**: run `~/dev/gstack-extensions/bin/gstack-extensions-update-check --snooze` to suppress the prompt for ~8h (so other skills do not re-ask this session), then continue without upgrading.
+
+Do not upgrade without asking. Ask at most once per session: if you have already prompted (or the user skipped) this session, proceed silently.
+
+# eng:deploy
+
+Deploy the code that is already on `main`, through a ceremony that ends in a verification rather than a hopeful "it started".
+
+## Why this exists
+
+`/land-and-deploy` is the merge-and-deploy ceremony, and it hard-stops when there is nothing to merge: no PR for the branch, or a PR already `MERGED` ("nothing to deploy, run `/canary`", which verifies but does not deploy). gstack has no `/deploy`. So three ordinary, PR-less situations had no sanctioned path at all:
+
+- **Retry.** The merge landed, the deploy ran, `devops check` failed. The host is now half-deployed and you must be able to run it again.
+- **Recovery.** The host rebooted, a service wedged, containers need recycling.
+- **Rebuild.** A baked layer changed and needs `--rebuild-base` / `--rederive-all`, which per-host derivation does not do automatically.
+
+Without this skill the deploy gate (`hooks/scripts/deploy-gate.sh`) would have no path for those, and its break-glass override would become the routine way to deploy, which is just the ungated state with extra typing.
+
+Invoking this skill ARMS the deploy gate for the session. That is what makes it a sanctioned path.
+
+## Step 1: Resolve the repo and its deploy contract
+
+```bash
+cd "$(git rev-parse --show-toplevel)"
+test -f deploy.json || echo "NO_DEPLOY_JSON"
+jq -r '{id:.id, host:.host, cmd:(.deploy.command // "scripts/deploy.sh"), on_merge:(.deploy.on_merge // false)}' deploy.json 2>/dev/null
+```
+
+- **No `deploy.json`**: STOP. "This repo has no `deploy.json`, so there is no declared deploy contract to run. Set one up with the `~/dev` deploy kit (`DEPLOY.md` + `deploy.json` + `scripts/deploy.sh`), then re-run." Do not improvise a deploy.
+- **`on_merge` is `true`**: STOP. "This app auto-deploys on merge to main; there is nothing to run by hand. Use `/canary ` to verify the live result." Deploying such an app manually is how you get two sources of truth.
+
+## Step 2: Refuse when there is a PR to merge
+
+This guard is what keeps this ceremony from becoming a merge-gate bypass.
+
+```bash
+gh pr view --json number,state,url 2>/dev/null || echo "NO_PR"
+```
+
+If a PR exists for the current branch and its `state` is `OPEN`: **STOP.** Say: "Branch `` has an open PR (#N). Merging it is `/land-and-deploy`'s job, and it runs the full pre-merge gauntlet before it deploys. Run `/land-and-deploy` instead. `/eng:deploy` is for deploying what is already on main."
+
+Never offer to merge it here.
+
+## Step 3: Assert the tree is deployable
+
+Deploying anything other than committed, pushed `main` means the host runs code that exists nowhere else.
+
+```bash
+git status --porcelain --untracked-files=no # TRACKED modifications only
+git status --porcelain --untracked-files=normal | grep '^??' || true # informational
+git branch --show-current
+git fetch origin main -q && git rev-parse HEAD origin/main
+```
+
+Each failure gets its own message; do not collapse them into a generic "repo not ready":
+
+- **Tracked modifications** (staged or unstaged): STOP, list the files. "Commit or stash these first. They are not going to the host, so a deploy now ships something other than what you are looking at."
+- **Untracked files only**: NOT a blocker. Mention them in one line and continue. Untracked files are in no commit, so `git pull` on the host can never receive them; refusing on them would make this skill unusable in exactly the repos that accumulate local scratch (nanoclaw's checkout carries untracked agent state as a matter of course).
+- **Not on `main`**: STOP, name the branch. "You are on ``. `/eng:deploy` deploys `main`. Switch to main, or if this branch needs to ship, `/ship` it and then `/land-and-deploy`."
+- **`HEAD` != `origin/main`**: STOP. If HEAD is behind, say so and offer `git pull --ff-only`. If HEAD is ahead, say the local commits are unpushed and must go through `/ship`.
+
+The deploy itself pulls from `origin` on the host, so `origin/main` is what actually ships. These asserts exist to catch the mismatch between that and what the operator believes they are deploying.
+
+## Step 4: Show the delta before touching anything
+
+State plainly what is about to change, so the user can catch a surprise before it ships rather than after.
+
+```bash
+WTR=~/dev/infra/where-things-run/wtr
+if ! HOST_STATE=$("$WTR" status "$(jq -r .id deploy.json)" 2>&1); then
+ RC=$?
+ printf 'wtr status FAILED (rc=%s):\n%s\n' "$RC" "$HOST_STATE" >&2
+ echo "Cannot read live host state. NOT deploying." >&2
+ exit 1
+fi
+printf '%s\n' "$HOST_STATE"
+git log --oneline -1 origin/main
+```
+
+`wtr status` live-queries the host (running commit and approximate deploy time); it is never read from the stored inventory, which carries structural data only. It is not on `PATH`, hence the absolute path.
+
+**A failed status query is a STOP, not a shrug.** If `wtr status` exits non-zero, report the captured error and stop before deploying. The whole point of this step is to know what the host is running before changing it; deploying blind is how you discover afterwards that the host was not where you thought. The one case worth offering to continue past is an explicit recovery deploy where the host is known-unreachable, and even then say so out loud and get a yes.
+
+Report it as a two-line delta, then continue:
+
+```
+host is running: ()
+main holds:
+```
+
+If they are the SAME commit, say so and ask whether to continue: a same-commit deploy is legitimate for recovery, a rebuild, or a retry, but it is worth naming out loud so a no-op deploy is a decision rather than an accident.
+
+Do not fire a readiness modal otherwise. Invoking the ceremony is the consent; stop only when something is red.
+
+## Step 5: Deploy
+
+Run the repo's declared command, forwarding any flags the user passed to this skill (`--rebuild-base`, `--rederive-all`, `--recycle`, `--force`):
+
+Build an argv array rather than interpolating a placeholder. `` is not executable shell (a bare `<` is input redirection), and quoting the whole declared command as `"$CMD"` treats `foo --bar` as one executable named `foo --bar`:
+
+```bash
+# The declared command may carry its own arguments, so split it into argv.
+read -r -a DEPLOY_CMD <<< "$(jq -r '.deploy.command // "scripts/deploy.sh"' deploy.json)"
+
+# Only the flags this skill declares are forwarded; anything else is refused
+# above rather than passed through to the deploy script.
+FLAGS=()
+for f in "$@"; do
+ case "$f" in
+ --rebuild-base|--rederive-all|--recycle|--force) FLAGS+=("$f") ;;
+ *) echo "refusing unknown flag: $f" >&2; exit 2 ;;
+ esac
+done
+
+"${DEPLOY_CMD[@]}" "${FLAGS[@]}"
+```
+
+Stream the output. If it exits non-zero, STOP and report the failing step verbatim. Do not retry automatically and do not fall back to a hand-rolled sequence: the hand-roll is exactly what skips the host's upgrade-marker stamp, and on nanoclaw that trips a version tripwire which crash-loops the host silently behind a circuit breaker.
+
+## Step 6: Gate on the checks
+
+A deploy is not done because it started. It is done when the declared checks pass.
+
+```bash
+devops check
+```
+
+Some repos' `scripts/deploy.sh` already ends with `devops check` (nanoclaw's does). Running it again is read-only and idempotent, and it means the guarantee holds for every repo regardless of what its script happens to do. If it fails, the deploy is NOT done: report which check failed and what it asserts, and treat the deploy as incomplete.
+
+## Step 7: Report
+
+State, in this order:
+
+1. What deployed: the commit, its subject, and the host.
+2. The `devops check` verdict, per check.
+3. The new live state: re-run `wtr status ` and show that the running commit now matches `origin/main`.
+
+Then state the QA posture per the QA contract: `QA_STATUS: prod_verified` plus `EVIDENCE:` when the checks passed and you verified the live state, or `QA_STATUS: blocked` plus `REASON:` when they did not.
+
+## What this skill does NOT do
+
+- It does not **merge**. An open PR sends you to `/land-and-deploy` at Step 2.
+- It does not **bump versions or write changelogs**. That is `/ship`, before the merge.
+- It does not **improvise a deploy**. No `deploy.json` means stop, not a hand-rolled ssh.
+- It does not **run QA flows**. Post-deploy browser or endpoint verification is `/canary`.
diff --git a/qa/.claude-plugin/plugin.json b/qa/.claude-plugin/plugin.json
index d2417ef..b1a149a 100644
--- a/qa/.claude-plugin/plugin.json
+++ b/qa/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "qa",
- "version": "3.6.0",
+ "version": "3.6.1",
"description": "QA Quincey, the manual-QA persona. Verifies one defined flow against the spec or mockup and records a QA posture. qa:browser drives the real running app through the user's persistent agent-browser session (click/pixel level), walks the spec, seeds and tears down tagged data via the repo's recipe, and ends with the QA_STATUS contract that satisfies the build-time Stop hook and the PR qa-gate CI. qa:headless does the same for backend features with no UI (capturing side effects). qa:qa-plan authors the two-phase QA plan and writes the approval stamp. The plugin also SHIPS Quincey's enforcement hooks (hooks/hooks.json): the QA-plan gates (presentation, build, PR) and the QA-status Stop gate, active in opted-in repos (.qa-plan-gate.json) under ~/dev. Skills: qa:browser, qa:headless, qa:qa-plan.",
"author": {
"name": "Mujtaba Badat",
diff --git a/qa/hooks/scripts/qa-plan-pr-gate.sh b/qa/hooks/scripts/qa-plan-pr-gate.sh
index f263c74..469be8f 100755
--- a/qa/hooks/scripts/qa-plan-pr-gate.sh
+++ b/qa/hooks/scripts/qa-plan-pr-gate.sh
@@ -29,7 +29,7 @@ CMD=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.command // empty')
# Match `gh pr create` at command position (line start or after a shell
# separator), tolerating env-var prefixes and an absolute/relative path to gh, so
# the phrase inside a quoted arg / heredoc body does not trip the gate.
-printf '%s' "$CMD" | grep -Eq '(^|[;&|(])[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+)*([^[:space:];&|]*/)?gh[[:space:]]+pr[[:space:]]+create([[:space:]]|$)' || exit 0
+printf '%s' "$CMD" | grep -Eq '(^|[;&|(])[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*([^[:space:];&|]*/)?gh[[:space:]]+pr[[:space:]]+create([[:space:]]|$)' || exit 0
# Resolve the repo the command targets, honoring a leading `cd ` (hooks run
# from the session cwd, not the cwd a `cd ... &&` switched into).
diff --git a/qa/hooks/tests/qa-plan-gate.bats b/qa/hooks/tests/qa-plan-gate.bats
index 5ab3c62..e2b4ef9 100644
--- a/qa/hooks/tests/qa-plan-gate.bats
+++ b/qa/hooks/tests/qa-plan-gate.bats
@@ -475,3 +475,13 @@ create_payload() { printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "
[ "$status" -eq 0 ]
[[ "$output" == *'"decision":"block"'* ]]
}
+
+@test "pr gate blocks: an EMPTY env-var prefix before gh pr create is still caught" {
+ # Regression, paired with pr-merge-gate.bats and ship-pr-gate.bats. This gate
+ # carries a copy of the same command-position matcher; the empty-value case
+ # bypassed all of them.
+ opt_in
+ run bash -c "cd '$REPO' && printf '%s' '$(create_payload "FOO= gh pr create")' | bash '$PR_GATE'"
+ [ "$status" -eq 0 ]
+ echo "$output" | grep -q '"decision":"block"'
+}
diff --git a/spec/plans/deploy-gate-and-eng-deploy-ceremony.md b/spec/plans/deploy-gate-and-eng-deploy-ceremony.md
new file mode 100644
index 0000000..daaaf4f
--- /dev/null
+++ b/spec/plans/deploy-gate-and-eng-deploy-ceremony.md
@@ -0,0 +1,171 @@
+# Gate deploys behind a ceremony, the way merges already are
+
+## Context
+
+**The problem.** Every PreToolUse guard on this machine intercepts a merge-side or ship-side surface. The complete blocking set is two literal strings: `gh pr merge` (`pr-merge-gate.sh`) and `gh pr create` (`ship-pr-gate.sh`, `qa-plan-pr-gate.sh`). Nothing guards a deploy. A grep across `gstack-extensions/*/hooks/scripts/` for `deploy.sh|kickstart|deploy-mini|launchctl|ssh` returns zero files.
+
+So `~/dev/CLAUDE.md`'s rule, "/land-and-deploy is the only way a PR reaches main and production", is one sentence covering two things. The merge half is backed by a hook that hard-refuses. The deploy half is backed by nothing.
+
+**What it cost.** On 2026-07-24 a hand-rolled ssh deploy to `mutwos-mac-mini` skipped the upgrade-marker stamp, tripped the version tripwire in `src/upgrade-state.ts`, and the host crash-looped behind a 900s circuit breaker for 16h46m (72 failed starts, first at 23:38:10, recovery 2026-07-25T16:23:54). Nothing was bypassed, because nothing was in the path. Worse, the agent was *following instructions*: mutwo's own CLAUDE.md documented that bare sequence, and repo CLAUDE.md outranks the `~/dev` rule.
+
+**Why a gate is now tractable.** Two facts discovered while planning:
+
+1. "Self-deploying agents" in `scripts/deploy-lock.sh` means container image self-mods (`src/modules/self-mod/apply.ts`), **not** host code deploys. Host deploys are harness-initiated only, so a laptop PreToolUse hook covers essentially the whole population.
+2. Every `~/dev` repo with a `deploy.json` now has a `scripts/deploy.sh` (verified: all 6). There is a real entrypoint to key on, which there was not before the deploy kit landed.
+
+**Intended outcome.** Deploying becomes as gated as merging: it happens through a ceremony that ends in `devops check`, or it does not happen.
+
+## Two decisions already made
+
+- **Matcher scope:** entrypoint **plus** the hand-rolled ssh shape. Entrypoint-only would not have blocked 07-24, which never touched `deploy.sh`.
+- **Standalone deploys:** build a `/eng:deploy` ceremony rather than rely on an env override. gstack has **no** `/deploy` command (only `land-and-deploy`, `setup-deploy`, `canary`), and `/land-and-deploy` hard-stops on an already-merged PR (`SKILL.md:945`). Retry-after-failed-deploy, recovery, and `--rebuild-base` reruns are unavoidable and have no path today. An override would become the routine path, which is the ungated state with extra typing.
+
+## Design
+
+Mirror the merge-gate architecture exactly. It is proven, and its primitives are already shared libraries.
+
+```
+/land-and-deploy ──┐
+ ├──> arms kind "deploy" ──> deploy-gate.sh allows
+/eng:deploy ──┘ (else blocks)
+```
+
+**One new script, `eng/hooks/scripts/deploy-gate.sh`, is both sentinel and gate**, wired to three events exactly like `land-deploy-sentinel.sh`:
+
+| Event | Branch | Behavior |
+|---|---|---|
+| PreToolUse / Skill | arm | skill matches `land-and-deploy` or `eng:deploy` (bare, namespaced, or path form) → `ga_arm "deploy"`, exit 0 |
+| UserPromptSubmit | arm | prompt starts with `/land-and-deploy` or `/eng:deploy` → `ga_arm "deploy"`, exit 0 |
+| PreToolUse / Bash | **gate** | evaluate and block, or slide the arm window |
+
+Self-contained by design: it arms its own kind rather than editing `land-deploy-sentinel.sh`. Kind `"deploy"` is never `"land"`, so `/eng:deploy` can never authorize a `gh pr merge`.
+
+### Gate branch order
+
+1. **Read-only escape.** `deploy.sh check`, `--status`, `--dry-run`, bare `devops check` → allow unconditionally. A retry follows a failure; diagnosing must never be gated.
+2. **Resolve target repo** via `sg_workdir_from_cmd` + `sg_dev_repo_gitdir` (`ship-gate-repo-lib.sh`). Not a `~/dev` repo → allow. Using the same resolver as the merge gate is what stops gate and sentinel disagreeing about which repo a command targets.
+3. **Opt-in marker.** No `.deploy-gate.json` at the repo root → allow. Fail-open, same posture as every sibling gate.
+4. **Deploy-shaped?** Tier 1 or tier 2 below. No match → allow.
+5. **Armed?** `ga_armed_fresh "deploy" $SESSION $TMPDIR $(date +%s) 1800` → allow, and slide the arm forward (mirrors `land-deploy-sentinel.sh`, so a long `/land-and-deploy` keeps itself armed through merge → CI wait → deploy).
+6. **Break-glass.** `DEPLOY_GATE_OVERRIDE=` in the command → allow. Genuine emergencies only; `/eng:deploy` is the routine standalone path.
+7. **Block** with a reason naming both ceremonies.
+
+### Tier 1: the entrypoint
+
+From `.deploy-gate.json`'s `deploy_commands` if set, else derived: `deploy.json`'s `.deploy.command` plus `scripts/deploy[a-z-]*\.sh`. Anchored at command position, tolerating `./` and `bash ` prefixes, reusing the anchoring approach in `pr-merge-gate.sh:44`.
+
+### Tier 2: the hand-roll catcher
+
+Fires only when the command contains `ssh ` for a host listed in the marker's `hosts` array **and** a mutating verb: `git pull`, `pnpm run build`, `npm run build`, `launchctl kickstart`, `systemctl restart`. Empty `hosts` disables tier 2, so a repo can opt into entrypoint-only.
+
+Hosts are listed explicitly in the marker rather than derived. `deploy.json` carries `"host": "mac-mini"` (a `where-things-run` id), not the ssh hostname `mutwos-mac-mini`; hard-coding the mapping would be fragile.
+
+```
+BLOCKS: ssh mutwos-mac-mini 'cd ~/nanoclaw && git pull && pnpm run build'
+ ssh mini 'launchctl kickstart -k gui/501/com.nanoclaw'
+ALLOWS: ssh mutwos-mac-mini 'launchctl list | grep nanoclaw'
+ ssh mutwos-mac-mini 'tail -50 ~/nanoclaw/logs/nanoclaw.log'
+```
+
+### `/eng:deploy`
+
+New skill at `eng/skills/deploy/SKILL.md`. Deploy-only ceremony for retry, recovery, and rebuild.
+
+1. Standard eng-plugin update-check preamble (copy from `eng/skills/coderabbit-config/SKILL.md`).
+2. Assert the repo has a `deploy.json`; refuse otherwise.
+3. **Refuse if the current branch has an open unmerged PR.** That is `/land-and-deploy`'s job. This guard is what keeps the new ceremony from becoming a merge-gate bypass.
+4. Assert clean tree, on `main`, synced with `origin/main`.
+5. Show the delta: running version via `wtr status ` (per `~/dev/CLAUDE.md`, deploy state is live-queried, never read from the inventory) vs `main`.
+6. Run `deploy.json`'s `.deploy.command`, passing flags through (`--rebuild-base`, `--rederive-all`, `--recycle`, `--force`).
+7. Assert `devops check` passed. mutwo's `deploy.sh` already runs it; assert for repos whose script does not.
+8. Post-deploy liveness check, then report.
+
+No readiness modal. Per `~/dev/CLAUDE.md`, invoking the ceremony is the consent; stop only when something is red.
+
+## Files
+
+**New (`~/dev/tooling/gstack-extensions`)**
+- `eng/hooks/scripts/deploy-gate.sh`
+- `eng/hooks/tests/deploy-gate.bats` — mirror `eng/hooks/tests/pr-merge-gate.bats`, whose `setup()` already builds a temp `~/dev` repo and whose `payload()` / `opt_in()` helpers transfer directly
+- `eng/skills/deploy/SKILL.md`
+
+**Modified**
+- `eng/hooks/hooks.json` — three tuples: `PreToolUse|Bash`, `PreToolUse|Skill`, `UserPromptSubmit`
+- `eng/hooks/tests/hooks-json.bats` — golden tuple set at line ~27 grows by three; it also has an orphan check asserting every script in `hooks/scripts/` is wired
+- `eng/.claude-plugin/plugin.json` — version bump plus description
+- `~/dev/gated-repos.json` — add `.deploy-gate.json` for `~/dev/tooling/mutwo` only. `arm-gates.sh` iterates registry keys generically, so a new marker filename needs **no** code change there
+- `~/dev/CLAUDE.md` — the "only paths" section must name `/eng:deploy`, or the doc contradicts the gate *(CLAUDE.md approval gate)*
+- `~/dev/tooling/mutwo/CLAUDE.md` — point the retry case at `/eng:deploy` *(CLAUDE.md approval gate)*
+
+**Companion fix (separate, straight to main, no PR)**
+- `~/dev/infra/where-things-run/annotations.json:220` — the `mutwo` `deploy_cmd` still spells out the unstamped hand-rolled sequence that caused the outage. `#227` fixed the prose in CLAUDE.md and left this. Repoint at `scripts/deploy.sh`, run `wtr build`. Same stale shape exists on the `sms-hero-backend` entry.
+
+## Reused, not rebuilt
+
+| Primitive | Path |
+|---|---|
+| `ga_arm` / `ga_armed_fresh` / `ga_arm_file` | `eng/hooks/scripts/ship-gate-arm-lib.sh` |
+| `sg_workdir_from_cmd` / `sg_dev_repo_gitdir` | `eng/hooks/scripts/ship-gate-repo-lib.sh` |
+| Marker opt-in + registry + drift check | `~/dev/gated-repos.json`, `~/.claude/scripts/arm-gates.sh` |
+| Block/allow output protocol | `pr-merge-gate.sh` header comment |
+| bats harness shape | `eng/hooks/tests/pr-merge-gate.bats` |
+
+## Known limits, stated honestly
+
+- **The arm slides.** As with the merge gate, an armed session working in `~/dev` keeps the window alive (1800s idle budget). A `/land-and-deploy` at 10am plus continuous activity means a hand-rolled deploy at 5pm passes. Sliding is required, since only Skill and prompt events arm and a real run outlives a fixed window. This is an accident-guard, matching `pr-merge-gate.sh`'s own framing, not a sandbox.
+- **A plain terminal is invisible.** A human ssh'ing outside Claude Code is unreachable by any PreToolUse hook.
+- **A gate prevents; it does not detect.** The 07-24 loss was 16h46m of *silence*, and this gate buys zero observability. The tripwire already detected the bad state correctly and failed closed. `scripts/checks/nanoclaw.sh` checks 1 and 2 would have caught it within seconds, but run only post-deploy. Wiring that probe into `mini-health.sh` (10-min cadence, existing rate-limited alert path, precedent at its check 8) turns 16h46m into ~10 minutes. **Recommend as a follow-up sidequest**; it is complementary, not covered here.
+
+## Verification
+
+Targeted only. Per the machine rule, no full suites locally; CI covers those.
+
+```bash
+cd ~/dev/tooling/gstack-extensions
+bats eng/hooks/tests/deploy-gate.bats
+bats eng/hooks/tests/hooks-json.bats
+```
+
+`deploy-gate.bats` must cover, mirroring `pr-merge-gate.bats`'s allow/block pairs:
+
+- allow: non-deploy command; deploy command in a non-`~/dev` repo; `~/dev` repo with no marker
+- allow: `scripts/deploy.sh check` and `--status` even when unarmed
+- allow: `ssh 'launchctl list'` (read-only verb)
+- block: `scripts/deploy.sh` unarmed in an opted-in repo
+- block: `ssh 'cd ~/nanoclaw && git pull && pnpm run build'` unarmed
+- block: `ssh 'launchctl kickstart -k gui/501/com.nanoclaw'` unarmed
+- allow: each of the above once a `"deploy"` arm marker is fresh
+- allow: with `DEPLOY_GATE_OVERRIDE="reason"`
+- allow: tier 2 disabled when `hosts` is empty
+
+Manual end-to-end, in order:
+
+1. In mutwo with the marker armed, run `scripts/deploy.sh` cold. Expect a block naming both ceremonies.
+2. Invoke `/eng:deploy`. Expect it to arm, show the delta (mini 2.1.54.0 vs main 2.1.54.1), deploy, and pass `devops check`.
+3. Confirm liveness the way the incident taught: `launchctl list | grep com.nanoclaw` for a live pid with `lastexit=0`, and `tail -20 logs/nanoclaw.error.log` for tripwire or breaker lines.
+
+The pending 2.1.54.1 mini deploy is the natural first real exercise of `/eng:deploy`.
+
+## Landing
+
+`gstack-extensions` is gated (`.qa-plan-gate.json`, `.ship-gate.json`, `.merge-clearance.json`), and the `plugin.json` version bump counts as a source edit, so the QA-plan build gate fires first.
+
+`/qa:plan` → `/ship` → `/eng:cr` → `/land-and-deploy`. Assign to `mujtaba3B`.
+
+Once approved, this plan file moves to `~/dev/tooling/gstack-extensions/spec/plans/deploy-gate-and-eng-deploy-ceremony.md` and the `~/.claude/plans/` copy is deleted, per the `~/dev` plan-file convention.
+
+---
+
+## What changed during implementation
+
+Recorded because each was found by running the thing rather than reasoning about it.
+
+1. **An empty env assignment bypassed every gate.** The command-position prefix these matchers share required a non-empty value (`[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+`), so `FOO= gh pr merge` matched neither the bare form (no shell separator before `gh`) nor the env-prefixed one, and sailed through. Found via a deploy-gate test, then grepped: the same prefix sat in `pr-merge-gate.sh`, `ship-pr-gate.sh`, `ship-watch-nudge-lib.sh`, and `qa-plan-pr-gate.sh`. The quantifier is now `*` in all five, with a regression test in `pr-merge-gate.bats`. This is why the `qa` plugin also gets a version bump (3.6.1), which the plan did not anticipate.
+
+2. **`/eng:deploy` would have refused to deploy the repo it was built for.** Step 3 originally blocked on any dirty tree. nanoclaw's checkout carries untracked agent state as a matter of course, and untracked files are in no commit, so `git pull` on the host can never receive them. The assert now blocks on TRACKED modifications only (`--untracked-files=no`) and reports untracked files informationally.
+
+3. **The no-merge-authority test was rewritten.** Asserting that `pr-merge-gate.sh` blocks after a deploy-only arm proved nothing: that gate fails OPEN when it cannot resolve a PR, so a fixture repo with no remote returns "allow" for unrelated reasons. It now asserts the mechanism directly, that a deploy arm causes `land-deploy-sentinel.sh` to mint no clearance sentinel.
+
+4. **`wtr` is not on `PATH`.** `/eng:deploy` calls it at `~/dev/infra/where-things-run/wtr`.
+
+5. **The new marker needed a global-gitignore entry.** `.deploy-gate.json` joined its three siblings in `~/.config/git/ignore`; without it the marker would have shown up as untracked in every opted-in repo.