AGENTS.md · git:20260909.0a77cdb · 2026-09-09 · sha256 540f5b82e6cc54e2
AGENTS.md git:20260909.0a77cdbA
Immutable. This exact content is served forever at /api/v1/blob/540f5b82e6cc54e2.
# AGENTS.md — rules of record for AI-assisted contributions
Terse and imperative by design: this file is meant to be loaded into an AI coding
assistant's context. The narrative version (why these rules exist, setup, PR process)
is [CONTRIBUTING.md](CONTRIBUTING.md). Repo orientation is [CLAUDE.md](CLAUDE.md).
## Mental model (one paragraph)
`gs-admin` is an **artifact-driven** CLI: 8 JSON manifests in the installed npm package
declare every domain action, and each action becomes both a CLI command and an MCP tool
with the same handler. This repo extracts those manifests into `data/catalog.json` (the
**single source of truth**), generates human docs and a wiki from it, and ships
`plugins/gs-superadmin/` — a Claude Code plugin whose mutation guard, cheatsheet, and
ask-rules all derive from that same catalog.
## Generated vs hand-maintained — check before editing ANY file
| Path | Status | Regenerate with |
|------|--------|-----------------|
| `data/catalog.json` | GENERATED | `npm run build:catalog` (needs the pinned CLI installed globally) |
| `reference/domains/*.md` | GENERATED | `npm run build:reference` |
| `wiki/index.html` (~500 KB — never read it whole) | GENERATED | `npm run build:wiki` |
| `wiki/comparison-guide.html` | GENERATED (from the hand-maintained `reference/comparison-guide.md`) | `npm run build:comparison` |
| `plugins/gs-superadmin/reference/*` | GENERATED | `npm run build:plugin:gs-superadmin` |
| `plugins/gs-superadmin/scripts/extract-catalog.mjs` | GENERATED (verbatim copy of `build/extract-catalog.mjs`) | same |
| `plugins/gs-superadmin/scripts/render-cheatsheet.mjs` | GENERATED (verbatim copy of `build/render-cheatsheet.mjs` — the one cheatsheet emitter, shared with the repo build) | same |
| `data/reader-shapes.json` | GENERATED — the plugin's READ SURFACE: per CLI command, the payload keys the KB readers dereference, MEASURED by running every reader over the shared fixture corpus under a recording Proxy (`plugins/gs-superadmin/test/trace-reader-shapes.mjs`); a cross-repo contract the gs-fortress CLI-watch audit reads (`schemaVersion` bumps on any structural change). A reader that reads a new key or drops one changes this file in the same PR — regenerate, never hand-edit | `npm run build:reader-shapes` (no CLI needed — it reads the committed catalog) |
| Everything else — `build/`, the repo-root `test/` (the shared test rig — plumbing only, GP-B5 DS-22), plugin `skills/` `hooks/` `templates/` `test/` and plugin `scripts/` (every script except the two verbatim copies in the rows above), `reference/` concept docs + `workflows/`, READMEs + the plugin's `MAINTAINERS.md` | hand-maintained | — |
Generated files carry a "GENERATED FILE — do not edit by hand" banner, and CI rejects
hand-edits via a rebuild-and-diff check. To change generated content, edit the generator
in `build/` and regenerate.
**Version-citation convention in hand-maintained prose** (established at the 1.0.6
adoption, 2026-07-28): a `v`-prefixed CLI version (`vX.Y.Z`) asserts a CURRENT-version
claim — `build/check-stale-facts.mjs` requires it to equal the pinned `meta.cliVersion`,
so every such citation trips CI on the next upgrade and forces a human re-check. A BARE
version (`X.Y.Z`) records historical vintage ("observed on 1.0.4") and is deliberately
invisible to the checker — it stays true as written forever. Consequences: date a live
observation with the bare form, but keep at least one `v`-prefixed citation on any doc
or section whose claims must be re-verified per upgrade (e.g. the operating-model
known-issues header). Per-fact upgrade review is owned by the external CLI-watch audit
(the gs-fortress repo), which diffs every release against the pin; the checker's tripwire
is the belt to that suspenders.
Inside a **verbatim quotation** — an archived prompt string, a quoted file line, a captured
program output — do NOT demote the prefix to make the checker pass. That silently rewrites
what the quoted source actually said, and where the surrounding text reasons about the
prefix it leaves the record self-contradictory (F-103: an archive paragraph ended up
quoting a bare literal and then certifying that the `v`-anchored pattern matched it).
Demotion remains right for descriptions that merely *name* a version ("citing 1.0.4").
Two cases, because the demotion pass runs at EVERY CLI adoption:
- **`dev/FEEDBACK-archive.md` is exempt from the citation checks entirely**
(`CITATION_EXCLUDE` in the checker). It is append-only frozen evidence and cannot hold a
current-pin claim by construction, so quote it exactly as recorded — prefix and all — and
never edit an archived version or count to make a guard pass. That exemption exists so
the file can keep the promise its own header makes.
- **Everywhere still scanned** (the live `dev/FEEDBACK.md` bus, tracked docs), write a
quoted version as `[v]X.Y.Z`: the brackets are an editorial marker, and the checker's
pattern requires a digit straight after the `v`, so it does not match while the quotation
stays faithful. Use it ONLY inside a quotation — it suppresses the tripwire for any
literal, including a current one, so outside quotations it is a way to hide real drift.
Note the bracket only defeats the `vX.Y.Z` pattern. The count patterns (`N CLI commands`,
`N MCP tools`, `N named operations`, `N defined tools`) and `pkg@X.Y.Z` have **no** marker,
so in a scanned file a verbatim quote of an old count or install command has to be
reworded out of the literal form — describe the phrase instead of spelling it. Both this
file and the bus have had to do exactly that; it is friction, not a defect, and the
alternative (a marker per pattern) would widen the blind spot for no real gain.
## Design tenets — deliberate decisions that look like bugs. Do not "fix" them.
1. The mutation guard **asks, never blocks**. Never return `permissionDecision: "deny"`
for a mutating command — the approval prompt IS the override path. (Two coaching
exceptions deny a *first* offense with a rewrite hint and escalate to ask on repeat:
the shell-safety lint (unquoted `|` / lone `&` from asset-name fragments; `&&` is
never touched) — the command is broken as written — and the variable-subcommand
coaching — `gs-admin $cmd` can't be inspected. A deny that can't be durably recorded
asks instead, so nothing is ever denied forever.)
2. Error-handling asymmetry in the guard hook is deliberate: **fail-open on the hook's
own failures** (never break plain `gs-admin` use), **fail-closed on unrecognized
gs-admin commands** (ask — a newer CLI's mutation must not slip through silently).
3. The hook may only ever **add** an "ask" — never grant an allow. The trust model for
the workspace catalog depends on this.
4. **`_manifest.json` values are untrusted input.** Clamp anything from them (printable
ASCII, 80 chars) before it reaches a permission prompt.
5. **Zero dependencies.** Node built-ins only, everywhere: build scripts, hook, manifest
script, tests. Never add a package. One ratified carve-out (Bradley, 2026-09-01,
GP-B5 DS-42): two CHECKER-ONLY direct devDependencies, `typescript` + `@types/node`
— what the two pins pull, measured (W8.5/DS-51, TypeScript 7.0.2 + @types/node
20.19.43): `@types/node` one transitive typings package (`undici-types`);
`typescript` its own per-platform compiler binaries as optional dependencies
(`@typescript/typescript-<platform>`, all in the lock, one installed per host);
nothing else, and nothing direct — behind the `tsc --noEmit`
JSDoc-contract gate (`tsconfig.json`, `npm run typecheck`, the docs-drift
workflow). Bright lines: the checker is invoked-only (CI + on demand); nothing in
`build/`, `scripts/`, or `hooks/` may import from it; uninstalling it changes
nothing but the gate. `build/check-imports.mjs` enforces both halves as data — bare
specifiers are forbidden tree-wide, and package.json may declare exactly those two
devDependencies and no `dependencies`. The gate is NON-STRICT by decision: it checks
what is annotated or inferable — an unannotated parameter is `any` and unchecked,
and `?string` nullability is documentation except where the **strictNullChecks
ratchet** has reached (started 2026-09-02, GP-B5 W8/DS-46): `tsconfig.strict.json`
extends the base with `strictNullChecks: true` over an explicit `files` list —
`doc-lib.mjs`, `capture.mjs` and `journal-lib.mjs` (capture's import) first — and
`npm run typecheck` runs the base config, then `build/check-strict-list.mjs`, which
runs the strict program once and proves the list IS the surface: tsc checks every
file a listed one imports, so the program's local files must EQUAL the list (a
transitive file is admitted by naming it, never by accident), and every file in
the gate's `STRICT_FLOOR` must still be listed. Ratchet rule: **a file enters the
strict list and never leaves** — the floor is that rule as data; the list only
grows, one file per small change, each admission re-verifying that file's T-1/T-2
nullability claims against the emitter (house rule 13); the base config is never
flipped tree-wide. "tsc green" means green where annotated, not shape-safe — and
null-safe only inside the strict list.
6. New behavior derives from **the catalog**, never from hardcoded command lists. One
sanctioned exception (approved by Bradley, 2026-07-11): `hooks/ask-overrides.json`, a
hand-maintained list that forces "ask" on commands whose catalog `mutating` flag is a
**verified upstream mislabel** — each entry is version-scoped to the CLI version it was
verified on and self-retiring (it applies only while the live catalog still shows the
mislabel), and overrides can only ever ADD an ask, never remove or downgrade one
(tenet 3 applies).
7. The plugin — the guard hook and the link script alike — is **inert outside
`.gs-superadmin/` workspaces** and must never countermand `gs-admin` used on its
own. (`scripts/plugin-link.mjs`, run by the `SessionStart` hook, touches nothing
when cwd has no `.gs-superadmin/` directory — GP-B5 DS-43.)
### Architecture principles (A-1..A-11)
Adopted at the GP-B5 architecture program's C1 review (2026-08-16, Bradley;
maintainer archive holds the full arguments). Condensed by design — each line is
the rule plus why it exists.
- **A-1 · Define once, derive everywhere.** The fix-one-copy-miss-the-sibling
class dominated the finding corpus and is design-preventable; every
consolidation cites it. Its limit is A-2.
- **A-2 · Deliberate duplication names its sync mechanism.** A copy that must
exist (self-containedness tenets, dual-lane byte-identity) is registered with
the mechanism keeping it honest — comment-only sync is the weakest tier:
promote it or accept the risk explicitly (`build/check-doc-drift.mjs` check 9
is the pattern).
- **A-3 · Parse, don't validate — with honesty stats.** Every JSON boundary
parses defensively and reports seen-vs-parsed counts instead of silently
skipping (F-228); typedef headers are the static half of this rule.
- **A-4 · No silent anything.** Fail loud; withhold rather than guess; record
"could not look" as its own state (F-218); structured parts over prose blobs;
null-prototype folds for model-authored keys (F-225).
- **A-5 · Construction > checking > prose, where tenets allow.** A rule carried
by code structure beats a CI check, which beats prose teaching — shared-code
portability rules stopped recurring; prose-taught rules kept regressing.
- **A-6 · Output-identity locks; differential test before any collapse.** A
refactor proves byte-identical output (rebuild + `git diff --exit-code`);
collapsing hand-synced copies requires a differential test proving agreement
BEFORE the collapse.
- **A-7 · Contracts live at the producer, as checkable artifacts.** A shape
consumed in N places is written once, at the file that produces it — the
FROZEN typedef headers (catalog, manifest, KB docs, journal, hook payload)
are this rule executed.
- **A-8 · Prose = judgment · scripts = mechanics · hooks = enforcement · CI =
consistency.** A SKILL.md walking the model through a deterministic procedure
is a script wearing prose; move it.
- **A-9 · Parse the grammar, don't tune the pattern.** A matcher needing its
second boundary tune has the wrong model — redesign, don't widen.
- **A-10 · Single-writer ownership for durable state.** Any durable record gets
exactly one writer from birth (`scripts/manifest.mjs`'s atomic-write monopoly
is the precedent).
- **A-11 · Already-law reaffirmations.** Mutation-proof gate, checked
time-sensitivity, mechanical-check closure, guard fail-open/fail-closed
asymmetry, bulk-data-never-in-context: every change preserves all five.
### Considered and rejected — do not re-propose without new evidence
Adjudicated at the same C1 review; each row records what was rejected, why, and
the kernel kept instead.
| Rejected | Why | Kept kernel |
|---|---|---|
| Class hierarchies / OOP-first design | zero corpus findings with an OOP cure | factory functions + plain data + typedef contracts |
| DI containers / injected-double architecture | the no-import tenets forbid imports exactly where DI would inject | parameter-level injection |
| Runtime deps, frameworks, or an in-house general validator | zero-dependency tenet 5; no-install deployment | typedef contracts + runtime honesty stats (A-3) |
| Full TypeScript (any compile-step shape) | breaks dual-lane byte-identity and ship-as-source | JSDoc + `tsc --noEmit` `checkJs` shape, gated separately |
| "Design can buy test honesty" | the fixture-gap finding class is 0% design-preventable | the procedural mutation-proof gate; a shared test rig priced as maintenance only |
| Consolidating tenet-locked duplicates | the tenets ARE the design; the copies are the cost of self-containedness | strengthening a copy's sync mechanism stays fair game |
| A separate ADR directory | a second decision home drifts from the enforced one | decisions live where their enforcement lives (Review-gate rules) |
| Schema files + a validator artifact | a third artifact nothing executes | contracts-as-data inside the enforcing check/script |
## Writing skills (SKILL.md files are executed by an LLM — ambiguity is a bug)
- Fenced blocks are **literal output**. No annotations, arrows, or meta-instructions
inside them; render rules go in the prose above the fence.
- Every placeholder needs an explicit substitution rule, including how it repeats for
N items.
- The operating model, the managed CLAUDE.md block, and the cheatsheet are loaded into
the same session: statements about the same topic must agree. Grep for the topic
across `templates/`, `skills/`, and the cheatsheet renderer before editing any one.
- Bulk JSON goes through `scripts/manifest.mjs` / `scripts/describe-batch.mjs` and files
on disk — never through model context.
- Ask-once interactions need a durable marker (manifest field or marker comment) so
re-runs stay idempotent and never re-ask.
- **Paraphrase-plus-pointer — one canon per rule** (GP-B5 C1, 2026-08-16): when the
same rule must appear in several skills, exactly one site carries the canonical
statement; every other site carries a one-line paraphrase plus a pointer to the
canon — never a full second copy (it drifts), never a bare pointer (context-blind).
- **Address bundled scripts as `.gs-superadmin/plugin/…`, never the placeholder**
(GP-B5 DS-43, 2026-09-04): `node .gs-superadmin/plugin/scripts/<x>.mjs …` — a
relative forward-slash path every tool shell resolves, through the directory link
`scripts/plugin-link.mjs` maintains (created by setup, refreshed by the
`SessionStart` hook to whatever plugin the loader actually loaded). The
`${CLAUDE_PLUGIN_ROOT}` placeholder is substituted by the plugin loader only and
expands to empty in bash AND PowerShell when a literal survives (F-175); it has
exactly two homes — `hooks/hooks.json` and setup's first-run link fence — and
`build/check-doc-drift.mjs` check 10 reds it anywhere else by name, and reds any
REFERENCE to the retired warning by its vocabulary (the check's phrase list is the
data; F-387 reopen: a rewrite that deletes a canon by its token leaves the sentences
that point at it, and those then instruct the opposite of the link bullet). Reference
files and prose paths for the model to READ use the same link path.
## Testing — never against real state
- **Never run `gs-admin login`, `gs-admin config`, or any mutating command against a
real tenant or the real `~/.gs-admin/` during development.** Use the fixture harness;
it builds fake workspaces in the OS temp dir.
- Verification commands (run all that apply before opening a PR):
```bash
node plugins/gs-superadmin/test/guard-fixtures.mjs
node plugins/gs-superadmin/test/guard-oracle.mjs
node plugins/gs-superadmin/test/manifest-init.mjs
node plugins/gs-superadmin/test/manifest-ops.mjs
node plugins/gs-superadmin/test/domain-candidates.mjs
node plugins/gs-superadmin/test/describe-batch.mjs
node plugins/gs-superadmin/test/relationships-build.mjs
node plugins/gs-superadmin/test/template-doc.mjs
node plugins/gs-superadmin/test/program-doc.mjs
node plugins/gs-superadmin/test/journal-ops.mjs
node plugins/gs-superadmin/test/change-request-fixtures.mjs
node plugins/gs-superadmin/test/report-bug-fixtures.mjs
node plugins/gs-superadmin/test/jo-report.mjs # + test/jo-report-<mode>.mjs siblings as modes land (CI globs jo-report*.mjs)
node plugins/gs-superadmin/test/tenant-deps.mjs
node plugins/gs-superadmin/test/er-count.mjs # email-report page-entry counter (GP-B5 DS-29 — shipped from the skill's inline transcription)
node plugins/gs-superadmin/test/er-gaps.mjs # email-report gap work-list builder (same DS-29 pair)
node plugins/gs-superadmin/test/capture.mjs # the shipped capture helper: clean-write + normalize encodings, failure honesty, and the shared read-only gate in its capture parameterization (DS-17)
node plugins/gs-superadmin/test/doc-lib-fixtures.mjs # DIRECT suite for the portability layer + shared helpers (kills the F-113 mutant class at the definition site)
node plugins/gs-superadmin/test/contract-conformance.mjs # executes T-1/T-2/T-3/T-4 against produced artifacts (manifest verbs + catalog key sets + stub-marker pins + journal entry grammar through both real writers)
node plugins/gs-superadmin/test/plugin-link.mjs # the workspace plugin-link writer (DS-43): create / no-op / repoint stale + dangling / refuse a real directory untouched / target survives removal / target from self never from env / check-ignore belt reports never fails / hook-mode JSON exit 0 on failure / CLI failure non-zero / the SessionStart hooks.json entry
node plugins/gs-superadmin/test/scaffold.mjs # the workspace scaffold writer (F-396): fresh / current / refreshed (unedited, template moved) / offer (edited, template moved → .new beside it, never overwritten) / accept-keep-defer record the answer once / legacy adopt / removed stays removed / pack only when adopted / check is read-only / CRLF-insensitive hashing
node plugins/gs-superadmin/test/trace-reader-shapes.mjs # the reader-shape MEASUREMENT (CLI-adoption arm): engine self-tests with their mutants, then every KB reader traced over the shared corpus — every command observes keys, every finding-backed floor key is still read, the JSON-parsing-script enumeration closes both ways (row or rule-out; every named file exists)
node build/test-comparison-html.mjs # comparison-HTML generator parsing fixtures + fail-loudly + byte-stability
node build/test-emit-reader-shapes.mjs # the read-surface emitter: committed data/reader-shapes.json equals a fresh emission, byte-stable, catalog-joined, floors present; refuses an unknown command; the tracer's floor / enumeration / dropped-reader reds driven from outside
node build/test-wiki-html.mjs # wiki generator rendering fixtures + outbound-anchor resolution + byte-stability + the T-9 v4 grammar pins (the ONE block parser + inline core in build/lib.mjs, each pin mutation-proved in memory — run it after any edit to lib.mjs's grammar or either generator's renderer)
node build/test-render-cheatsheet.mjs # the one cheatsheet emitter: repo/workspace lane parity + rendering fixtures
node build/test-check-instance-data.mjs # the instance-data gate itself: UTF-16 files are decoded and scanned, skipped-binary accounting
node build/test-check-stale-facts.mjs # the stale-facts gate itself: bus code-context exemption (F-275/F-328) — indented quotes exempt, line-leading fences refused, prose stays checked, bus-only; the W10 handoff conventions (Blind spots line, pred: rows, Class: keys, Sibling sweep)
node build/test-extract-catalog.mjs # the dual-lane catalog emitter: fixture-package derivations + T-1 conformance on the committed catalog
node build/test-defect-classes.mjs # the defect-class DETECTORS in memory (milliseconds): detectRow/callClose over every mechanical row, exact hits per decision point (F-364, F-365)
node build/test-check-doc-drift.mjs # the doc-drift gate itself: green baseline on the real tree, then per-check mutants must go red (incl. check 19's CONSUMER pins: the F-365 site, allowances, unreadable files, the scope floor)
node build/test-check-imports.mjs # the import-graph gate itself: lexer + declaration tables (the W0 mutation battery, committed)
npm run typecheck # the JSDoc-contract type gate (tsc --noEmit, checkJs): T-1..T-9 checked wherever a consumer is annotated — needs `npm ci` once (the tenet-5 carve-out's two checker-only devDependencies); then build/check-strict-list.mjs runs the strictNullChecks ratchet (tsconfig.strict.json) once and proves its two invariants — the strict program's files EQUAL the list, and no floor file has left it
node build/test-check-strict-list.mjs # the strict-list gate itself: a listed file pulling in an unlisted import, and a floor file dropped from the list, both go red (needs `npm ci`, like the gate)
node build/build-plugin-gs-superadmin.mjs # rebuild the plugin bundle… (two lines, not `&&`: PS 5.1 can't parse `&&`)
git diff --exit-code plugins/gs-superadmin # …then: the rebuild produced no drift
claude plugin validate ./plugins/gs-superadmin --strict
claude plugin validate . --strict
node build/check-stale-facts.mjs # hand-written docs cite the current CLI version/counts, spell flags as the catalog does, and relationships-build's SEMANTICS_BASIS cites the pin; the live bus carries the W10 handoff conventions
node build/check-doc-drift.mjs # skill lists + invocation labels in READMEs match the plugin; dev-only content contained; test suites all listed here; defect-class rows swept tree-wide (check 19, build/defect-classes.mjs)
node build/check-imports.mjs # every real import edge matches the declared layer graph (lanes, zero-import files, dual-lane import-free rule — the declaration lives in the check as data)
node build/check-instance-data.mjs # nothing tenant/instance/org-specific committed
npm run build # only when build/ scripts or the catalog changed (needs the pinned CLI).
# Editing docs a generator EMBEDS (reference concept docs + workflows → wiki;
# comparison-guide.md → its HTML) also changes generated output: run the five
# derived generators CI's drift job runs (no pinned CLI needed — they read the
# committed catalog): build-reference, build-wiki, build-comparison-html,
# build-plugin-gs-superadmin, emit-reader-shapes. This list, CLAUDE.md's and
# README.md's are held to package.json's build script by check-doc-drift
# check 21 (F-407) — edit package.json first, then the prose it reds.
```
- **Probing the guard hook directly** (tester sessions, bus verifications — the recipe
every session otherwise re-derives from the hook source): drive it with Node
`spawnSync(process.execPath, [hook], { input: JSON.stringify(payload), encoding:
"utf8" })` — never by piping a string from PowerShell: its pipe re-encodes stdin
(BOM + CRLF prepended/appended), which silently no-opped every payload until the hook
learned to tolerate the BOM, and the lane remains untrustworthy for byte-exact
probes. The payload's `cwd` must
sit inside a directory containing `.gs-superadmin/` (throwaway temp dir), which may
omit `catalog.json` to exercise the plugin's bundled fallback; the PRODUCTION half of
a prompt expectation needs a sibling tenant dir whose `_manifest.json` records
`"environment": "production"`; and ask reasons cite the resolved canonical catalog
path (`journey programs save`), not the alias spelling the probe used.
If the probe includes the PostToolUse/journal half, the hook is NOT self-contained
there: it imports `../scripts/journal-lib.mjs` at runtime, so a hook-only scratch
copy answers PreToolUse correctly but can never write a journal — an absence that
reads exactly like a journaling defect (F-212: it faked the pre-fix F-194 symptom
during a verification round). Copy the plugin's `scripts/` dir alongside the copied
hook, or drive the hook in place. The failure IS loud — stdout carries a
`systemMessage` naming the missing module, pinned by the guard-fixtures
"missing journal-lib degrades to systemMessage alert" check — so read the probe's
raw stdout before concluding "no journal".
- To rebuild, install the **pinned** CLI version from `data/catalog.json`
`meta.cliVersion` — never `@latest`, which upgrades what the repo documents and
dirties every generated artifact.
- Bump `plugins/gs-superadmin/.claude-plugin/plugin.json` `version` on any user-visible
plugin change, and add a matching entry to `plugins/gs-superadmin/CHANGELOG.md`.
Documentation-only changes need neither — unless they change SHIPPED plugin bytes
(a comment edit inside a shipped plugin file counts): any shipped-byte change bumps
and logs, however doc-only it reads (decree, Bradley 2026-08-16, GP-B5 W0; precedent
0.31.3 / 0.31.4 / 0.31.8, each a header-or-comment-only shipped change).
## Review-gate rules (closing findings durably — F-160)
- **Contracts as data** (GP-B5 C1, 2026-08-16): a site list or enforcement table a
check consumes is carried as data inside the check (or a file the check reads),
never as prose the check re-parses — prose lists go stale (the F-159 class).
`build/check-imports.mjs`'s layer declaration is the pattern.
- **Decisions live where their enforcement lives** (GP-B5 C1, 2026-08-16): a design
decision is recorded at the site that enforces it — a tenet line in this file, a
register/declaration entry inside the enforcing check, a WONTFIX ruling on the bus —
never in a separate decision directory, and not in a generated index either (the
DS-21 part 3 index was measured and cut 2026-09-07: the homes below hold about fifty
entries, most of them in this file, and a rendered table would re-list them for no
consumer). Discovery hint — enumerate by home, each greppable by its own shape:
the numbered items of "Design tenets" (a dated exception inside one reads
"approved by …, date" or "ratified … (Bradley, date)"); the `A-n ·` bullets of
"Architecture principles"; the rows of the "Considered and rejected" table; the
`Class: <key>` bullets under "Defect classes are registered where a check reads
them" below; and the register tables inside the checks — any `const` in
`build/*.mjs` whose rows carry an `id`/`why` or whose name reads `SANCTIONED_*`
or `*_FLOOR` — e.g. `RESTRICTED`, `SANCTIONED_DYNAMIC` and `LAYERS` in
`check-imports.mjs`, `SANCTIONED_PORTABILITY_COPIES` in `check-doc-drift.mjs`,
`STRICT_FLOOR` in `check-strict-list.mjs`, `DECLARED` in `sweep-twins.mjs`, the rows of
`build/defect-classes.mjs`; examples, never the roster — the tester's shape sweep at
the 0.36.3 gate found two the first hand list omitted, so enumerate by the shape. WONTFIX rulings are the seventh home — sections whose
header matches `^## F-.*WONTFIX` on `dev/FEEDBACK*.md` (the dash is em or hyphen
by era, so never anchor on one: an em-dash-only pattern finds 4 of 13) — and
dev-branch only: the public tree's bus starts empty, so finding none there is
expected.
- **Shared prose sits on the highest rung that fits it — and rung 0 is to remove the
cause** (GP-B5 DS-43, 2026-09-04): a rule several skills must carry lives at one of
these places, chosen top-down; "can't import at runtime" is a fact, not a limit.
| Rung | Mechanism | Fits when | In the tree |
|---|---|---|---|
| 0 Remove the cause | change the mechanism so the rule has nothing to say | the rule warns about a failure the design can make impossible | the workspace plugin link retired the seven-skill surviving-literal family (DS-43) |
| 1 Enforcement | a hook checks it at the tool-call boundary | decidable from the command, and the plugin is loaded when it matters | the mutation guard, the pipe-safety lint |
| 2 Code | a script every skill calls | a deterministic procedure | GP-B3: `--paginate` retired 5 prose sites |
| 3 Session-global doc | one statement every session reads | applies session-wide and the doc exists by then | `templates/operating-model.md`, the managed CLAUDE.md block |
| 4 Build-time expansion | one fragment rendered into each file | text must be physically present per independently loaded file | shelved with a trigger (DS-43 note §8): a prose family with a recorded drift finding that cannot move to rungs 0–3 |
| 5 Checking | hand copies held to a shape by a check | copies must differ per site | check 16 (token pre-flight) |
| 6 Prose discipline | paraphrase-plus-pointer | small, judgment-laden, low churn | the ground-rules blocks (2 sites, 0 findings) |
- A finding whose What is a **doc-vs-tree contradiction** is not closed by correcting
the prose: land the mechanical check that locks the claim to the tree in the same
fix, or state in the Fix note why the claim is not mechanically checkable.
(`build/check-doc-drift.mjs` checks 5 and 9 are the pattern; the F-151/F-158 pair —
the same sentence stale twice, the second time inside the commit that rewrote it —
is what prose-only closure produces.)
- A verification probe that **discovers a defect** or **verifies a whole-tree claim**
is a guard prototype, not scratch: the Fix/Verified note must record whether the
probe should be promoted to a committed check, and log a finding when it should.
(The tree sweep that found F-158 became check 9.)
- A round is **assessed from open PRs as well as the dev log** (`git log` plus
`gh pr list`) — a fix riding an unmerged branch is invisible from dev alone. And the
role that lands a fix on an unmerged PR says so **in a bus comment on dev** — branch
and PR number — not only inside the entry's Fix note on that branch. (F-161: a fix
recorded only on its own unmerged branch was re-implemented from scratch, with a
reworked PR, by the next session.) The pointer covers **every status transition, not
just FIXED** — it is re-stamped whenever the branch's statuses change: a verdict
(VERIFIED, or back to OPEN), a WONTFIX — so the dev view never lags a transition
behind the branch. (F-223: three verdicts riding PR #76 read OPEN from dev under a
pointer still saying FIXED — the F-161 hazard reproduced one transition later.) The
same pointer **claims the finding number(s) its branch consumes, at logging time,
not at merge**; and a session picking a next number takes the max across both
`^## F-` section headers and the numbers claimed in header comments, after a fresh
pull. (The F-215 collision: two concurrent branches each took the same announced
next number, and the losing entry had to be re-logged as F-223.)
- **Every open PR to `dev` is named at a session's kickoff and close-out, bot PRs
included** (`gh pr list --base dev`), each with its check state, and a red one is
DISPOSITIONED — merged, closed with a reason, or carried forward explicitly in the
handoff — never left unmentioned. `dev` has no branch protection, so a red PR blocks
nothing and alerts no one: it just sits. (B12, 2026-09-02: Dependabot's first npm PR
opened two minutes after a release, red on the type gate, and sat through a whole
wave whose first command had listed it. The bump was a TypeScript major whose
strict-by-default setting silently reversed the recorded NON-STRICT decision —
which is why `tsconfig.json` now states `strict` explicitly, and why majors arrive
as their own PRs to be adopted deliberately rather than bundled or ignored.)
- An **"Actions isn't running" symptom is a mergeability check first**, not a platform
outage: a PR in CONFLICTING state gets no `pull_request` check suite at all — the
merge ref cannot be computed, so GitHub silently creates no suite, no run, no
failure. `gh pr view <n> --json mergeable,mergeStateStatus` answers it in one call;
resolve the conflict (the bus's section format merges by keeping both sides) and the
suites fire on the next push. (The 2026-08-09 round spent an empty-commit nudge and
a close/reopen theory on what was a conflicted bus file.)
- A guard or fixture landed to close a finding is **not accepted until it is
mutation-proved to FAIL** on the defect it claims to catch, and the Fix note records
the mutant. A guard design proposed in a bus entry or a review comment is a
**hypothesis to validate, not a spec to implement literally** — where the shipped
shape differs from the proposed one, the Fix note says so and why. Same standard for
coverage: **"no existing suite catches X" is a mutation-test result**, not an
inference from reading the suites. (F-188 proposed a fixture whose every pass saw the
same two ids in the same order, so a writer regressed to an in-run-only Set passes all
four of its assertions — readable, green, and locking nothing; the same entry's
coverage claim named a regression the existing unit checks already caught while
missing the arrangement that genuinely escaped every suite. F-189 is what that pair
cost, and it was caught only because the implementing session mutation-tested the
draft instead of trusting the design.)
- CI evidence for an **advisory or canary leg is read per JOB, not from the run
conclusion**. A leg running `continue-on-error` cannot fail its run, so
`gh run list` reports `success` for a run whose canary leg went red; verify a
rollout with `gh run view <run-id> --json jobs` and read each leg's own
conclusion. (Wave 5's matrix canary: the windows leg was red on two commits of
PR #71 while both runs' conclusions read `success` — and that red leg was the
entire point of the canary, being the F-191 defect it was rolled out to catch.)
- **Verification must be able to surprise** (GP-B5 W10 / B14, 2026-09-03 — derived from
the record, not the ideas: across F-300..F-359, 20 of 31 tester findings were a NAMED
class recurring one site over, and 23 fix rounds were verified from the arms just
written and had their hole found on the next verdict; every chain that switched to an
independent frame closed in one round). The test for any verification claim is the
**deletion test**: delete the sentence stating the property — does the property still
hold by construction (a tool, a gate, a generator)? If not, the claim is
judgment-selected and says so; it is never "complete". Four rules — two are STRUCTURE
the live bus carries and `build/check-stale-facts.mjs` refuses, two are obligations
with a greppable hook:
1. **Enumeration source** (obligation). What-to-test derives from the ARTIFACT — the
code's decision points (`build/sweep-fence-grammar.mjs --census` for the fence
core; an in-process differential over the changed function elsewhere), a spec
oracle, or an independent frame such as prior rounds' repros (the F-351 acceptance
table) — never from the arms, messages, or fix scope just written. Every copy-out
names its generator on its header line (`sweep (source: …)`); a sweep whose mutants
come from the arm list is labelled a vacuity check on the arms, never closure.
(F-326: three hand-picked mutants over seven arms; F-329→F-337: five rounds of
enumerating from the previous round's failure.)
2. **Prediction first** (structure). Expected kill/survive is written per point
BEFORE the run and copied out beside the measurement: every indented copy-out row
recording KILLED or SURVIVED carries `pred:KILL` or `pred:SURVIVE`; a
disagreement is marked `MISMATCH` and IS the information — killed when expected to
survive means the mechanism is not understood. Sections from F-360 on; the sweep
tool prints the tokens. A verbatim quote of an older table is introduced by a line
saying "quoted verbatim" and is exempt — a quoted record is never edited for a gate. The timing is the obligation the check cannot see; its
only tell is a table that never mismatches. (F-358, F-359: predicted = measured
closed each round in one verdict.)
3. **Declared blind spots** (structure). Every handoff writes
`Blind spots (<token>): …` directly under the bus's `Under test:` line — the
frames not used, the measurements skipped with the argument stated, or
`none — <reason>`. Keyed by the token, so a fresh handoff with a stale line is red
by construction. The tester states its own the same way in its verdict comment
(obligation — there is no tester token to key on). The next role aims there first:
division of labour, not a race. (F-338/F-339 declared theirs; both closed in one
round.) A blind spot is for what the round CANNOT measure — no CLI at the version,
no host, no tenant. A claim about a shipped script's behaviour that a fixture can
execute offline in minutes is measured, never declared: F-388 round 1 declared two
migration-note claims as blind spots (3) and (5); both were false, both were one
`upsert-batch` run on a scratch manifest away, and the round ping-ponged for it.
4. **Budget by uncertainty** (obligation). A measurement whose outcome is
structurally determined — re-running mutants a suite already killed after only
ADDING arms; red cannot go green — is skipped and its argument written on the
blind-spots line, never re-measured; the energy goes to what can surprise.
(F-336, F-338.)
- **Defect classes are registered where a check reads them** (GP-B5 W10). A defect that
recurs by CLASS gets a key in `build/defect-classes.mjs`: a MECHANICAL row when its
detector is a pattern with zero false positives at gate cost — swept by
`build/check-doc-drift.mjs` check 19 on every run, with an allowance ledger (sanctioned
files, each with a reason) checked both ways so a stale allowance reds too — or a
MANUAL entry (key only) whose recipe and cost are one of the `Class:` bullets below,
cross-checked with the key list both ways. Graduation is that property, never a count
(a detector with a knob, like `build/sweep-twins.mjs`, is an instrument a recipe runs,
not a row). INSTANCES live on the bus: a finding of a registered class carries
`Class: <key>` after `Severity:`, and its FIXED note carries `Sibling sweep:` — the
check-19 pass line for a mechanical class, the recipe's copy-out with its cost for a
manual one, or `batched — <reason>` on a polish finding (the valve). From F-436 on,
its FIXED note also carries `Judge:` — what decides the fix's correctness INDEPENDENTLY
of the fixer's list (an oracle, a differential against the real shell or tool, the
tester's live arm) — and a section reopened twice is not FIXED again without
`Redesign:`, naming the model replaced (the F-436 arc: two boundary tunes judged
against the fixer's own lists, both reopened by the next spelling bash accepted; the
lexer plus the shell-as-oracle test closed it). check-stale-facts refuses both;
`grep -n '^Class: <key>' dev/FEEDBACK*.md` enumerates a class, and no file copies the
list. The class list is human input and nothing claims it complete: a new class arrives
as a finding and gets a row or a bullet then. (F-328's sibling sweep ran BY EYE and
cleared scanFenceMarkers as "not this class"; F-329 was that site, the same day — a
recipe is not a mechanism.) Keys are append-only — archived instances are frozen
text and the enumeration grep spans both bus files. Manual classes, recipe · cost:
- `Class: reader-shape` — a reader assumes the producer's shape or value format and
the fixture certifies the reader (F-342, F-343, F-345, F-353, F-355, F-358). Recipe:
the fixture is CAPTURED output or derived from the producer's source at the pin, the
keys read are a declared typedef under the tsc gate (F-343's M5), and every external
reader gets one captured payload per pin adoption (dev/VALIDATION.md arms). Cost: one
read-only call per reader.
- `Class: consumer-parity` — one rule with N consumers or copies, and a fix that lands
in one of them (F-306, F-310, F-311, F-320, F-349, F-354/F-355). Recipe:
`node build/sweep-twins.mjs` (identical line-runs across production modules, ~1 s;
an instrument, not a gate) plus `git grep '<symbol>('` for every consumer of a shared
symbol the fix touched — the fix reaches all of them or the note says why not. Cost:
seconds.
- `Class: unpinned-arm` — a decision point of new code that no committed arm kills
(F-314, F-315, F-324, F-326, F-327, F-332, F-335..F-337). Recipe: enumerate the
changed function's decision points from the CODE at token granularity (every
conjunct, quantifier bound, branch, loop bound, return field), predict each, mutate
each in an in-process differential (seconds — F-338), pin the survivors or ledger
them with reasons compared both ways. Cost: minutes per function.
- `Class: malformed-shape-readers` — two readers of one user-editable file disagree
on a malformed shape (F-357). Recipe: `git grep` every reader of the file the fix
touched and drive each with the same malformed value. Cost: seconds.
- `Class: fence-grammar-copy` — a new copy of a fence-delimiter grammar that
re-decides pairing on its own (F-323, F-328, F-329 — three rounds per copy).
Demoted from a mechanical row in the W10 review round: a needle list caught two
spellings and missed six, and a 3-backtick-run token matches the emitters — no
zero-false-positive pattern exists. Recipe: `git grep` the in-scope lanes for
triple-backtick / triple-tilde runs and run-quantifier regexes on non-comment
lines, and name the homes (scanFenceMarkers, BUS_FENCE_DELIM_RE,
readAliasConvention, extractFencedJson, jo-report's fence parse, the T-9 block
parser in build/lib.mjs — one home since T-9 v4, formerly two renderers);
`build/sweep-fence-grammar.mjs` is the census for the core. Cost: seconds.
- `Class: false-rationale` — a shipped note states a false REASON for correct advice,
so the failure the note promises cannot occur and any drift check resting on that
promise is dead (F-416: audit's "`id` yields `undefined`" while `id` exists and differs;
deprecate's "the catalog has no schedule read command" while two exist). Recipe: for
every explanatory clause in a skill's payload-shape or CLI-surface notes, check the
REASON — not the instruction it supports — against the installed catalog or one
captured payload; write what the command does, never what it is presumed not to be.
Cost: one read per note.
- `Class: recording-blind-lookup` — a consumer keys per-workspace RECORDED data by a
hardcoded default or a namespace spelling instead of reading the recording, so the
lookup is right on the workspace it was written against and silently empty or forked
on any other (F-429: tenant-deps' folder-name constants read 0 connection, report and
connector-job docs on a workspace that recorded `connections` / `report-reports` /
`connector-jobs`, with no caveat; refresh's 1.0.9 example passed `--domain connectors`
into such a workspace and forked the inventory). The recording of record for a domain
is `domains_indexed[<name>].listCommand`, resolved through the catalog (doc-lib
`recordedDomainsByPath`). The doc half is MECHANICAL since the fourth pass: the lane
vocabulary has one home (doc-lib `RECORDED_LANES`, which every reader derives its lanes
and defaults from), and `build/check-doc-drift.mjs` check 22 reads the folder literals
out of that source and refuses any RUNNABLE line in a skill or template — a fence
line, or an inline command span — that names one as a `--domain` value, an
`--out-dir` segment or a `<slug>/<folder>` path; prose that merely mentions a folder
passes. Three hand sweeps each missed one shape (a lookup, a `--domain`, an
`--out-dir`) before the enumeration was made mechanical. Recipe for the code half:
grep the shipped scripts for literal
domain names used as lookups (`DOMAINS`-style constant maps, `opt("--x-domain",
"<literal>")` defaults, `--domain <literal>` in runnable skill examples); each either
resolves from the manifest with the literal as the no-recording fallback, or is a
placeholder the reader fills from `report`'s `byDomain`. Cost: minutes.
- `Class: outcome-from-proxy` — a ledger or report field asserts an OUTCOME from a
proxy signal that describes a larger unit than the thing the row is about (F-428: the
change journal wrote "completed" from the harness's success event for a mutating call
that sat before a `|` — the pipeline succeeded, the call exited 1). Recipe: for every
field that states an outcome, name the signal it is derived from and the unit that
signal describes; where the units differ, the word is qualified IN the field (not in
a trailing parenthetical), and the qualifier says which signal was missing. Cost: one
read per outcome-writing site (the guard's journal, journal.mjs, report-bug's
template).
- `Class: field-from-adjacency` — a ledger or report field is filled by scanning ADJACENT
tokens past the boundary that defines it, so it states as fact words the parser never
attributed to that row's unit (F-431: the change journal's `- target:` collected words
until the next operator token, so a two-line command journaled line 2 as line 1's
target, a heredoc opener carried its redirection word and body, and a body hit swallowed
the rest of the body and the command after the terminator). Recipe: for every field
assembled by walking tokens, name the boundary that defines the field's unit (segment
start, redirection, heredoc data, body line) and stop at it from what the tokenizer
RECORDED, never from operator adjacency alone; pin one arm per boundary plus the
controls that must keep collecting (a redirect after ordinary arguments, a backslash
continuation). Cost: one read per token-walking field writer (the guard's argument
collector; any report that quotes "the rest of the line").
- `Class: quote-erased-grammar` — a grammar decision (is this word an operator, a
redirection, a heredoc delimiter) is made on the ASSEMBLED word after quote removal, so
quoted or arithmetic text is read as shell syntax (F-433: the tokenizer matched `<<` on
the finished word, so `grep '<<EOF' f.md` and `$((1<<8))` opened a phantom heredoc that
swallowed the next line's real mutation as inert body). Recipe: decide syntax where the
quotes are still visible — in the character loop, with a per-word flag the assembler
consults — never on the joined word; pin the quoted, the arithmetic and the spaced
spellings beside the real one. Cost: one read per operator the tokenizer recognizes by
word shape.
- `Class: redirection-as-boundary` — a token walk stops at a REDIRECTION as if it were a
command separator, so a redirection standing before the words the walk needs empties it,
and an empty walk is read as "no invocation" (silence) rather than "an invocation whose
words stand past the redirection" (F-436: the guard's argument collector broke at a
heredoc word and at every operator token, redirections included, so
`gs-admin <<EOF jo p save` and `gs-admin > out.txt jo p save` ran a mutation with no
prompt and no journal row — the first a regression of the F-431 target boundary, the
second older than it). Recipe: for every token walk, split its stop set into separators
(they end the unit) and redirections (step over the operator word and, when it stands
bare, its target word — from what the tokenizer RECORDED, never from the word's text).
Which characters FORM a redirection is read from ONE vocabulary the tokenizer owns (the
operator list, longest first, fd-optional) through ONE predicate — never a clause per
metacharacter: the first F-436 fix wrote an `&` clause for `2>&1` and left `|` in
`>|` for the tester to find, the same defect one layer down. Pin the leading position
of every operator in that vocabulary GENERATED from it (glued, spaced, fd-prefixed),
the constructs beyond it (process substitution as a redirect target, as an argument,
and with a mutation inside), the trailing controls, and a quoted look-alike that must
stay an argument. Cost: one read per token walk that consults an operator set, plus one
per tokenizer that decides an operator character by character. End state (F-436, third
pass, after bash's `{varname}` fd prefix reopened it): the whole character loop runs on
grammar tables — control operators, redirection operators with the COMPLETE fd-prefix
grammar, the nesting openers and closers — and the proof is a shell-as-oracle
differential (`test/guard-oracle.mjs`: generated lines through real bash and PowerShell
with a recording shim; the guard must ask on every line the shell executed), not a list
the fixer wrote.
- `Class: unread-anchor-line` — a header line that a rule ANCHORS on is not itself read by
any gate, so a hand edit can delete or replace it and every gate stays green (F-439: a
builder's round-block script REPLACED the bus header's Under test line with the Blind
spots line; the Blind spots rule keys on that line, and with no line there was nothing
to key on — the tester compared the canary against a commit instead). Recipe: for every
structural rule a gate enforces, name the line or field the rule anchors on and assert
its presence, uniqueness, and the fact it carries (here: exactly one Under test line,
and the canary at the declared location names the same handoff); pin the deleted, the
duplicated and the mismatched shapes in the rig. Cost: one read per rule in
build/check-stale-facts.mjs's bus block.
- `Class: name-spelling` — the binary is spelled on the line in a form the word scanner
never matches, so the invocation drops out of the scan: a substitution, an expression
or a variable standing where the command name goes, a quoted or glued spelling (F-441:
`$(which gs-admin) jo p save`, `` `which gs-admin` jo p save ``, `"$(which gs-admin)"
jo p save`, PowerShell's `& (Get-Command gs-admin) jo p save` and `n=gs-admin; $n jo
p save` all ran a mutation in silence; F-109 case, F-111 launcher suffix, F-244 the
brace-glued name and F-250 the assignment prefix were instances closed one at a time).
Recipe: for every scanner keyed on a literal name, read the shell's expansion grammar
for the ways a command NAME is computed and treat each as an unreadable spelling of
the name at command position — fail closed, with the arguments after it; generate
every such form in the shell-oracle suite (`test/guard-oracle.mjs`) so a spelling the
scanner does not match is measured against the real shell, never against the fixer's
list. Cost: one read per name-keyed scanner, plus the oracle rows.
- `Class: second-scanner` — a reader re-derives grammar from the text — its own quote
loop, its own operator clauses, a regex over a word's text — instead of consulting the
tokenizer's record, so the two disagree exactly where the grammar is subtle (F-443:
the guard's pipe-safety lint scanned the raw command with its own quote state and
`||`/`&&`/`|&` clauses and a text walk re-deriving the redirection vocabulary, so a
`|` inside heredoc DATA drew a coaching deny on a line carrying a real mutation, and it
read `word&>file` as a redirection where the lexer read a background; the here-string
payload reader re-matched the fd-prefix grammar on the word). Recipe: `git grep` every
function that receives the raw command text or tests a word with a grammar regex; each
consumes the tokenizer's record (words, starts, redirs, subs, spans, quoted) or the
record grows a field it needs — the tokenizer is the ONE place a quote or an operator
is decided. Cost: one read per reader of the command text.
## Data hygiene — nothing instance-specific, ever
This repo may be open-sourced. Nothing tenant-, instance-, or org-specific may be
committed anywhere — code, test fixtures, docs, **commit messages, and PR bodies**.
Concretely: no real tenant slugs or URLs, no live asset names/ids/counts, no local
user paths (`C:\Users\<name>` style), no personal email addresses — security reports
go through GitHub private advisories (SECURITY.md), not an email address.
- Fixtures and doc examples use **fictional tenants** — the `acme-prod` /
`acme.gainsightcloud.com` pattern already used throughout `test/` and the docs.
- Live findings and upstream bug reports are **archived locally, outside any git
repo** — never committed here, not even under `docs/`.
- CI enforces the committed-file part on every PR: `build/check-instance-data.mjs`
(docs-drift workflow). Its patterns are generic by design — the check itself must
never contain a sensitive string. Org-specific strings to detect go in the
git-ignored `gs-admin-explorer.config.json` (`privateInstancePatterns` — see the
`.example.json`), never in the repo.
## Reading order before touching…
- …anything: `CLAUDE.md`, then the table above.
- …the plugin: `plugins/gs-superadmin/README.md`, then `reference/building-on-gs-admin.md`.
- …the guard hook: `hooks/gs-admin-guard.mjs` top comment + `test/guard-fixtures.mjs`.
- …a skill: that SKILL.md end to end, plus `templates/operating-model.md` for anything
the skill's output must agree with.
- …exact CLI flags/tool names: `data/catalog.json` (or `reference/domains/<domain>.md`)
— never guess from memory; the upstream package README's tool count is stale.
- …a comment citing `F-nnn`, `DS-nn`, `Wn`, `ER-nn`: those are provenance tokens, not
links — the reasoning is in the comment beside them (CONTRIBUTING.md, "Reading the
citations in code comments"). On this repo's `dev` branch an `F-nnn` resolves to a
`## F-nnn` section in `dev/FEEDBACK.md` or `dev/FEEDBACK-archive.md`; on `main` and in
the public repository it resolves to nothing, by design.
- …a PR body: `.github/pull_request_template.md` — the shape every PR reproduces;
`gh pr create --body-file` never pre-fills it, so read it before composing.