12 KiB
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>(rendered21st (whoami: rc=3 …)) and the 11 block prints a CLI-specific remedy for 21st instead ofclaude plugin list; MINOR 2 — classify on stdout only (2>/dev/null), stderr never enters the match; MINOR 3 — rc captured throughif line="$(…)"under pipefail, and thefailstub prints the signed-out sentence AND exits 3 so the test proves rc≠0 wins; MINOR 4 —</dev/nullon the whoami call (the gate loop reads the profile on stdin); MINOR 5 — hermeticity precondition =! PATH=/usr/bin:/bin command -v 21stand 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 run21st loginin 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 honorsAPI_KEY_21STnext toTWENTYFIRST_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/outon the exact "Not logged in" sentence /unknown→ exit 11 with the raw diagnostic, never the sign-in remedy), MAJOR 3 (no in-sessionexport TWENTYFIRST_TOKENremedy; 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 unverifiedline 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 the21stcli entry withcommand -vonly (tool_active, casecli).ensure_21st_on_pathprobes~/.local/bin,/usr/local/binand~/.nvm/versions/node/*/bin. Exit codes: 0 ready · 11 ready-but-unverified · 10 incomplete · 2 error.REPOis derived from the script path (no override);PROFILE_SHhasDESIGN_GATE_PROFILE_SH;[ -x "$PROFILE_SH" ]is required.21st whoamiis a local token read, rc 0 both ways:Logged in as <user> (saved …).orNot 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
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): echoesinwhen${TWENTYFIRST_TOKEN:-}or${API_KEY_21ST:-}is non-empty; elseif 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).inwhen rc=0 and the line starts withLogged in as;outwhen rc=0 and the line starts withNot logged in; otherwise exactlyunknown: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_activecasecli:command -vfails →inactive; name21st→case "$(twentyfirst_auth_state)"inin→active,out→signedout,unknown:*→unknown:<diag>; other cli names →active.- Main loop: state
signedout→signedout+=("$name"); stateunknown:*→unverified_cli+=("$name (${state#unknown:})"), rendered21st (whoami: rc=3 Something unexpected); the existing bareunknown(claude unreachable) keeps fillingunverified. - Verdict order: blocking/manual → INCOMPLETE exit 10 (block unchanged).
Else signedout non-empty → print
design toolchain: SIGN-IN REQUIRED — 21st CLI installed, not signed inask 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 silentlyplus thealso unverifiedline(s) when either unverified array is non-empty; exit 12. Else unverified or unverified_cli non-empty → 11: one helperprint_unverified(≤ 25 logic lines) prints, forunverified, the existing claude-unreachable block (claude plugin listremedy) and, forunverified_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 theiralso unverifiedlines so no block ever says "claude CLI unreachable" about 21st. Else 0. - Header comment: exit codes line gains
12 = sign-in required (21st); therequired-manualparagraph 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".
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-rundesign-tool-gate.shbefore 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 visibly21st skipped for this run at your requestand 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 run21st loginyourself.TWENTYFIRST_TOKENis a shell-profile setting followed by a session restart, never an in-sessionexport(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 loginin 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".
- Exit-codes line: add
skills/feat/SKILL.mdandskills/bugfix/SKILL.mdSTEP 0.5 bullet → "If signals found → rundesign-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.lib/tests/design-tool-gate.test.sh(hermetic,set -u,checkhelper,trap 'rm -rf "$WORK"' EXIT, style of lib/tests/skill-routing-census.test.sh):- Precondition, loud:
! PATH=/usr/bin:/bin command -v 21stand[ ! -e /usr/local/bin/21st ](the two places the sanitized test PATH andensure_21st_on_pathcould still find a real CLI) else printFAIL precondition: system-wide 21st present, CLI_ABSENT case not hermeticand count a FAIL. - Fixture
$WORK/repo:lib/profiles/design.profileholding# GATE-BLOCK: 21st ghost-skilland entries21st cli,ghost-skill external;lib/profile.sh= an executable stub thatcats$WORK/plain.txtforshow design --plain(per case the test writescli\t21stalone, orcli\t21st+external\tghost-skill);skills/empty.$WORK/bin/21st= executable stub:whoamiprints 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
inandout(PASS STUB_CONTROL). - Cases, each
PASS <NAME>:SIGNED_IN_READY(rc 0,READY);SIGNED_OUT_12(rc 12,SIGN-IN REQUIRED,21st login, noINCOMPLETE);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, noSIGN-IN REQUIREDline);UNKNOWN_11(mode garbage → rc 11, output haswhoami: rc=0andSomething unexpected, lacks21st loginand lacksclaude CLI unreachable; mode fail → rc 11 withwhoami: rc=3).TOKEN_READYalso checksAPI_KEY_21ST=xalone → rc 0. SummaryPASS=n FAIL=m, rc 1 on any FAIL.
- Precondition, loud:
- CHANGELOG
[Unreleased]→ Added: design gateSIGN-IN REQUIRED(exit 12) when the 21st CLI is installed but signed out — the agent asks for! 21st loginand waits, explicit opt-out only; unknown whoami answers surface as unverified with the diagnostic.
Edge cases
21st whoamihang:timeout 15→ rc 124 →unknown, exit 11 with the diagnostic (not a sign-in loop).TWENTYFIRST_TOKENset but invalid: the CLI decides at call time; the gate honors the env var asin(documented).- INCOMPLETE and signed out at once: 10 wins by construction (the re-run
after
/profile designreturns 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: thewhoamicapture must not abort the script on a nonzero rc (run insideif, 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.
Disposition (RELATED MEMORY)
- 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".