Files
claude/.claude/tasks/plans/2026-09-28-21st-signin-gate-1215.md
T

12 KiB

PLAN — 21st-signin-gate (feat, ad-hoc dispatch) — r3 (after confirmation pass)

  • r3 closes the confirmation pass (robustness CONCERNS(1)): MAJOR 1 — the unknown diagnostic format is pinned to unknown:whoami: rc=<rc> <line> (rendered 21st (whoami: rc=3 …)) and the 11 block prints a CLI-specific remedy for 21st instead of claude plugin list; MINOR 2 — classify on stdout only (2>/dev/null), stderr never enters the match; MINOR 3 — rc captured through if line="$(…)" under pipefail, and the fail stub prints the signed-out sentence AND exits 3 so the test proves rc≠0 wins; MINOR 4 — </dev/null on the whoami call (the gate loop reads the profile on stdin); MINOR 5 — hermeticity precondition = ! PATH=/usr/bin:/bin command -v 21st and no /usr/local/bin/21st; MINOR 6 — MIRROR note at both sites: the auth state is gate-only, profile.sh:skill_status() has no counterpart; MINOR 7 — doc offers "or run 21st login in any terminal on this machine, then reply"; MINOR 8 — a 12 after an explicit opt-out in the same run is reported once, not re-asked. Also honors API_KEY_21ST next to TWENTYFIRST_TOKEN (the CLI's second token env, per its getToken).
  • date: 2026-09-28 | contract: contracts/2026-09-28-21st-signin-gate-1215.md
  • branch: feature/skill-catalog-prune (working branch → commit in place)
  • executor: 1 feater (sonnet-pinned)
  • r2 closes: simplicity MAJOR 1 (no shared helper — predicate inline in the gate, toggle-external.sh and install-plugins.sh untouched, which also voids correctness BLOCKER 1 / MAJOR 2 / MINOR 3 and robustness BLOCKER 1 / MINOR 5-6), robustness MAJOR 2 (three-state predicate: in / out on the exact "Not logged in" sentence / unknown → exit 11 with the raw diagnostic, never the sign-in remedy), MAJOR 3 (no in-session export TWENTYFIRST_TOKEN remedy; token path documented as shell profile + restart), MAJOR 4 (explicit user opt-out "proceed without 21st", scoped to the run, stated visibly; silent skip stays forbidden), correctness MINOR 4 (fake profile.sh executable, per-case output), MINOR 5 / robustness MINOR 7 (also unverified line in the 12 block), MINOR 6 (design-gate.md §4 names the resume-after-sign-in path), robustness MINOR 8 (test asserts no system-wide 21st first), simplicity MINOR 2 (no extra INCOMPLETE line, the precedence test case stays), MINOR 4 (helper cases dropped), MINOR 5 (feat/bugfix bullets point at design-gate.md §3, no restated remedy).

Ground truth (verified 2026-09-28)

  • lib/design-tool-gate.sh (set -euo pipefail) checks the 21st cli entry with command -v only (tool_active, case cli). ensure_21st_on_path probes ~/.local/bin, /usr/local/bin and ~/.nvm/versions/node/*/bin. Exit codes: 0 ready · 11 ready-but-unverified · 10 incomplete · 2 error. REPO is derived from the script path (no override); PROFILE_SH has DESIGN_GATE_PROFILE_SH; [ -x "$PROFILE_SH" ] is required.
  • 21st whoami is a local token read, rc 0 both ways: Logged in as <user> (saved …). or Not logged in. Run \21st login`, or set TWENTYFIRST_TOKEN.The CLI is#!/usr/bin/env node` under nvm: with a sanitized PATH it can fail (rc≠0, "env: node: No such file") — that is NOT "signed out". On this machine: installed, not signed in; the live gate today returns 0.
  • Consumers: lib/design-gate.md §3 (verdict branches) and §4 (resume list); skills/feat and skills/bugfix STEP 0.5 bullets; hotfix skips the gate.
  • No hermetic test covers design-tool-gate.sh today. Existing suites copy toggle-external.sh / profile.sh into fixtures — NOT touched by this plan.

Approach

  1. lib/design-tool-gate.sh:
    • REPO="${DESIGN_GATE_REPO_OVERRIDE:-$(cd -P … && pwd)}" (fixture seam, same idiom as PROFILE_REPO_OVERRIDE). Nothing new is sourced.
    • New function twentyfirst_auth_state (≤ 25 logic lines): echoes in when ${TWENTYFIRST_TOKEN:-} or ${API_KEY_21ST:-} is non-empty; else if line="$(timeout 15 21st whoami 2>/dev/null </dev/null | head -1)"; then rc=0; else rc=$?; fi (pipefail is set: rc is 21st's rc, 124 on timeout; stdout only, stderr never enters the match; stdin closed so a CLI reading stdin cannot eat the gate's profile loop). in when rc=0 and the line starts with Logged in as ; out when rc=0 and the line starts with Not logged in; otherwise exactly unknown:whoami: rc=<rc> <first 60 chars of line, or "no output">. Comment: the token envs are honored when already present (a shell- profile export), never requested in-session.
    • tool_active case cli: command -v fails → inactive; name 21st → case "$(twentyfirst_auth_state)" in in → active, out → signedout, unknown:* → unknown:<diag>; other cli names → active.
    • Main loop: state signedout → signedout+=("$name"); state unknown:* → unverified_cli+=("$name (${state#unknown:})"), rendered 21st (whoami: rc=3 Something unexpected); the existing bare unknown (claude unreachable) keeps filling unverified.
    • Verdict order: blocking/manual → INCOMPLETE exit 10 (block unchanged). Else signedout non-empty → print design toolchain: SIGN-IN REQUIRED — 21st CLI installed, not signed in ask the user to run in this session: ! 21st login (browser flow, saves a local token) then re-run this gate before any 21st step — never skip 21st silently plus the also unverified line(s) when either unverified array is non-empty; exit 12. Else unverified or unverified_cli non-empty → 11: one helper print_unverified (≤ 25 logic lines) prints, for unverified, the existing claude-unreachable block (claude plugin list remedy) and, for unverified_cli, 21st could not answer: <diag> — a CLI runtime/PATH problem (node under nvm?), not a sign-in problem; fix it, then re-run. The 10 and 12 blocks reuse the same helper for their also unverified lines so no block ever says "claude CLI unreachable" about 21st. Else 0.
    • Header comment: exit codes line gains 12 = sign-in required (21st); the required-manual paragraph gets two lines on the three auth states; the MIRROR sentence ("tool_active MIRRORS profile.sh:skill_status()") gains "except the 21st auth state, gate-only, no skill_status counterpart".
  2. lib/design-gate.md:
    • Exit-codes line: add 12 = sign-in required (21st installed, signed out).
    • §3 new branch 12 / SIGN-IN REQUIRED → STOP. Relay the script's block. Ask the user to run ! 21st login (the ! prefix runs it in this session, browser flow, saves a local token). 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. Never skip silently ("not logged in, so we don't use it" is the failure this branch closes). Never run 21st login yourself. TWENTYFIRST_TOKEN is a shell-profile setting followed by a session restart, never an in-session export (tool calls do not share a shell, and a secret does not belong in the transcript).
    • §3 11 bullet: a 21st (whoami: rc=… …) entry means the CLI could not answer (runtime/PATH problem, node under nvm), so the remedy is the diagnostic, not a sign-in; relay the script's own line.
    • §3 12 bullet also says: "or run 21st login in any terminal on this machine, then reply" (the token is a local file, any terminal works; ! … in-session is the convenient form, not the only one); and: after an explicit opt-out, a later 12 in the same run is reported in one line, never re-asked.
    • §4 first paragraph: "(READY, after the user ran /profile design, or after the sign-in re-run returns READY)".
    • §IMPORTANT 21st bullet: add "signed out → exit 12: ask ! 21st login, wait, re-run; explicit opt-out only". §IMPORTANT MIRROR bullet: add "except the 21st auth state: gate-only, no skill_status counterpart".
  3. skills/feat/SKILL.md and skills/bugfix/SKILL.md STEP 0.5 bullet → "If signals found → run design-tool-gate.sh; INCOMPLETE → tell the user to run /profile design; SIGN-IN REQUIRED → design-gate.md §3 (ask ! 21st login, wait) before proceeding." No restated remedy beyond that.
  4. lib/tests/design-tool-gate.test.sh (hermetic, set -u, check helper, trap 'rm -rf "$WORK"' EXIT, style of lib/tests/skill-routing-census.test.sh):
    • Precondition, loud: ! PATH=/usr/bin:/bin command -v 21st and [ ! -e /usr/local/bin/21st ] (the two places the sanitized test PATH and ensure_21st_on_path could still find a real CLI) else print FAIL precondition: system-wide 21st present, CLI_ABSENT case not hermetic and count a FAIL.
    • Fixture $WORK/repo: lib/profiles/design.profile holding # GATE-BLOCK: 21st ghost-skill and entries 21st cli, ghost-skill external; lib/profile.sh = an executable stub that cats $WORK/plain.txt for show design --plain (per case the test writes cli\t21st alone, or cli\t21st + external\tghost-skill); skills/ empty. $WORK/bin/21st = executable stub: whoami prints per $FAKE_21ST_MODE: in → Logged in as tester (saved locally)., out → Not logged in. Run \21st login`, or set TWENTYFIRST_TOKEN., garbage→Something unexpected(rc 0),fail` → prints the exact signed-out sentence AND exits 3 (proves rc≠0 overrides the sentence).
    • Every gate run: env -u TWENTYFIRST_TOKEN 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=<mode> bash "$ROOT/lib/design-tool-gate.sh" (CLAUDE_BIN irrelevant: no plugin/mcp entry in the fixture).
    • Stub positive control first: the stub prints the expected sentence for in and out (PASS STUB_CONTROL).
    • Cases, each PASS <NAME>: SIGNED_IN_READY (rc 0, READY); SIGNED_OUT_12 (rc 12, SIGN-IN REQUIRED, 21st login, no INCOMPLETE); TOKEN_READY (mode out + TWENTYFIRST_TOKEN=x → rc 0); CLI_ABSENT_10 (PATH without $WORK/bin → rc 10, INCOMPLETE); INCOMPLETE_WINS (plain adds ghost-skill, mode out → rc 10, INCOMPLETE, no SIGN-IN REQUIRED line); UNKNOWN_11 (mode garbage → rc 11, output has whoami: rc=0 and Something unexpected, lacks 21st login and lacks claude CLI unreachable; mode fail → rc 11 with whoami: rc=3). TOKEN_READY also checks API_KEY_21ST=x alone → rc 0. Summary PASS=n FAIL=m, rc 1 on any FAIL.
  5. CHANGELOG [Unreleased] → Added: design gate SIGN-IN REQUIRED (exit 12) when the 21st CLI is installed but signed out — the agent asks for ! 21st login and waits, explicit opt-out only; unknown whoami answers surface as unverified with the diagnostic.

Edge cases

  • 21st whoami hang: timeout 15 → rc 124 → unknown, exit 11 with the diagnostic (not a sign-in loop).
  • TWENTYFIRST_TOKEN set but invalid: the CLI decides at call time; the gate honors the env var as in (documented).
  • INCOMPLETE and signed out at once: 10 wins by construction (the re-run after /profile design returns 12); no extra line.
  • The fixture never sees the real ~/.nvm (HOME redirected) and the test fails loudly if a system-wide 21st exists.
  • set -euo pipefail: the whoami capture must not abort the script on a nonzero rc (run inside if, or || true).

Tests

  • lib/tests/design-tool-gate.test.sh (new); make test suite= for doctrine-citers, design-toolchain-reminder (unchanged suites, stay green).
  • shellcheck lib/design-tool-gate.sh lib/tests/design-tool-gate.test.sh.
  • honors BDR-025 — GATE-BLOCK single source untouched; a state is added, not a scope.
  • honors BDR-093 — 21st auth is 21st login / TWENTYFIRST_TOKEN, no key, no MCP.
  • honors LRN-102 — the STOP asks in the turn's final text and ends the turn.
  • honors "ask, don't guess" — a signed-out tool becomes a question to the human, and an explicit refusal is an answer, never a silent skip.
  • honors LRN-096 (vacuous guard class) — an unknown answer is surfaced, not swallowed as "signed out".