git:20261001.65f53d7 to git:20261001.05ffc90

3 added, 9 removed. Audit B to B.

# gh-fine-grained-pat — maintainer SSoT
Per-skill compass (sibling to [SKILL.md](./SKILL.md)). **Hub**: [gh-tools CLAUDE.md](../../CLAUDE.md). Read this before editing the engine — the GitHub UI is the fragile part and the load-bearing knowledge lives here.
## Why this exists
GitHub has **no API to create fine-grained PATs** (only the web UI). We created one by hand-driven browser automation, hit every gotcha, and codified the result here so it never has to be re-learned. The declarative JSON spec is the SSoT; the engine is a thin, anti-fragile UI driver.
## File map
| File | Role |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `scripts/pat.mjs` | CLI: `login \| doctor \| create \| rotate \| list \| inspect \| delete \| register \| patch-password \| agent \| accounts \| quit`. |
| `scripts/browser.mjs` | Chrome launch (persistent profile), CDP attach (`/json/version` → `connectOverCDP`, retry), specific-PID teardown. |
| `scripts/form.mjs` | The form driver — all the gotchas. `createToken / listTokens / inspectToken / deleteToken` + `ensureFormReady` (sudo). |
| `scripts/selectors.mjs` | Selector constants + generic click/wait/DOM-fallback/screenshot helpers. |
| `scripts/identity.mjs` | Resolve target account (flag → host-alias → spec owner → logged-in) + provisioned-account registry. (ADR 2026-06-26) |
| `scripts/webauthn.mjs` | CDP virtual-authenticator helpers (mount/inject/getCredentials) — autonomous passkey, no biometric at run time. |
| `scripts/autosudo.mjs` | Clear GitHub sudo via the gated `github-web-<account>` blob: passkey primary, password/TOTP fallback. |
| `scripts/webauth-agent.mjs` | Memory-only session agent (ssh-agent pattern) → one Touch-ID unlock lasts the session. |
| `schema/token-spec.schema.json` | JSON Schema 2020-12 — formal spec SSoT. |
| `specs/*.json` | Five templates (release-bot, read-only-auditor, ci-status-reporter, account-scoped, kitchen-sink) + `specs/examples/`. |
| `test/campaign.mjs` | Empirical create→verify→delete harness + forced-failure + leak sweep. |
| `test/autonomous.test.mjs` | Unit tests (identity parsing, credential serde, agent put/get/TTL). |
| `test/webauthn-smoke.mjs` | Live CDP proof: virtual-authenticator create→capture→restore on localhost (GitHub-independent). |
## Runtime invariants (do NOT change blindly)
- **node, never bun.** Bun's `connectOverCDP` times out; node attaches in <1s. All scripts are `.mjs` run with `node`.
- **`--remote-allow-origins=*`** on the Chrome launch — Chrome 111+ rejects the CDP websocket without it.
- **CDP attach** = fetch `http://127.0.0.1:9222/json/version` → `webSocketDebuggerUrl` → `chromium.connectOverCDP()` (mirrors `gemini-deep-research/scripts/client.ts`). Retry both steps.
- `playwright-core` resolves from the repo root `node_modules` (pinned `^1.58.2`); scripts just `import { chromium } from "playwright-core"`.
- **Teardown kills a specific PID** found via `lsof -iTCP:<port>`; never `pkill -f` (process-storm policy in `~/.claude/CLAUDE.md`).
- **`browser.close()` after each command** disconnects Playwright only — the externally-launched Chrome keeps running (persistent session). Use `pat quit` to actually kill it.
## Hard-won gotchas (encoded in `form.mjs`)
1. **Generate-confirmation overlay — detection, and the trap in the old fix.** After clicking the form's "Generate token", a summary overlay headed "New personal access token" appears with its own "Generate token" button. ⚠️ **Two corrections, measured 2026-09-04.** (a) It IS a `<dialog>` today: `DIALOG#fgpat-confirmation-dialog.Overlay < DIALOG-HELPER < FGPAT-CONFIRMATION-DIALOG`, and the two buttons are distinguishable by `type` — the form's is `type="button"` (`.js-integrations-install-form-submit`, it merely OPENS the overlay), the overlay's is `type="submit"`. (b) The old advice — "click the **last** visible button whose text is exactly Generate token", **unconditionally, 1.6 s after submitting** — was the bug described in GOTCHA #17: the overlay only appears for some specs, so when it does not, the last visible button IS the one already clicked and the form is submitted TWICE. Gate any such click on the overlay actually being present (`SEL.confirmModalHeading`).
2. **Repo picker intercepts pointer events.** After selecting repos in `#repository-menu-list-dialog`, the dialog stays open and swallows the next click. Fix: close it via `button[aria-label="Close"]` before continuing (`setRepoAccess()`).
3. **"No expiration"** is selectable inline (no immediate modal); its confirmation arrives at the generate-time summary overlay — handled by (1). The inline amber "GitHub strongly recommends…" warning is not a blocker.
4. **Permission access levels.** Newly added permissions default to "Read-only". Each row has an "Access:" button → menu with `menuitemradio` "No access" / "Read-only" / "Read and write". Match the row by its heading prefix, then pick the target level. **Never touch Metadata** (auto-required RO).
5. **Permissions are split across two tabs.** The Permissions section has "Repositories" and "Account" tabs, each with its **own** "Add permissions" menu ("Select repository permissions" / "Select account permissions"). Repository perms (`Contents`, …) are only in the repo menu; account perms (`Gists`, …) only in the account menu. Add + set-level each group while its tab is active (`applyGroup`). _Discovered empirically: searching "Gists" in the repo menu returns "No items available"._
6. **Token-list scrape: capture only the name link.** Each token row has a name link `/settings/personal-access-tokens/<id>` AND a `…/<id>/regenerate?index_page=1` link that shares the id. A naive id-keyed map lets the regenerate link (text "This token has no expiration date") overwrite the name. Fix: match `/\/personal-access-tokens\/(\d+)(?:[?#]|$)/` so only the bare-id href is captured. _This caused a false "cc-skills-release MISSING" sweep on the first run._
7. **Detail-page verification uses friendly nouns, not labels.** A token's detail page renders permissions as prose grouped by access level — e.g. _"Read access to actions, metadata, and repository hooks"_ / _"Read and Write access to code, commit statuses, deployments, and environments"_ — NOT per-label rows. `test/campaign.mjs` parses these clauses and maps UI label → detail noun (below).
8. **GitHub sudo mode ("Confirm access").** On a session that hasn't re-authed recently (e.g. after a Chrome restart), navigating to the new-token page shows a "Confirm access" passkey/2FA challenge instead of the form — `input[name="user_programmatic_access[name]"]` is absent. This cannot be bypassed programmatically. `createToken` → `ensureFormReady()` detects it (page title "Confirm access") and waits up to 3 min for the operator to confirm in the browser (or clears it via the gated passkey when `GH_PAT_AUTONOMOUS=1`), then proceeds.
9. **Passkey enablement invalidates the session ONCE.** Right after the `register` ceremony adds a passkey, GitHub commonly forces a fresh sign-in — the _next_ run lands on the full login page (not just sudo). Re-run `pat login --account <a>` ONE more time; the session then persists across restarts (verified for `vanjobbers`). The ceremony prints this heads-up.
10. **Gists has no Read-only.** GitHub's `Gists` account permission only offers "Read and write" — a spec with `Gists: "read"` fails at `setAccessLevels` ("Read-only not selectable"). Pick the level GitHub actually offers.
11. **Passkey sudo assertion races the virtual authenticator → retry the click.** GitHub's `/sessions/sudo` page auto-fires `navigator.credentials.get()` on load — _before_ `autosudo` mounts its virtual authenticator — so the first attempt shows "Authentication failed" and the button flips "Use passkey" → "Retry passkey". A single post-mount click can still race the authenticator's warm-up. `tryPasskey` therefore **arms the authenticator, then re-clicks the assertion up to 6×** (regex matches both "Use passkey" and "Retry passkey"); a subsequent fresh assertion reliably succeeds. _Empirically: `terrylica` won the race on the first click and looked fine; `vanjobbers` lost it and failed — the retry loop fixes both. The credential itself was valid the whole time (a manual "Retry passkey" with the same stored credential cleared sudo)._
12. **A session check is not an identity check — and `--account` was ignored by half the verbs.** `isAuthedViaRequest` answers "a session exists", never "whose". The engine DERIVES the profile dir from the resolved account (`profile` for the shared account, `profile-<account>` otherwise) and then trusts that binding. Worse, only `create`/`login`/`quit` ever set `GH_PAT_ACCOUNT`; `list`/`inspect`/`delete` silently read the SHARED profile whatever account you named, then labelled the result with the account you asked for. Measured 2026-09-04: `list --account terrylica` printed **"(no fine-grained tokens)"** while reading a profile signed in as **`work`** — a TRUE statement about work's genuinely empty list, indistinguishable from a fact about terrylica's. `doctor` printed `auth authenticated ✓` over the same state. A `create` would have minted on work, and `setOwner`'s catch-all (`catch { /* owner already correct */ }`) would have swallowed the absent `example-org` resource-owner option, producing a silently wrong token that fails only later, mid-release. Fix: `session()` now resolves `--account` at the one chokepoint every verb passes through (before `launchChrome()`, since profile/port derive from it) and `assertProfileIdentity()` compares `meta[name="user-login"]` to the resolved account, refusing on mismatch **and on an unreadable login** (INCOMPLETE is not a match). `doctor` gained a `signed in as` row; `login` no longer says "already authenticated" for the wrong identity. Escape: `GH_PAT_SKIP_IDENTITY_CHECK=1`.
13. **Assert the RESULT, never the absence of an exception.** `clickOption()` RETURNS FALSE on a miss and `setOwner`'s click sat in a catch-all commented `/* owner already correct */` — a guess rendered as a fact. Every signal the function had was negative-only, so a missed resource-owner selection was completely silent and the form kept its defaults. `setOwner` now reads the control back and refuses on a **positive** mismatch (it names a different owner); an unreadable control warns instead of blocking, because a UI rename must not be able to veto a correct mint. Same treatment in `setExpiration`. **Note the sharp edge in the empty case:** the first version tested `if (!ok && shown)`, so an unreadable-and-therefore-empty read scored as a PASS — the assertion silently disarmed itself. Empty is INCOMPLETE, never OK.
14. **The owner control is labelled with the CURRENT owner, so a selector naming the DESIRED owner can never match it.** The original locator was `getByRole("button", {name: /<spec.owner>|owner/i})`; the button's accessible name is the signed-in login (`terrylica`), matching neither alternative, so the click timed out into the catch-all. Anchor on the one string that does not change — the **"Resource owner" field label** — and take the first control inside its container (`ownerControl()`, exported for probes). Read it back the same way.
15. **Menu rows are `role="none"` wrappers; click the node that carries the behaviour.** GitHub's ActionList renders every option as `<li class="ActionListItem" role="none"><button role="option|menuitemradio" data-value="…"><span class="ActionListItem-label">`. The `li`, the `button` and the `span` all carry the same `textContent`, and **the `li` comes first in document order** — so a text-matching DOM walk (the old `evalClick(page, '…,li,button', re)` fallback) clicks the wrapper. That does nothing AND throws nothing, so the fallback reported success. This one defect silently defeated **both** the resource-owner picker and the expiration menu. `clickOption()`'s fallback now descends to the interactive child and skips `role="none"`/`presentation`.
16. **"No expiration" is not always on the menu — an ORG can cap token lifetime.** With resource owner `example-org` the menu is 7/30/60/90/**366** days + "Custom between 1 and 366 days", labelled "Limit set by example-org"; the `li` even advertises it as `data-fg-limit="366"` / `data-fg-limit-exempt="false"`. The option is **removed, not disabled**, and `clickExact()` does not throw on a miss, so a spec asking for `"none"` was silently downgraded to the 30-day default. `setExpiration` now fails closed and prints what WAS offered. Corollary worth internalising: a token that "never expires" under a user owner may simply be evidence that the owner is wrong. ⚠️ `clickExact()` resolves to `undefined` and **throws** on a miss — catch it; testing its return for falsiness fires on success.
17. **Never click the confirm button unconditionally — it double-submits.** See GOTCHA #1(b). The second POST raced the first, GitHub answered with a freshly-rendered form, and the failure screenshot showed the form back at its **defaults** — which reads as "your form was filled wrong" and sends you after the wrong bug. A `GH_PAT_TRACE=1` run had already proven owner and expiration were correct at submit time; the screenshot was of a different, later form.
18. **The overlay's confirm needs a REAL mouse event.** An in-page `HTMLElement.click()` on the overlay's submit produced an untrusted event: the overlay closed, the browser landed back on the form with NO token created and no error anywhere on the page or in the token list. Drive it through Playwright (`getByRole(...).last().click()`), which dispatches a genuine CDP input event — but only when the overlay is up (GOTCHA #17).
19. **An over-long `description` is rejected by the SERVER, silently.** The description textarea advertises **no** maxlength (`maxLength === -1`), so the browser types any length happily; the POST is then dropped, the form re-renders with defaults, no token is created, and **no error text appears anywhere** — not as a flash, not inline, not in the list. Measured 2026-09-04: **4272 chars failed repeatedly, 191 chars succeeded on the very next attempt with every other field byte-identical.** This is now blocked in `validateSpec()` (>1000 chars) before the browser opens, and specs carry long-form rationale in the new **`notes`** field, which the engine never types into the UI. _Diagnostic value: this failure is indistinguishable from a mis-filled form, and it is what made GOTCHAs #14–#18 so slow to isolate — three of them were real and fixed while this one kept the symptom alive._
## Live selector map (update when GitHub drifts)
| Step | Selector / strategy |
| ----------------- | ------------------------------------------------------------------------------------------ |
| Name | `input[name="user_programmatic_access[name]"]` |
| Description | `textarea[name="user_programmatic_access[description]"]` |
| Expiration open | `button` name `/days \( \| No expiration \| Custom \| Expiration/i` |
| Expiration pick | exact text "No expiration" / `^<N> days` option |
| Repo access radio | exact text "Public/All repositories" or "Only select repositories" |
| Repo picker open | `button` name `/Select repositories/i` |
| Repo search | `input[type="search"]:visible` |
| Repo option | exact text `owner/name` |
| Repo picker close | `#repository-menu-list-dialog button[aria-label="Close"]` |
| Add permissions | `button` name `/Add permissions/i`; check via `menuitemcheckbox` name = label |
| Access level | row "Access:" button → `menuitemradio` name = level |
| Generate | `button` name `/^Generate token$/i` (form), then **last** visible "Generate token" (modal) |
| Token value | `input` whose value starts `github_pat_` |
| Token list | `a[href*="/settings/personal-access-tokens/<id>"]` (text = name) |
| Account perms tab | `role=tab` name `/Account/i` → its own "Add permissions" menu |
## Detail-page noun map (for verification)
The token detail page names permissions with friendly nouns, not the UI labels. `test/campaign.mjs` (`NOUN`) maps them. Verified empirically:
| UI label | Detail noun | | UI label | Detail noun |
| ------------- | --------------- | --- | --------------- | ---------------------- |
| Contents | `code` | | Commit statuses | `commit statuses` |
| Issues | `issues` | | Deployments | `deployments` |
| Pull requests | `pull requests` | | Environments | `environments` |
| Actions | `actions` | | **Webhooks** | **`repository hooks`** |
| Metadata | `metadata` | | Gists (account) | `gists` |
Verified for `specs/examples/dependabot-secrets.json`: `Dependabot secrets` → `dependabot secrets`, `Secrets` → `secrets`, `Secret scanning alerts` → `secret scanning alerts`.
When adding a new permission to a spec, confirm its detail noun (create one token, read the detail page) and add it to `NOUN` — otherwise verification reports a false "perm missing".
## Security invariants
- Token value never reaches stdout/chat: `--out` writes 0600; `--vault scope:dot` shells `vault set` (value in-process). `inspect`/`list` never expose the value (the value is not on any later page anyway).
- Persistent profile (`~/.local/share/gh-pat-automation/profile`) holds the GitHub session cookie → sensitive, outside the repo, never committed.
## Recent changes
- - **2026-06-26** — skill created. Engine + 5 spec templates + JSON Schema + empirical campaign harness. Codifies the gotchas learned while minting `cc-skills-release`. Empirically validated by create→verify→delete across all 5 specs + a forced-failure case (PASS). The campaign surfaced gotchas 5–7 (two-tab permissions, regenerate-link list scrape, friendly-noun detail page) which are now encoded + documented.
- - **2026-06-26 (follow-up)** — added `rotate`, two reminder hooks, `specs/examples/`, and **gotcha #8 (sudo mode)**: `createToken`→`ensureFormReady()` now waits (non-disruptively, no page reload) for the operator to clear GitHub's "Confirm access" challenge. Empirically verified `dependabot-secrets` and extended `NOUN` (dependabot secrets / secrets / secret scanning alerts).
- - **2026-06-26 (autonomous web-auth, ADR-driven)** — multi-account autonomous sudo: virtual-authenticator passkey (primary) + password/TOTP fallback, gated `github-web-<account>` blob, session agent for one-tap-per-session, `register`/`agent`/`accounts` verbs + `--account` + `GH_PAT_AUTONOMOUS=1`. Core mechanism proven by `test/webauthn-smoke.mjs` (create→capture→restore) + `test/autonomous.test.mjs` (9 assertions). ADR: `/docs/adr/2026-06-26-autonomous-github-web-auth-virtual-passkey-and-totp-for-pat-engine.md`.
- - **2026-06-26 (ceremony + autosudo VALIDATED end-to-end)** — `register` ceremony works: passkey URL is **`/settings/security`** (button "Add passkey"), NOT `/settings/passkeys` (404). Provisioned `terrylica`. After GitHub's server-side sudo window (~3h) expired, an autonomous `create` hit a **real** sudo challenge and `autosudo.mjs` cleared it via the virtual-authenticator passkey (`✓ autonomous sudo (terrylica)` + token created) — the sudo-page selectors worked first try, no refinement needed. One-tap-per-session confirmed (the session agent cached the unlocked blob). The full autonomous path is proven.
- - **2026-06-26 (multi-account, per-account profile/port)** — the engine now derives an isolated browser profile (`profile-<account>`) + CDP port from the resolved account, so accounts never collide; the shared/default account (`GH_PAT_SHARED_ACCOUNT`, default `terrylica`) keeps the original `profile` + port 9222. Provisioned a **second** account (`vanjobbers`) end-to-end in its own profile — registry now lists both. **Thoroughly tested vanjobbers**: gated blob intact (passkey+password, no TOTP), session **persists across restarts** (after the one-time passkey-relogin, gotcha #9), and it **minted a real token** in its isolated profile. Surfaced gotchas #9 (passkey-relogin) + #10 (Gists no-read-only).
- - **2026-06-27 (passkey assertion race fixed — gotcha #11)** — vanjobbers' autosudo passkey path was exercised against a **real** expired-sudo challenge for the first time and **failed** ("Authentication failed"). Root-caused: NOT a credential problem (a manual "Retry passkey" with the same stored credential cleared sudo and reached the token form). GitHub's `/sessions/sudo` auto-fires the passkey `get()` on load before our virtual authenticator exists, and a single post-mount click raced the warm-up. `tryPasskey` now **re-clicks the assertion up to 6×** ("Use passkey"/"Retry passkey"). terrylica had won the race by luck; this makes both accounts robust. **Re-proven end-to-end (2026-06-28)**: against a genuinely fresh (~34h-expired) sudo challenge, the fixed `create` printed `✓ autonomous sudo (vanjobbers)` and minted a token with one Touch-ID tap and zero manual confirmation — the exact path that failed the day before now passes.
- - **2026-06-28 (all 4 personal accounts provisioned + `patch-password` verb)** — provisioned the remaining personal logins **`work`** and **`tainora`** (registry now: work, tainora, terrylica, vanjobbers; each blob = passkey + password, no TOTP). `example-org` needs none — it authenticates AS `terrylica` (org), so org-owned token creation reuses terrylica's passkey. Account set is definitive per the gitconfig `includeIf` SSoT (no hidden accounts). New **`patch-password --account <a>`** verb: re-stores a gated password (passkey **kept** — never re-registers a duplicate passkey) for when the `register` osascript password dialog is missed; idempotent (skips if a password ≥6 chars is already set, `--force` to overwrite, `--totp` to also set a seed). Born from work's first ceremony missing the password dialog — patched without re-registering. Note: work/tainora autosudo wasn't yet exercised against a _fresh_ sudo challenge (sudo was valid right after login); identical proven code path, so first expiry will clear with one tap.
+ The three most recent entries; earlier history is in `git log -- plugins/gh-tools/skills/gh-fine-grained-pat`. Every gotcha they found is in the gotcha list above.
+
- **2026-07-19 (autonomous FULL login on expired session cookie)** — `session()` used to `die("not logged in")` on an expired profile cookie even with `GH_PAT_AUTONOMOUS=1`, so the autonomous machinery never got a chance (found live: expired terrylica cookie during a zz-repo-cleanup token mint). `session()` now, in autonomous mode with a resolved account, navigates to settings (→ GitHub redirects to /login), runs `autonomousSudo` there (the login page's "Sign in with a passkey" button was already covered by `tryPasskey`'s regex), re-checks auth, and only then falls back to the manual `pat login` guidance. Extends ADR 2026-06-26 beyond sudo-only to cold login. Same-mint also exercised the narrow **Administration-RW two-repo delete token** shape end-to-end (create → 2× DELETE /repos → revoke → PID-specific Chrome kill).
- **2026-09-04 (GOTCHA #12: profile↔account binding asserted but never verified — wrong-account mint prevented)** — while minting the relay-monitor release PAT, `list --account terrylica` returned "(no fine-grained tokens)" for an account that demonstrably holds one. Root cause was two defects stacked: (a) `list`/`inspect`/`delete` never set `GH_PAT_ACCOUNT`, so `--account` was silently ignored and they always read the shared `profile`; (b) nothing ever checked WHICH login that profile holds — it was signed in as `work`. Both directions were dangerous: reads mislabel another account's tokens, and a `create` would have minted the example-org release token on work. Added `loggedInLoginViaRequest()` (non-navigating, reads `meta[name="user-login"]` from the same cookie-jar request) + `assertProfileIdentity()` in `session()`; account resolution moved into `session()` before `launchChrome()`. **Proven both ways**: `list --account terrylica` now exits 1 naming the mismatch, `create` refuses before touching the form (no token sink written), and the control — `GH_PAT_SHARED_ACCOUNT=work list --account work` on the same profile — still passes, so the guard is not always-red. Also fixed `specs/relay-monitor-release-bot.json`, which set `repositoryAccess.repos` to the org path but left **`owner` unset**; `setOwner()` returns on its first line without it, so the 2026-08-28 "repo path corrected" edit could never have taken effect and the minted token was account-scoped (`GET /repos/example-org/relay-monitor` → 404; git upload-pack AND receive-pack → 403 "Write access to repository not granted", the same text for the read service).
- - **2026-06-28 (work + tainora autosudo proven end-to-end — all 4 accounts now proven)** — scheduled recheck once their sudo windows expired: both hit a **real** fresh "Confirm access" challenge and `autosudo` cleared it (`✓ autonomous sudo (work)` / `✓ autonomous sudo (tainora)` + token minted, one Touch-ID tap each, zero manual confirmation). The retry-loop fix (gotcha #11) held — neither hit the get()-race; no selector refinement needed. **Autonomous passkey sudo is now proven against fresh challenges for all four accounts (terrylica, vanjobbers, work, tainora).**
- - **2026-09-04 (GOTCHAs #13–#19: the relay-monitor release PAT finally minted — six defects, one of which kept the other five alive)** — minting `relay-monitor-release-bot-doorward` (org resource owner `example-org`) failed **seven consecutive times** and each failure looked like the previous fix had not worked. In order: **#14** the owner locator named the DESIRED owner while the button is labelled with the CURRENT one, so it never matched; **#15** the owner *and* expiration menus are `role="none"` `<li>` wrappers around the real `<button role="option|menuitemradio" data-value>`, and the DOM-fallback click hit the wrapper — doing nothing, throwing nothing, and returning "clicked"; **#16** the org caps lifetime at 366 days and **removes** "No expiration", so the spec's `"none"` was silently downgraded; **#17** `generate()` clicked the confirm button unconditionally 1.6 s after submit, double-submitting whenever no overlay existed and re-rendering the form at its defaults — which is why every failure screenshot showed `30 days` and sent the investigation back to the expiration control that a trace had *already proven correct*; **#18** an in-page `.click()` on the overlay's submit is untrusted and closes it without submitting. **#19 was the one that mattered**: the spec's `description` was 4272 chars, and GitHub drops an over-long submission server-side with **no error anywhere** — no flash, no inline message, no entry in the token list. Cutting it to 191 chars minted on the next attempt with every other field byte-identical. #14–#18 were all real and all fixed, but #19 kept reproducing the same symptom after each one, which is what made this expensive: **when a silent failure survives a correct fix, the fix looks wrong.** Countermeasures now in the engine: `GH_PAT_TRACE=1` prints owner + expiration after every phase (it is what proved the form was right and moved the search to `generate()`); `validateSpec()` rejects `description > 1000` before the browser opens; specs carry long-form rationale in a new **`notes`** field the engine never types; `setOwner`/`setExpiration` assert their own result (**#13**, positive-rejection only, and note the first version scored an unreadable-and-empty read as a PASS). **Proven**: `create` exit 0; vault `ccmax-release:gh.token` moved `0bc5d79ed5c1` → `b1c18a25329d`; `GET /repos/example-org/relay-monitor` → 200 `private:true`; `release-preflight-verify-github-write-access.sh` → **exit 0** with `git WRITE (receive-pack advertisement, HTTP 200)` and `expires 2027-09-05`; `npm run release:dry` → exit 0 (next version 3.3.1). **Controls that fire**: the same verifier exits **11** when only `GH_TOKEN` is injected, and the new description guard exits 1 on a 4272-char spec without writing a token sink. The superseded token (id 17661881, `0bc5d79ed5c1`) was deliberately left LIVE and un-revoked throughout — the new token was minted under a **different name** rather than with `--replace`, so a failure at any point left the operator no worse off.
+ - **2026-09-04 (GOTCHAs #13–#19: the relay-monitor release PAT finally minted — six defects, one of which kept the other five alive)** — minting `relay-monitor-release-bot-doorward` (org resource owner `example-org`) failed **seven consecutive times** and each failure looked like the previous fix had not worked. In order: **#14** the owner locator named the DESIRED owner while the button is labelled with the CURRENT one, so it never matched; **#15** the owner _and_ expiration menus are `role="none"` `<li>` wrappers around the real `<button role="option|menuitemradio" data-value>`, and the DOM-fallback click hit the wrapper — doing nothing, throwing nothing, and returning "clicked"; **#16** the org caps lifetime at 366 days and **removes** "No expiration", so the spec's `"none"` was silently downgraded; **#17** `generate()` clicked the confirm button unconditionally 1.6 s after submit, double-submitting whenever no overlay existed and re-rendering the form at its defaults — which is why every failure screenshot showed `30 days` and sent the investigation back to the expiration control that a trace had _already proven correct_; **#18** an in-page `.click()` on the overlay's submit is untrusted and closes it without submitting. **#19 was the one that mattered**: the spec's `description` was 4272 chars, and GitHub drops an over-long submission server-side with **no error anywhere** — no flash, no inline message, no entry in the token list. Cutting it to 191 chars minted on the next attempt with every other field byte-identical. #14–#18 were all real and all fixed, but #19 kept reproducing the same symptom after each one, which is what made this expensive: **when a silent failure survives a correct fix, the fix looks wrong.** Countermeasures now in the engine: `GH_PAT_TRACE=1` prints owner + expiration after every phase (it is what proved the form was right and moved the search to `generate()`); `validateSpec()` rejects `description > 1000` before the browser opens; specs carry long-form rationale in a new **`notes`** field the engine never types; `setOwner`/`setExpiration` assert their own result (**#13**, positive-rejection only, and note the first version scored an unreadable-and-empty read as a PASS). **Proven**: `create` exit 0; vault `ccmax-release:gh.token` moved `0bc5d79ed5c1` → `b1c18a25329d`; `GET /repos/example-org/relay-monitor` → 200 `private:true`; `release-preflight-verify-github-write-access.sh` → **exit 0** with `git WRITE (receive-pack advertisement, HTTP 200)` and `expires 2027-09-05`; `npm run release:dry` → exit 0 (next version 3.3.1). **Controls that fire**: the same verifier exits **11** when only `GH_TOKEN` is injected, and the new description guard exits 1 on a 4272-char spec without writing a token sink. The superseded token (id 17661881, `0bc5d79ed5c1`) was deliberately left LIVE and un-revoked throughout — the new token was minted under a **different name** rather than with `--replace`, so a failure at any point left the operator no worse off.