feat(design-gate): ask for 21st login and wait instead of skipping

The design gate checked the 21st CLI with command -v only, so an
installed-but-signed-out CLI read as READY and every 21st step failed
downstream. tool_active now probes 21st whoami through a three-state
function: signed in (TWENTYFIRST_TOKEN or API_KEY_21ST set, or 'Logged in
as'), signed out (exact 'Not logged in'), unknown (rc != 0, timeout,
unexpected output). Signed out is a new verdict, SIGN-IN REQUIRED, exit
12: design-gate.md tells the orchestrator to ask the user to run
! 21st login, end the turn, re-run the gate on their reply, and to skip
21st only on an explicit 'proceed without 21st', never silently. Unknown
surfaces as exit 11 with the whoami diagnostic and a CLI-runtime remedy,
so a node/PATH failure can never loop on a sign-in prompt. INCOMPLETE
still wins. Hermetic suite lib/tests/design-tool-gate.test.sh (stub CLI,
fixture repo through DESIGN_GATE_REPO_OVERRIDE) covers every state.
This commit is contained in:
bastien
2026-09-28 12:51:55 +02:00
parent 729d71546f
commit bd3e525bb3
6 changed files with 307 additions and 32 deletions
+14
View File
@@ -7,6 +7,20 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Design gate asks the user to sign in to 21st instead of skipping it**:
`lib/design-tool-gate.sh` adds a three-state 21st auth predicate
(`twentyfirst_auth_state`, honors `TWENTYFIRST_TOKEN`/`API_KEY_21ST` or a
local `21st whoami` read). Signed out now trips a new `SIGN-IN REQUIRED`
state (exit 12) instead of silently proceeding or reporting a plain
INCOMPLETE. The agent asks the user to run `! 21st login` in-session and
waits, re-running the gate on reply; an explicit "proceed without 21st"
opt-out is honored and never re-asked. A `whoami` answer that can't be
classified (unexpected line, nonzero rc, timeout) surfaces as unverified
with the raw diagnostic (`21st (whoami: rc=… …)`), never guessed as
signed-in or signed-out. `lib/design-gate.md` and the
`skills/feat`/`skills/bugfix` STEP 0.5 design-gate bullets document the
new branch. Hermetic suite `lib/tests/design-tool-gate.test.sh`, 7 named
cases.
- **`make doctor` checks the vendored externals** — new - **`make doctor` checks the vendored externals** — new
`lib/doctor-vendored.sh` (`check_vendored_skills`), wired into doctor.sh `lib/doctor-vendored.sh` (`check_vendored_skills`), wired into doctor.sh
after the gstack section: every curl-pinned entry of plugins.lock.json after the gstack section: every curl-pinned entry of plugins.lock.json
+30 -6
View File
@@ -61,7 +61,7 @@ skill symlink, `claude plugin list`, `claude mcp list`, `command -v`. It never
reads `disabledMcpServers` (unreliable for bi-modal servers like context7). reads `disabledMcpServers` (unreliable for bi-modal servers like context7).
The core set lives in `design.profile`, not in the script or here — single source. The core set lives in `design.profile`, not in the script or here — single source.
Exit codes: `0` = ready · `11` = ready-but-unverified (proceed, but surface it) · `10` = incomplete (gate trips) · `2` = error. Exit codes: `0` = ready · `11` = ready-but-unverified (proceed, but surface it) · `10` = incomplete (gate trips) · `12` = sign-in required (21st installed, signed out) · `2` = error.
### 3. Branch on the result ### 3. Branch on the result
@@ -82,18 +82,39 @@ Exit codes: `0` = ready · `11` = ready-but-unverified (proceed, but surface it)
but the CLI they shell out to is a global npm install: tell the user to run but the CLI they shell out to is a global npm install: tell the user to run
`npm i -g @21st-dev/cli` then `21st login` (no API key, no MCP). `npm i -g @21st-dev/cli` then `21st login` (no API key, no MCP).
- Do NOT hand-activate individual tools. The profile is the unit of activation. - Do NOT hand-activate individual tools. The profile is the unit of activation.
- **12 / `SIGN-IN REQUIRED`** → STOP. The 21st CLI is installed but `21st
whoami` reports signed out. Relay the script's block, then ask the user to
run `! 21st login` (the `!` prefix runs it in this session, browser flow,
saves a local token) — or run `21st login` in any terminal on this
machine, then reply (the token is a local file; any terminal works, `!`
in-session is just the convenient form). END THE TURN and wait. On the
user's reply, re-run `design-tool-gate.sh` before any 21st step: `READY` →
continue; still `12` → ask again once, then offer the opt-out. Explicit
refusal — the user answers "proceed without 21st" (or words to that
effect) → say visibly `21st skipped for this run at your request` and
continue with the rest of the toolchain, 21st steps left out; after that,
a later `12` in the same run is reported in one line, never re-asked.
Never skip silently ("not logged in, so we don't use it" is the failure
this branch closes). Never run `21st login` yourself — it opens a browser
and needs the human. `TWENTYFIRST_TOKEN` is a shell-profile setting
followed by a session restart, never an in-session `export` (tool calls
don't share a shell, and a secret doesn't belong in the transcript).
- **11 / `READY BUT UNVERIFIED`** → `claude` was unreachable, so the design - **11 / `READY BUT UNVERIFIED`** → `claude` was unreachable, so the design
plugin (ui-ux-pro-max) could NOT be checked. Do NOT report a plain "ready": plugin (ui-ux-pro-max) could NOT be checked. Do NOT report a plain "ready":
proceed only after telling the user that N tool(s) went unverified and having proceed only after telling the user that N tool(s) went unverified and having
them confirm with `claude plugin list`. Fail-visible, not fail-silent. them confirm with `claude plugin list`. Fail-visible, not fail-silent. A
`21st (whoami: rc=… …)` entry in this block means the CLI itself could not
answer (a runtime/PATH problem, e.g. node under nvm) — the remedy is the
diagnostic the script prints, never a sign-in prompt; relay its own line.
### 4. Animation library — suggest-only (fires only on a real motion signal) ### 4. Animation library — suggest-only (fires only on a real motion signal)
Orthogonal to the toolchain check above: §2-3 are about Claude's design TOOLS; Orthogonal to the toolchain check above: §2-3 are about Claude's design TOOLS;
this is about the PROJECT's runtime dep. Evaluate it only once the toolchain is this is about the PROJECT's runtime dep. Evaluate it only once the toolchain is
resolved and you're actually proceeding with the build (READY, or after the user resolved and you're actually proceeding with the build (READY, after the user
ran `/profile design`). Never on the INCOMPLETE stop path — that path has one ran `/profile design`, or after the sign-in re-run returns READY). Never on
action only (`/profile design`); don't stack an optional note on it. the INCOMPLETE stop path — that path has one action only (`/profile
design`); don't stack an optional note on it.
**Fires only when ALL THREE hold** — drop any one → no suggestion, stay silent: **Fires only when ALL THREE hold** — drop any one → no suggestion, stay silent:
@@ -182,11 +203,14 @@ remedy is always `/profile <that>` — a profile, never a lone tool.
the profile system is the single source of truth for what's active. the profile system is the single source of truth for what's active.
- the `21st` CLI is REQUIRED (it trips the gate) and `/profile design` cannot - the `21st` CLI is REQUIRED (it trips the gate) and `/profile design` cannot
install it — the gate names the two commands; surface them to the user. install it — the gate names the two commands; surface them to the user.
Signed out → exit 12: ask `! 21st login`, wait, re-run; explicit opt-out
only, never a silent skip.
- The design-core set (what trips the gate) is declared in `design.profile` on - The design-core set (what trips the gate) is declared in `design.profile` on
the `# GATE-BLOCK:` line(s) — edit there to add/remove a blocking design tool, the `# GATE-BLOCK:` line(s) — edit there to add/remove a blocking design tool,
not in the script. not in the script.
- The state check shells out to `claude` (plugin/mcp list): a few seconds. - The state check shells out to `claude` (plugin/mcp list): a few seconds.
Trivial / non-design tasks skip it entirely (no signal, or trivial tier). Trivial / non-design tasks skip it entirely (no signal, or trivial tier).
- `design-tool-gate.sh`'s per-type state checks MIRROR - `design-tool-gate.sh`'s per-type state checks MIRROR
`profile.sh:skill_status()` — change one, sync the other. `profile.sh:skill_status()` — change one, sync the other, except the 21st
auth state: gate-only, no skill_status counterpart.
- Do NOT run this gate on pure backend/API/CLI tasks (no signals = no gate). - Do NOT run this gate on pure backend/API/CLI tasks (no signals = no gate).
+101 -22
View File
@@ -17,7 +17,8 @@
# -> fall back to every skill/plugin/mcp entry (coarse). # -> fall back to every skill/plugin/mcp entry (coarse).
# #
# State (active or not) is checked per channel, by type. These per-type # State (active or not) is checked per channel, by type. These per-type
# checks MIRROR profile.sh:skill_status() — change one, sync the other. # checks MIRROR profile.sh:skill_status() — change one, sync the other,
# except the 21st auth state: gate-only, no skill_status counterpart.
# #
# type channel class # type channel class
# gstack|external|personal skill symlink in skills/ blocking # gstack|external|personal skill symlink in skills/ blocking
@@ -31,18 +32,21 @@
# it, and the remedy is `/profile design` + a manual step. # it, and the remedy is `/profile design` + a manual step.
# This is where the `21st` CLI lands: required, never # This is where the `21st` CLI lands: required, never
# silent (npm i -g @21st-dev/cli, then 21st login). # silent (npm i -g @21st-dev/cli, then 21st login).
# 21st's sign-in state is three-valued: in (active),
# out (exit 12, ask to sign in), unknown (exit 11).
# Both classes trip the gate. Tools NOT on the GATE-BLOCK allowlist are # Both classes trip the gate. Tools NOT on the GATE-BLOCK allowlist are
# ignored entirely (browser/plan/shotgun tooling, graphify). # ignored entirely (browser/plan/shotgun tooling, graphify).
# #
# disabledMcpServers is NEVER read — unreliable for bi-modal servers # disabledMcpServers is NEVER read — unreliable for bi-modal servers
# (context7 can appear there yet be active via another channel). # (context7 can appear there yet be active via another channel).
# #
# Exit: 0 = ready · 11 = ready-but-unverified (proceed, say so) · 10 = incomplete (trips) · 2 = error. # Exit: 0 = ready · 11 = ready-but-unverified (proceed, say so) ·
# 10 = incomplete (trips) · 12 = sign-in required (21st) · 2 = error.
# Usage: design-tool-gate.sh [profile] (default profile: design) # Usage: design-tool-gate.sh [profile] (default profile: design)
# ============================================================ # ============================================================
set -euo pipefail set -euo pipefail
REPO="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO="${DESIGN_GATE_REPO_OVERRIDE:-$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
PROFILE_SH="${DESIGN_GATE_PROFILE_SH:-$REPO/lib/profile.sh}" PROFILE_SH="${DESIGN_GATE_PROFILE_SH:-$REPO/lib/profile.sh}"
CLAUDE_BIN="${CLAUDE_BIN:-claude}" CLAUDE_BIN="${CLAUDE_BIN:-claude}"
PROFILES_DIR="$REPO/lib/profiles" PROFILES_DIR="$REPO/lib/profiles"
@@ -104,6 +108,35 @@ ensure_21st_on_path() {
} }
ensure_21st_on_path ensure_21st_on_path
# 21st's sign-in state, three-valued. `whoami` is a local token read (no
# network), rc 0 either way — so rc alone can't tell signed-in from signed-
# out; the FIRST LINE of stdout does. A token env, when already exported by
# the user's shell profile, wins without a CLI call (never requested here:
# tool calls don't share a shell, and a secret doesn't belong in a comment
# or the transcript). `timeout 15` bounds a hung CLI; stdin is closed so a
# CLI that reads stdin can't eat the gate's own `read` loop; stderr never
# enters the match (stdout only). Echoes: in | out | unknown:<diagnostic>.
twentyfirst_auth_state() {
if [ -n "${TWENTYFIRST_TOKEN:-}" ] || [ -n "${API_KEY_21ST:-}" ]; then
echo in
return
fi
local line rc
if line="$(timeout 15 21st whoami 2>/dev/null </dev/null | head -1)"; then
rc=0
else
rc=$?
fi
if [ "$rc" -eq 0 ]; then
case "$line" in
"Logged in as "*) echo in; return ;;
"Not logged in"*) echo out; return ;;
esac
fi
[ -n "$line" ] || line="no output"
echo "unknown:whoami: rc=$rc ${line:0:60}"
}
# Gate scope: the "# GATE-BLOCK:" allowlist (one or more lines, concatenated). # Gate scope: the "# GATE-BLOCK:" allowlist (one or more lines, concatenated).
# Empty => fall back to "every gate-relevant entry is in scope" (coarse). # Empty => fall back to "every gate-relevant entry is in scope" (coarse).
core_set="$(grep '^# GATE-BLOCK:' "$PROFILE_FILE" 2>/dev/null \ core_set="$(grep '^# GATE-BLOCK:' "$PROFILE_FILE" 2>/dev/null \
@@ -115,8 +148,10 @@ in_scope() {
case " $core_set " in *" $1 "*) return 0 ;; *) return 1 ;; esac case " $core_set " in *" $1 "*) return 0 ;; *) return 1 ;; esac
} }
# State of one tool, by type. Mirrors profile.sh:skill_status() — keep in sync. # State of one tool, by type. Mirrors profile.sh:skill_status() — keep in
# Echoes: active | inactive | unknown (unknown = can't verify, claude absent) # sync, except the 21st auth state (gate-only, no skill_status counterpart).
# Echoes: active | inactive | unknown (can't verify, claude absent) |
# signedout | unknown:<diagnostic> (last two: 21st CLI only)
tool_active() { tool_active() {
local name="$1" type="$2" local name="$1" type="$2"
case "$type" in case "$type" in
@@ -135,7 +170,15 @@ tool_active() {
if "$CLAUDE_BIN" mcp list 2>/dev/null | grep -q "^${name}"; then echo active; else echo inactive; fi if "$CLAUDE_BIN" mcp list 2>/dev/null | grep -q "^${name}"; then echo active; else echo inactive; fi
;; ;;
cli) cli)
if command -v "$name" >/dev/null 2>&1; then echo active; else echo inactive; fi command -v "$name" >/dev/null 2>&1 || { echo inactive; return; }
[ "$name" = "21st" ] || { echo active; return; }
local auth
auth="$(twentyfirst_auth_state)"
case "$auth" in
in) echo active ;;
out) echo signedout ;;
unknown:*) echo "$auth" ;;
esac
;; ;;
*) echo inactive ;; *) echo inactive ;;
esac esac
@@ -150,12 +193,17 @@ plain="$("$PROFILE_SH" show "$PROFILE" --plain 2>/dev/null)" \
blocking=() # inactive, /profile design activates it (skill/plugin) blocking=() # inactive, /profile design activates it (skill/plugin)
manual=() # inactive, required but needs a manual step (mcp key / cli install) manual=() # inactive, required but needs a manual step (mcp key / cli install)
unverified=() # can't check (claude CLI absent) unverified=() # can't check (claude CLI absent)
signedout=() # 21st CLI installed, not signed in
unverified_cli=() # 21st CLI: whoami answered something unexpected
while IFS=$'\t' read -r type name; do while IFS=$'\t' read -r type name; do
[ -n "$type" ] || continue [ -n "$type" ] || continue
in_scope "$name" || continue # ignore non-core tooling (browser, plan-*, graphify) in_scope "$name" || continue # ignore non-core tooling (browser, plan-*, graphify)
case "$(tool_active "$name" "$type")" in state="$(tool_active "$name" "$type")"
active) ;; case "$state" in
unknown) unverified+=("$name") ;; active) ;;
signedout) signedout+=("$name") ;;
unknown) unverified+=("$name") ;;
unknown:*) unverified_cli+=("$name (${state#unknown:})") ;;
*) *)
case "$type" in case "$type" in
gstack|external|personal|plugin) blocking+=("$name") ;; gstack|external|personal|plugin) blocking+=("$name") ;;
@@ -165,11 +213,31 @@ while IFS=$'\t' read -r type name; do
esac esac
done <<< "$plain" done <<< "$plain"
# Verdict — three outcomes: # print_unverified — the "also unverified" lines shared by the 10, 11 and 12
# blocks: a claude-unreachable tool keeps its existing remedy; a 21st CLI
# that answered whoami with something unexpected gets its own — the two are
# never merged, so no block blames 21st for a claude problem or vice versa.
print_unverified() {
if [ "${#unverified[@]}" -gt 0 ]; then
echo " also unverified (claude CLI unreachable): ${unverified[*]}"
fi
local entry name diag
for entry in "${unverified_cli[@]}"; do
name="${entry%% (*}"
diag="${entry#*\(}"; diag="${diag%\)}"
echo " $name could not answer: $diag —" \
"a CLI runtime/PATH problem (node under nvm?)," \
"not a sign-in problem; fix it, then re-run"
done
}
# Verdict — four outcomes, checked in order:
# blocking/manual non-empty -> INCOMPLETE (exit 10): the gate trips. # blocking/manual non-empty -> INCOMPLETE (exit 10): the gate trips.
# only unverified non-empty -> READY BUT UNVERIFIED (exit 11): fail-VISIBLE. # else signedout non-empty -> SIGN-IN REQUIRED (exit 12): ask the user to
# claude was unreachable, so the plugin channel (ui-ux-pro-max) could not # run `21st login`, end the turn, wait, re-run — never a silent skip.
# be checked. Never pass this as a silent READY — proceed, but say so. # else unverified/unverified_cli non-empty -> READY BUT UNVERIFIED (exit
# 11): fail-VISIBLE. claude unreachable and/or 21st couldn't answer
# whoami — never pass either as a silent READY.
# nothing pending -> READY (exit 0). # nothing pending -> READY (exit 0).
if [ "${#blocking[@]}" -gt 0 ] || [ "${#manual[@]}" -gt 0 ]; then if [ "${#blocking[@]}" -gt 0 ] || [ "${#manual[@]}" -gt 0 ]; then
echo "design toolchain: INCOMPLETE" echo "design toolchain: INCOMPLETE"
@@ -182,19 +250,30 @@ if [ "${#blocking[@]}" -gt 0 ] || [ "${#manual[@]}" -gt 0 ]; then
*" 21st "*) echo " 21st needs the CLI: npm i -g @21st-dev/cli then 21st login" ;; *" 21st "*) echo " 21st needs the CLI: npm i -g @21st-dev/cli then 21st login" ;;
esac esac
fi fi
if [ "${#unverified[@]}" -gt 0 ]; then print_unverified
echo " also unverified (claude CLI unreachable): ${unverified[*]}"
fi
echo " → run: /profile $PROFILE" echo " → run: /profile $PROFILE"
exit 10 exit 10
fi fi
if [ "${#unverified[@]}" -gt 0 ]; then if [ "${#signedout[@]}" -gt 0 ]; then
echo "design toolchain: READY BUT UNVERIFIED — ${#unverified[@]} tool(s) not checked" echo "design toolchain: SIGN-IN REQUIRED — 21st CLI installed, not signed in"
echo " unverified (claude CLI unreachable): ${unverified[*]}" echo " ask the user to run in this session:" \
echo " the gate could NOT confirm the design plugin (ui-ux-pro-max) is" " ! 21st login" \
echo " active. Proceed only after checking manually:" " (browser flow, saves a local token)"
echo " claude plugin list" echo " then re-run this gate before any 21st step — never skip 21st silently"
print_unverified
exit 12
fi
if [ "${#unverified[@]}" -gt 0 ] || [ "${#unverified_cli[@]}" -gt 0 ]; then
echo "design toolchain: READY BUT UNVERIFIED —" \
"$(( ${#unverified[@]} + ${#unverified_cli[@]} )) tool(s) not checked"
print_unverified
if [ "${#unverified[@]}" -gt 0 ]; then
echo " the gate could NOT confirm the design plugin (ui-ux-pro-max) is"
echo " active. Proceed only after checking manually:"
echo " claude plugin list"
fi
exit 11 exit 11
fi fi
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# lib/tests/design-tool-gate.test.sh — hermetic suite for the 21st sign-in
# state added to lib/design-tool-gate.sh (contract 2026-09-28-21st-signin-
# gate-1215): a fake `21st` CLI on a fixture PATH drives every whoami answer
# (signed in / signed out / garbage / nonzero rc) through the real gate
# script, with HOME and PATH redirected into the fixture so the machine's
# real CLI is never reachable. A loud precondition proves that redirection
# actually holds before any case runs.
set -u
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
GATE="$ROOT/lib/design-tool-gate.sh"
PASS=0; FAIL=0
ok() { echo "PASS $1"; PASS=$((PASS + 1)); }
bad() { echo "FAIL $1 — $2"; FAIL=$((FAIL + 1)); }
# Precondition, loud: the sanitized PATH below and ensure_21st_on_path()'s
# own probes must never resolve a REAL 21st — else CLI_ABSENT_10 (and every
# other case) would silently exercise this machine's CLI instead of the stub.
if PATH=/usr/bin:/bin command -v 21st >/dev/null 2>&1 \
|| [ -e /usr/local/bin/21st ]; then
echo "FAIL precondition: system-wide 21st present," \
"CLI_ABSENT case not hermetic"
FAIL=$((FAIL + 1))
echo "PASS=$PASS FAIL=$FAIL"
exit 1
fi
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/repo/lib/profiles" "$WORK/repo/skills" "$WORK/bin" "$WORK/home"
# GATE-BLOCK allowlist: 21st (cli) always, ghost-skill (external) only used
# by INCOMPLETE_WINS to prove a blocking miss still trips the gate.
cat > "$WORK/repo/lib/profiles/design.profile" <<'EOF'
# GATE-BLOCK: 21st ghost-skill
21st cli
ghost-skill external
EOF
# Fake profile.sh: `show design --plain` cats whatever the case wrote to
# plain.txt. $WORK is read from the environment at run time (exported
# below), never baked in here — the heredoc is quoted on purpose.
cat > "$WORK/repo/lib/profile.sh" <<'EOF'
#!/usr/bin/env bash
[ "$1" = show ] && [ "$3" = --plain ] && { cat "$WORK/plain.txt"; exit 0; }
exit 1
EOF
chmod +x "$WORK/repo/lib/profile.sh"
export WORK
# Fake 21st CLI: `whoami` answers per $FAKE_21ST_MODE, the real CLI's exact
# sentences for in/out (proven by STUB_CONTROL below), a garbage line at
# rc 0, or the signed-out sentence at a nonzero rc (proves rc wins).
cat > "$WORK/bin/21st" <<'EOF'
#!/usr/bin/env bash
[ "${1:-}" = whoami ] || exit 1
signedout='Not logged in. Run `21st login`, or set TWENTYFIRST_TOKEN.'
case "${FAKE_21ST_MODE:-in}" in
in) echo "Logged in as tester (saved locally)."; exit 0 ;;
out) echo "$signedout"; exit 0 ;;
garbage) echo "Something unexpected"; exit 0 ;;
fail) echo "$signedout"; exit 3 ;;
esac
EOF
chmod +x "$WORK/bin/21st"
# gate_run <FAKE_21ST_MODE> <plain, \t and \n escapes> [PATH override]
# -> sets $GATE_OUT / $GATE_RC. TWENTYFIRST_TOKEN and API_KEY_21ST are
# always unset here; TOKEN_READY below sets them explicitly instead.
gate_run() {
local mode="$1" plain="$2" gate_path="${3:-$WORK/bin:/usr/bin:/bin}"
printf '%b\n' "$plain" > "$WORK/plain.txt"
GATE_OUT="$(env -u TWENTYFIRST_TOKEN -u API_KEY_21ST \
HOME="$WORK/home" PATH="$gate_path" \
DESIGN_GATE_REPO_OVERRIDE="$WORK/repo" \
DESIGN_GATE_PROFILE_SH="$WORK/repo/lib/profile.sh" \
FAKE_21ST_MODE="$mode" bash "$GATE" 2>&1)"
GATE_RC=$?
}
# ── stub positive control ────────────────────────────────────────────────
if FAKE_21ST_MODE=in "$WORK/bin/21st" whoami | grep -q '^Logged in as ' \
&& FAKE_21ST_MODE=out "$WORK/bin/21st" whoami | grep -q '^Not logged in'
then ok STUB_CONTROL; else bad STUB_CONTROL "stub sentences wrong"; fi
# ── SIGNED_IN_READY: whoami says logged in -> exit 0, READY ────────────────
gate_run in 'cli\t21st'
if [ "$GATE_RC" -eq 0 ] && echo "$GATE_OUT" | grep -q 'toolchain: READY'; then
ok SIGNED_IN_READY
else
bad SIGNED_IN_READY "rc=$GATE_RC out=$GATE_OUT"
fi
# ── SIGNED_OUT_12: whoami says not logged in -> exit 12, ask to sign in ────
gate_run out 'cli\t21st'
if [ "$GATE_RC" -eq 12 ] && echo "$GATE_OUT" | grep -q 'SIGN-IN REQUIRED' \
&& echo "$GATE_OUT" | grep -q '21st login' \
&& ! echo "$GATE_OUT" | grep -q 'INCOMPLETE'; then
ok SIGNED_OUT_12
else
bad SIGNED_OUT_12 "rc=$GATE_RC out=$GATE_OUT"
fi
# ── TOKEN_READY: a token env wins even while whoami reports signed out ─────
printf 'cli\t21st\n' > "$WORK/plain.txt"
out_t="$(env HOME="$WORK/home" PATH="$WORK/bin:/usr/bin:/bin" \
DESIGN_GATE_REPO_OVERRIDE="$WORK/repo" \
DESIGN_GATE_PROFILE_SH="$WORK/repo/lib/profile.sh" \
FAKE_21ST_MODE=out TWENTYFIRST_TOKEN=x bash "$GATE" 2>&1)"; rc_t=$?
out_k="$(env HOME="$WORK/home" PATH="$WORK/bin:/usr/bin:/bin" \
DESIGN_GATE_REPO_OVERRIDE="$WORK/repo" \
DESIGN_GATE_PROFILE_SH="$WORK/repo/lib/profile.sh" \
FAKE_21ST_MODE=out API_KEY_21ST=x bash "$GATE" 2>&1)"; rc_k=$?
if [ "$rc_t" -eq 0 ] && [ "$rc_k" -eq 0 ]; then
ok TOKEN_READY
else
bad TOKEN_READY "TOKEN rc=$rc_t ($out_t) | API_KEY rc=$rc_k ($out_k)"
fi
# ── CLI_ABSENT_10: 21st off PATH -> exit 10, INCOMPLETE (not sign-in) ──────
gate_run in 'cli\t21st' /usr/bin:/bin
if [ "$GATE_RC" -eq 10 ] && echo "$GATE_OUT" | grep -q 'INCOMPLETE'; then
ok CLI_ABSENT_10
else
bad CLI_ABSENT_10 "rc=$GATE_RC out=$GATE_OUT"
fi
# ── INCOMPLETE_WINS: a blocking miss outranks a signed-out 21st ────────────
gate_run out 'cli\t21st\nexternal\tghost-skill'
if [ "$GATE_RC" -eq 10 ] && echo "$GATE_OUT" | grep -q 'INCOMPLETE' \
&& ! echo "$GATE_OUT" | grep -q 'SIGN-IN REQUIRED'; then
ok INCOMPLETE_WINS
else
bad INCOMPLETE_WINS "rc=$GATE_RC out=$GATE_OUT"
fi
# ── UNKNOWN_11: whoami answers something else -> surfaced, never guessed ───
gate_run garbage 'cli\t21st'
if [ "$GATE_RC" -eq 11 ] && echo "$GATE_OUT" | grep -q 'whoami: rc=0' \
&& echo "$GATE_OUT" | grep -q 'Something unexpected' \
&& ! echo "$GATE_OUT" | grep -q '21st login' \
&& ! echo "$GATE_OUT" | grep -q 'claude CLI unreachable'; then
ok UNKNOWN_11
else
bad UNKNOWN_11 "garbage: rc=$GATE_RC out=$GATE_OUT"
fi
gate_run fail 'cli\t21st'
if [ "$GATE_RC" -eq 11 ] && echo "$GATE_OUT" | grep -q 'whoami: rc=3'; then
ok UNKNOWN_11
else
bad UNKNOWN_11 "fail: rc=$GATE_RC out=$GATE_OUT"
fi
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]
+3 -2
View File
@@ -55,8 +55,9 @@ git log --oneline -20 --all -- <suspected files>
Follow `$HOME/.claude/lib/design-gate.md`: Follow `$HOME/.claude/lib/design-gate.md`:
- Scan $ARGUMENTS and target files for design/UI/style signals (CSS, component, layout, animation). - Scan $ARGUMENTS and target files for design/UI/style signals (CSS, component, layout, animation).
- If signals found → run `design-tool-gate.sh`; if it reports INCOMPLETE, - If signals found → run `design-tool-gate.sh`; INCOMPLETE → tell the user
tell the user to run `/profile design` before proceeding. to run `/profile design`; SIGN-IN REQUIRED → design-gate.md §3 (ask
`! 21st login`, wait) before proceeding.
- If no signals → skip (zero overhead). - If no signals → skip (zero overhead).
## STEP 2 — INVESTIGATE ## STEP 2 — INVESTIGATE
+3 -2
View File
@@ -71,8 +71,9 @@ FEAT: <feature name> — rule <N>, ~<N> files, <brief approach>
Follow `$HOME/.claude/lib/design-gate.md`: Follow `$HOME/.claude/lib/design-gate.md`:
- Scan $ARGUMENTS and target files for design/UI/style signals. - Scan $ARGUMENTS and target files for design/UI/style signals.
- If signals found → run `design-tool-gate.sh`; if it reports INCOMPLETE, - If signals found → run `design-tool-gate.sh`; INCOMPLETE → tell the user
tell the user to run `/profile design` before proceeding. to run `/profile design`; SIGN-IN REQUIRED → design-gate.md §3 (ask
`! 21st login`, wait) before proceeding.
- If no signals → skip (zero overhead). - If no signals → skip (zero overhead).
## STEP 0.6 — MEMORY READ-BEFORE (decisions-first) ## STEP 0.6 — MEMORY READ-BEFORE (decisions-first)