chore(memory): 21st sign-in gate — contract, plan r3, TODO, journal

This commit is contained in:
bastien
2026-09-28 12:51:56 +02:00
parent bd3e525bb3
commit 132bcdf7c5
4 changed files with 259 additions and 0 deletions
@@ -0,0 +1,190 @@
# 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
`cat`s `$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.
## 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".