Imported from mephistopheles4/stacks (
AGENTS.md). Install upstream withnpx skills add mephistopheles4/stacks. Copyright stays with the author.
AGENTS.md — Stacks (reading tracker + 3D library)
What this project is
A local-first reading tracker where the notes vault IS the database. A CLI (stacks) writes book notes with structured frontmatter into an Obsidian vault; a static site (Astro + vanilla Three.js) renders the vault as a 3D bookshelf. Public builds expose covers, metadata and each book's ## Thoughts section as plain text — never the rest of a note body.
Start here — orientation for a cold session
docs/progress.md— where the project actually is. Read this first, always. It is a spine: current state, the gate log, environment findings, and an index of every investigation. The narratives themselves live one per file indocs/log/— follow a link from the index when you need one, rather than reading 1500 lines to find the four that matter.docs/plan.md— the approved execution plan, rules of engagement, fixture spec.docs/library-brief.md— full product spec. Read before starting any phase.docs/gates.md— the invariant scoreboard: which rule each gate protects, and which rules are still protected by nothing.docs/notes-on-the-shelf.md— the design for public/private notes and for picking a book up. Both halves are built — the## Thoughtsextractor, and picking a book up in place of the card (#413); read it before changing invariant 2, the publisher, or the cover cap.docs/spec/— locked specs waiting for an implementation session. Everything in there is decided: read it instead of re-deciding, and readdocs/spec/README.mdfirst for the build order and the gate roster.docs/blockers.md— only if it exists; records gates that defeated 3 approaches.
Claude Code sessions also load CLAUDE.md, which imports this file
and adds harness-specific notes. Nothing project-normative lives there — if a
rule appears only in CLAUDE.md, it is in the wrong file, and G37 fails. Every
other agent reads this file and is missing nothing.
Update docs/progress.md in the same commit as the gate it describes, and
docs/gates.md in the same commit as the gate it scores. A new investigation
goes to docs/log/<date>-<slug>.md with one index line in the spine — not
appended to docs/progress.md, which is how that file reached 1551 lines while
its own second paragraph called it an index.
Invariants — never violate these
This list is the project's constitution, and each numbered rule is an
article. docs/gates.md scores every one of them as either
gated in CI or visibly not, and gates/constitution-scoreboard.test.ts (G19)
holds these two documents to each other — so adding an article here without
scoring it there is a red build, in both directions.
-
The vault is the source of truth. No parallel database.
library.jsonis a build artifact, always regenerable, never hand-edited, gitignored. -
Note bodies are private.
library.jsonnever carries body text, in any build — that part is absolute. A build ships body text from one explicitly allowlisted section of a note,## Thoughts, and nothing else: the adapter'sreadPublicSectionreads that section alone, strips it to plain paragraphs and withholds the whole of it on any shape that is not plain prose (ADR-0106), reading the body through a CommonMark parser with that allowlist over its tokens (ADR-0107), and the publisher writes it asnotes/<id>.json, one file per book a public build would publish, in both ofpublish()'s modes, public and local. A plainstacks buildwithout--publicnever runs the publisher and stages no notes. The adapter reads the whole body to find that section, and nothing else below the frontmatter block leaves the adapter or ships; seedocs/spec/picking-a-book-up.mdand ADR-0100. An allowlist and never a denylist, for the same reasonprivate:fails closed.⚠️ That allowlist must never name
## About. The merge writes a note body now — a provider's description, throughinsertBodySection— while still never reading one. Writing and publishing are different halves, and the body was chosen over a frontmatter property precisely so that "never published" is structural: a body section is not aBookRecordfield, so no build can carry it. An allowlist that later picked this section up would publish third-party marketing prose under the owner's name. -
Never crash on a bad note. Malformed frontmatter → skip with a console warning listing the file. One bad file must not break
stacks build. -
All vault access goes through the adapter. No code outside
packages/core/src/adapters/may read or write vault files directly. See "Vault adapter" below. -
Hand-edited notes are first-class. The parser must tolerate extra frontmatter keys, reordered keys, and missing optional keys. Only
type: book+titleare required.
Architecture
packages/
core/ # vault adapter interface + obsidian adapter, metadata fetchers, library.json builder
cli/ # stacks CLI (commander), depends on core
site/ # Astro site, vanilla Three.js island, reads library.json
fixtures/
vault/ # ~10 seed books: 2 missing covers, 1 malformed, 1 audiobook
docs/
library-brief.md
Vault adapter contract
interface VaultAdapter {
listBooks(): Promise<BookRecord[]>; // parse all type:book notes
writeBook(book: BookInput): Promise<string>; // create note, return path
updateBook(sourcePath: string, changes: FrontmatterChanges): Promise<void>;
insertBodySection(sourcePath: string, heading: string, text: string): Promise<boolean>;
readPublicSection(sourcePath: string): Promise<readonly string[] | undefined>;
bookExists(isbn: string, titleAuthor: string): Promise<boolean>;
coverDir(): string; // where covers are cached
}
writeBookcreates; it never overwrites. A colliding filename gains a numeric suffix.updateBooksets frontmatter keys on an existing note by rewriting individual lines — key order, quoting, comments and the note body all survive byte for byte. Scalars only; a key whose value is a list is left alone. Re-serialising the YAML would reformat files the owner edits by hand.insertBodySectionis the only method that writes below the frontmatter, and it exists for one thing: the provider description the merge stores under## About. It writes only when the heading is absent — absent-only applied to a section, which is also what makes a wholeenrichpass idempotent — placing it above## Notesso a provider's prose never lands under the owner's own. Everything else in the file survives byte for byte,updateBook's promise extended to the half it never touched. The text it writes is disarmed: every line ending becomes LF,<,>and%%are written as entities, every run of three or more backticks or tildes, every$, every[^, the[of every![and a[that opens a line become character references, and every line that would read as a heading, behind list markers too, and every setext underline, gains a backslash first; each line rule reads a line at any indent. So a provider's prose can never open a## Thoughtssection of its own, carry## Notesout of one, embed an image, or land a live comment or HTML block. And it is written as a block quote, every line behind>, so if its heading is ever demoted or deleted the quote withholds the owner's Thoughts rather than shipping in them. Four writes are refused, each with a warning naming the note and never quoting the text: a note holding a line ending other than LF or CRLF, tested before any parse; a text over 8,000 code points as it lands, disarmed, quoted and joined with the note's line ending; one that would leave the note body over the extractor's 20,000-code-point cap; and one whose parse fails, which coversmicromark's development build and a parse that disagrees with the text on where## Notesis, so it costs only that book. It returns whether it wrote, and takes the vault-relative path aBookRecordcarries or the absolute onewriteBookreturns.readPublicSectionis the only method that returns text from below the frontmatter, asinsertBodySectionis the only one that writes there. It is also the only reader of a note's Thoughts. It returns a note's## Thoughtssection as plain-text paragraphs, orundefinedwhen there is none or it is withheld — any shape but plain prose withholds it (ADR-0106), judged on a CommonMark parser's tokens with hand rules where Obsidian departs from it and for characters that draw as nothing (ADR-0107), and it never returns the body — and the publisher is its only caller. A withheld section warns naming the note and never quoting it. See ADR-0100 and ADR-0101.- v1 ships
ObsidianAdapteronly (YAML frontmatter,[[wikilinks]],Library/folder). - Do NOT build a second adapter. Do NOT add adapter config plumbing beyond a single constructor arg (vault path). The interface exists so a Logseq/Anytype adapter is possible later, not to be a framework.
Frontmatter contract (do not change without updating this file)
Required: type: book, title. Optional: author, isbn, status (reading|read|abandoned|wishlist, default: read), started, finished, rating (1–5), cover (relative path), cover_source (open-library|google-books|apple-books|oreilly|unknown), spine_color (hex), pages, binding (hardback|paperback), face_out (bool), tags, shelf_order (number), private (bool), publisher, published (verbatim), subjects (semicolon-separated, max 5), google_volume_id, apple_track_id, openlibrary_olid, oreilly_ourn.
This list is the contract, and gates/frontmatter-contract.test.ts holds it to
the parser in both directions. The paragraphs below are commentary — adding a
key there and not here is exactly the drift that gate exists to catch.
face_out forces the book cover-forward on the shelf, or forces it not to. Unset means decide from status — a book you're reading stands face-out on its own.
shelf_order places a book by hand, lowest first. Books carrying one come before every book without one, so a few favourites can be pinned without numbering the whole shelf. Unset means the default order: newest finished first.
private: true keeps a book out of every public build. It still appears in a local build and on your own machine — private means "not published", not "hidden from you". Wishlist books are excluded too, for a different reason: you don't own them. ⚠️ Marking a book private after it was published takes it off the next deployment only: every earlier Pages deployment keeps its cover and held copy at its own address until it is deleted (docs/commands.md).
It fails closed, unlike every other key. Anything present that is not clearly a "no" means private, because private: yes is a string under YAML 1.2 and a strict boolean check would drop it and publish the book. Wrongly private is a missing spine you notice in a second; wrongly public is someone's reading on a URL that may already have been shared or crawled. Only one of those is undoable.
binding is the one thing about a book's shape no provider knows — the word
physical_format appears zero times across every cached response this project
holds, and Google and Apple have no field for it at all. So it is declared or it
is invented, never looked up, and inference from cover aspect or page count is
struck permanently rather than deferred (#52).
Absent does not mean hardback. It routes to a stable per-book hash, so no
missing key can flatten the shelf into one format — the fail-closed property met
by structure rather than by care, since there is no default value to fall into.
Binding moves the board and the binder's square together (a paperback is not a
hardback with the overhang removed) and biases the height band, which is the tell
that actually reads at shelf distance. books.paperbackRatio dials the mixture.
cover_source records which provider a cover's bytes came from, taken from the URL that was actually downloaded. It is provenance, not permission — the four providers permit different things, but nothing reads this key: publish.ts has never looked at it and every cover ships whatever its source. What it buys is precision if a provider ever asks for its art down — those two, not all of them. This line used to say a public build "cannot treat them alike", which read as policy and described nothing; see packages/core/src/covers/cover-source.ts and ADR-0038. Absent and unknown are different: absent means nobody looked (every cover cached before this key existed), unknown means somebody looked and did not recognise the host. An unrecognised value is dropped at parse time rather than kept, because a typo must not read as a permission. A cover dropped in by hand is unknown too — there is no local, because the honest reading of that value is "somebody looked and did not recognise the host", which is exactly what a file from outside the four providers is. What must never survive is the old value: swapping the bytes under a note that still says apple-books is the one way this key can state something false, and it is the only failure here nothing would notice.
publisher, published and subjects come from the merge revision, and each
carries a rule worth knowing. published is stored verbatim — 2008 from
Open Library and 2027-02-25T00:00:00Z from O'Reilly are both valid values,
because normalising at write time was the one irreversible option and undoing it
means re-asking every provider; the card renders the first four-digit run.
subjects is semicolon-separated, never comma-separated, capped at five:
provider categories contain commas natively — Apple's Health, Mind & Body is in
this repo's own G26 corpus — so a comma split would invent a genre nobody said,
and a value containing the separator is dropped rather than escaped.
⚠️ publisher was already hand-written on 17 of the 41 real notes before it
was ever a contract key, and absent-only leaves every one of them alone, so the
field is mixed-provenance from day one: nothing downstream may assume a
provider supplied it.
google_volume_id, apple_track_id, openlibrary_olid and oreilly_ourn are
the contributor ids, and the set of them present is the record of which
providers matched this book — there is no contributors: list and no winner key,
because a list would be derivable from these. Ids, never URLs: a provider URL
lands in an href, where the card's textContent rule protects nothing, and
with an opaque id the worst a corrupted value can do is 404. Each is
shape-checked at parse and dropped on mismatch, cover_source's rule for an
opaque value — ⚠️ a typo guard and explicitly not a correctness guarantee,
since a well-formed wrong id passes and always will. Each key names its
provider's own field, and for O'Reilly that is the guard rather than a
convention: ourn is not archive_id, and a key called oreilly_id would
invite pasting the identifier this file already documents as a trap.
oreilly_ourn is recorded although it can never be linked — its URL 403s
whether the book exists or not — because an O'Reilly early release is the one
book no other provider matches, and recording only linkable providers would leave
it with no provenance at all.
A book you are reading comes ahead of all of that, numbered or not. stacks order --renumber numbers every shelved book, so any rule that only applied to unnumbered books stopped applying at all after one run — and the next book you picked up sorted behind every pin. Pinned by gates/shelf-order.test.ts.
Tech decisions (made — don't relitigate)
-
Vanilla Three.js, not react-three-fiber. Plain Astro island, no React on the page.
-
One
THREE.Groupper book, notInstancedMesh.buildBookinpackages/site/src/shelf/scene.tsbuilds each book as a case of single-material parts round a page block, and nothing on the shelf is instanced. Instancing was rejected because per-book covers would force a texture atlas, and 49 books rendered fine; the parts kept that rejection for the same reason (ADR-0008). ⚠️ The measurement it deferred has never been taken — the ADR puts the brief's 200-book target at ~1200 objects rather than ~200, and parts have been added since, so measure first and don't optimize blind. The one recorded live lead for instancing is the head cap, per-instance colour collapsing its ~20 draws to 1, and it is written up beside that mesh inscene.ts. -
Real-time shadows are the default, and one program reads the map: the bookcase's, in 2 draws at every library size. Every book part compiles with no shadow fetch (
withoutShadowFetchinpackages/site/src/shelf/shadow-receivers.ts) and the woodwork is one mesh. On the Pixel 10 Pro XL every book reading the map loses the WebGL context and this shape survives, and what kills it is not known — a count of sampling draws was the model until a re-check refuted it — so a book that reads the map again, or a bookcase member with a mesh of its own, leaves the one configuration measured to survive and is answered on a phone, not by arithmetic (ADR-0088, ADR-0090). G61 (one-shadow-reader) counts it on the default page, and with a book held. Painted shading is the fallback a device takes after a lost context (ADR-0091). -
A click picks the book up, and the card overlay retired (ADR-0102). The card's lines moved onto the held book's pages, which are plain DOM placed in the scene by three's
CSS3DRendererand updated in the same frame as the render — never in-canvas UI, and never markup: every text node goes throughtextContent. GSAP (gsap/gsap-core) plays the motion, stepped from the shelf's own frame (ADR-0103). -
The site may only
import typefrom@stacks/core. The package root re-exports the adapter, sharp and the metadata layer, so a value import dragsnode:fsand sharp into the browser bundle and the shelf silently never boots. Runtime values shared with the site live in a pure subpath —@stacks/core/shelf-order— that imports nothing. -
Site code layout: no logic in
.astrofiles. They hold markup, styles, and a<script>that imports and calls a.tsmodule — nothing more..astrofrontmatter is typechecked, byastro checkinsidepnpm build(G50,astro-types); the rule stands on a different footing. Logic in an.astrofile is counted by nothing: all eight globs instryker.scopes.jsonend*.tsand the complexity populations are those same globs, so a function there earns no mutation score and no complexity series. Anything with a type or a branch lives in a.tsfile, where the counters can see it. -
Metadata: four providers, in this order — Open Library first, Google Books as the fallback (needs
GOOGLE_BOOKS_API_KEY; unauthenticated requests share one exhausted quota and 429 every time), O'Reilly last and only when neither of those actually found the book, and Apple Books consulted only for cover art, because its artwork is ~800x1200 against Google's ~128px. Cache all API responses in.cache/so tests and rebuilds don't re-hit APIs. -
O'Reilly — unauthenticated, one search endpoint serving both title and ISBN lookups (
query=<isbn>&field=isbn), and the sole source for its own early releases, covers included: Open Library answers their ISBNs with a 43-byte placeholder and Apple has never heard of them. Cover URLs are built from the response'sourn, at 1200w to match Apple — the endpoint serves up to 2000, butMAX_COVER_EDGEresizes every shelf cover to 512 andHELD_COVER_EDGEcaps the held copy at 1200, so anything larger costs vault bytes and reaches no screen. Its library URLs end in an internalarchive_id, never the ISBN — for one book that id is0642572352530, which passes an ISBN-13 check digit while starting064; for another it is a well-formed 979 ISBN that is still seven off the book's real one, so a check-digit test does not catch it. Take the ISBN from the response body. See ADR-0038. -
Which provider answered and which provider's bytes you kept are different questions. The metadata layer completes one provider's record from another's, so a book's
sourceneed not be where its cover came from.cover_sourceis derived from the URL actually downloaded.It records provenance and gates nothing. This line used to say it decided what a public build may re-host;
publish.tshas never read it, and every cover is published whatever its source — 26 of Apple's among them, whose terms the code's own comment says do not enumerate book covers at all. The claim read as a policy and was a plan, and it talked one session out of a decision the shelf had already made 35 times. What the key actually buys: if a provider ever asks for its art to come down, the answer can be those nine rather than all of them. -
TypeScript strict everywhere. Vitest. pnpm workspaces.
Phase gates — a phase is DONE only when its gate passes
Every phase: pnpm test && pnpm build green, plus:
- Phase 0 (scaffold):
pnpm stacks --helpprints commands; site dev server renders empty shelf; fixtures vault committed. - Phase 1 (data layer):
pnpm stacks buildon fixtures produces valid library.json with exactly the well-formed books; malformed fixture logged + skipped; tests cover ISBN hit / fuzzy title / API miss / malformed frontmatter (use cached API fixtures, no live calls in tests — gated by G21 (no-live-network), which records any request the suite makes and fails the test that made it;vi.stubGlobalis the escape hatch). - Phase 2 (shelf):
pnpm smoke:render(headless puppeteer screenshot of the shelf) produces a non-blank PNG atartifacts/shelf.png; 50-book fixture renders; clicking a book (integration test via puppeteer) picks it up: the shelf reports the held state for the clicked book, the page layer carries that book's title, andlocation.hrefis unchanged. - Phase 3 (public build):
pnpm stacks build --publicoutput contains no note-body text outsidenotes/(grep gate against a canary planted in fixture note bodies, below and beside## Thoughts), andnotes/carries the ship phrase planted inside one while the notes stage is on (PUBLISH_THOUGHTS) — switched off,notes/must hold no file; the committed share card reachesdist/intact. - Phase 4 (Audiobookshelf import): import against mock ABS API dedupes by ISBN then normalized title+author; re-running import is idempotent.
Working rules for agents
- Assign an issue to yourself before you cut a branch or a worktree for it — any issue an agent picks up, not only a wayfinder ticket. Reading, searching and triaging need no claim; the claim is due before the first durable artifact exists. The assignee is an advisory, not a lock: it records that somebody intends to work the issue, so the next session can decide knowingly, and it prevents nothing. Read it back through a window — an assignment less than one hour old is presumed live — and on finding a live one, take a different issue, or establish that whoever holds it has finished before carrying on. Nothing needs unassigning when work is abandoned, because the window expires the presumption on its own. ⚠️ The case the tracker cannot answer is an assignee that is already your own account. Every session here authenticates as the same user, so mine, claimed a minute ago and free to take are the same record. Establish outside the tracker whether another of your own sessions holds it; your own name on it is not permission. The invocation lives in
docs/agents/issue-tracker.md, and this rule is not gated, deliberately. - Commit at every green gate with a one-paragraph summary. Never batch multiple phases into one commit. The subject is conventional and the body is that paragraph —
<type>(<scope>): <subject>, type frombuild chore ci docs feat fix perf refactor revert style test, scope optional and fromcore cli site gates docs ci. ⚠️ Conventional Commits enumerates no type vocabulary — it mandatesfeatandfix, permits others, and points at the Angular convention as an example — so that list is this repository's decision and not a lookup; do not go looking for a canonical one. ⚠️docsandciare each a type and a scope, which is fine:docs: x,docs(docs): xandfeat(docs): xall pass, because the two slots are read separately and never compared. Both lists are parsed out of this sentence bygates/pr-conventions.test.ts(G55), so editing either here moves the checker or turns the build red. Branch names follow Conventional Branch —<type>/<description>, lowercasea-z,0-9and hyphens, with no consecutive, leading or trailing hyphen, and trunk branches unprefixed. Dots are allowed in arelease/description and nowhere else —release/v1.2.0is conformant — under the same no-consecutive, no-leading, no-trailing rule, and never adjacent to a hyphen. ⚠️scripts/worktree.tsis more permissive than this and is not the contract: itsBRANCHguard at line 64 admits dots anywhere, plus underscores and uppercase, because it exists to refuse a path that would break the script rather than to enforce a convention. A branch you cut yourself is<type>/<issue>-<slug>, which is that shape carrying the ticket number the spec asks for:fix/273-gates-in-the-dry-run. ⚠️ Four of this repo's prefixes are local extensions and not in the spec —docs/, which Conventional Branch files underchore/, andresearch/,prototype/andexperiment/for a branch that never becomes a commit. They are kept because each says somethingchore/does not, and they are named here so the next session reading the spec does not quietly rename them as non-conformant. ⚠️ Put both on the pull request too, and treat that copy as the real one: this repo squash-merges withPR_TITLE/PR_BODY, so the pull request title is the subject that reachesmainand the local one is discarded. ⚠️ A branch the harness named needs no exemption, and this line used to grant one.claude/is one of the spec's own AI-agent prefixes, soclaude/wayfinder-228-remaining-046e4abreaks no rule — it is noisy rather than non-conformant, and the noise is a generated suffix nothing reads. Rename it to a purposeful<type>/<issue>-<slug>before the first commit if you like; nothing here requires it, because the name exists before a session can read this and a squash-merge discards it either way. ADR-0057. ⚠️ The pull request half is gated and the local half and the branch half are not, which is the whole of G55 (pr-conventions): the title and body a squash-merge actually lands are read on everypull_requestevent, and a local subject or a branch name is held by nothing. The branch stays ungated on coverage rather than visibility —github.head_refcarries it on the same event, but only on a pull request, so a branch that never opens one is never read. - When you make a decision the brief left open (library choice, API quirk, workaround), record it in
docs/adr/in the same commit — if it is hard to reverse, surprising without context, and a real trade-off. A gate lesson goes todocs/gates.mdinstead. - In Phase 2, save a screenshot to
artifacts/on every meaningful visual change — the human reviews aesthetics from these. - If a gate can't pass after 3 distinct approaches, stop, write up what you tried in
docs/blockers.md, and end the session rather than thrashing. - Do not add dependencies without noting why in the relevant record under
docs/adr/. Prefer zero-dep solutions for small utilities.
Commands
Held to reality by gates/commands.test.ts — both lists below, in both
directions. Adding a script or a CLI command without documenting it here is a
red build.
pnpm install
pnpm prepare # husky, run for you by `pnpm install` — installs .husky/ as the hooks path
pnpm typecheck # tsc --noEmit across every .ts in the repo
pnpm lint # G46: the tuned type-checked rule set over every .ts
# file, tests included (--fix repairs about a quarter)
pnpm test # vitest: packages/**/src and gates/
pnpm format # Prettier over code — Markdown and fixtures/ excluded
pnpm format:check # the same check, reporting instead of writing (.astro reaches neither)
pnpm build # typecheck, then astro check (G50), then astro build
pnpm dev # site dev server
pnpm dev:watch # site + rebuild on every vault change
pnpm lint:md # the Markdown gate: the narrow rule set over tracked docs
pnpm lint:md:fix # the same, applying the seven fixes measured safe here
pnpm stacks <cmd> # run the CLI from source
pnpm worktree <branch> # a second checkout, cut from origin/main and installed
pnpm fixtures:50 # regenerate the 50-book fixture vault (--books N for vault-N)
pnpm smoke:render # phase 2 gate: headless shelf screenshot
pnpm gate:public # phase 3 gate: the public build leaks nothing
pnpm deploy:site # gates, then build from the real vault, then publish
pnpm duplication:report # jscpd over the eight scopes and the whole tree, as a table
pnpm mutation:run # Stryker over the eight declared scopes — minutes, not seconds
pnpm mutation:score # that run's report, scored per declared scope (--markdown)
pnpm mutation:stamp # re-derive `stryker.floors.json`'s configHash — G56's remedy (--check)
pnpm metrics:emit # one run's trend series, as the OpenMetrics text CI commits
pnpm metrics:commit # put that record on the orphan `metrics` branch
pnpm trend:sync # pull that record into the local store, put the page up, and ask the live site what it serves
pnpm deploy:site runs the four gates first and builds from the real vault
last, because gate:public and smoke:render both stage a fixture vault
into packages/site/public/ — run either after the real build and you publish
eight invented books. It then re-checks the actual dist/ before uploading: the
gates prove the code path is safe using fixtures, and say nothing about the
folder about to go on the internet. --dry-run stops before the upload;
--check-only builds and uploads nothing, and goes to asking the live site which
build it is serving.
Every run prints the trend record first, and refuses a stale one — per
series, at 3 days, gated by G39 (metrics-freshness). No flag clears that;
--check-only reports it instead of refusing, because it publishes nothing, and
--stop-after-record ends the run before the verdict, publishing nothing. The
same is true of the zero-mutant residual G38 (mutation-scope) checks, so
neither flag reaches a refusal on any path that publishes.
pnpm duplication:report prints the same counts CI records — one counter, two
callers — for the eight declared scopes and for whole-tree TypeScript, plus a
permalink for every suppression block. The eight scope rows do not sum to the
tree row: a clone is a relation between two places, so a clone spanning two
scopes is counted by both (ADR-0072).
It is a print and never a gate; nothing in it refuses anything.
pnpm lint:md is the Markdown gate, and its rule set is narrow because a
default run here is dangerous: at default rules a fix pass changed 55 files,
corrupted 11 issue references, stripped a documented separator out of 16 code
spans and renumbered a verbatim quotation — with all 1055 tests green throughout.
pnpm lint:md:fix is allowlisted to seven rules for that reason. Nothing can
narrow a markdownlint run below the root config, so the allowlist is a
refusal: the fix pass declines to run at all when a version bump widens what
the tool can rewrite, or when the tree holds a finding whose fix nobody has
watched. Every rule turned off carries its measured reason in
.markdownlint.jsonc.
pnpm trend:sync needs Docker and nothing else, and is run by hand, never
on a schedule. It brings up two containers: the store, and the page you read
at http://localhost:3000/d/stacks-trend-layer — provisioned read-only from
grafana/, so the panel order is a diff somebody can review rather
than a state on one machine. Read panel 1 before panel 2, which is what the
page says at the top; there is no confidence figure on it and there will not be
one (ADR-0062).
The rest is in docs/commands.md — read it before you
deploy, cut a worktree, read a mutation score, sync the trend store, widen a
lint rule, or change shadows, a material, the bookcase's geometry or three. It
carries deploy:site's main-only branch guard and what it verifies after
upload, worktree's three cases and the one shared .env, why a mutation score
is a trend and not a gate, what trend:sync refuses, why lint loads a config
file of its own rather than the counter's, and scripts/phone-check.ts — the
check only a phone can run, and not a pnpm script.
CLI commands — pnpm stacks <cmd>:
add fetch metadata and a cover, then write a note into the vault
build parse the vault into library.json (--public, --watch)
status quick stats: books this year, in progress, covers still missing
covers report where each cover came from, or record it (--backfill)
enrich fill missing metadata on notes that already exist, never overwriting
(a whole-vault network pass — run it twice; see below)
order show the shelf order, or renumber it with gaps (--renumber)
import import a library export into the vault (audible)
The shelf's inspectors — ?solo, ?debug and ?woodSeed
Three query-string instruments, none of which exists for a visitor who does not
ask: ?solo mounts one book on an unclamped turntable, ?debug loads the black
box, the tuning panel and the pickup tuner, and ?woodSeed=<token> pins the root the bookcase's
per-member figure is drawn off — no default, and never in ?tune=. Read
docs/shelf-inspectors.md before changing the
renderer, the debug panel, or shelf-settings.ts — it carries why each exists,
what only they can see, and the rule that a control must not lie.
Decisions
Every choice this project has already made, one file each, lives in
docs/adr/ — with the original reasoning carried verbatim.
Read it before proposing anything that contradicts one.
They were a dated log at the bottom of this file until that log was 85% of it
by weight and buried the rules above. A lesson about a gate rather than a
decision goes to docs/gates.md; an environment finding goes
to docs/progress.md.
Agent skills
Configuration for the optional engineering skills.
Nothing here is required to work on this repo — the gates are the contract,
and a contributor with none of these installed must be able to pass every one of
them. See CONTRIBUTING.md.
Issue tracker
GitHub issues on mephistopheles4/stacks, via the gh CLI. See docs/agents/issue-tracker.md.
Triage labels
The five canonical roles, each label string equal to its name. All five exist on the repo since August 2026. See docs/agents/triage-labels.md.
Domain docs
Single-context — three packages, one vocabulary. That vocabulary is CONTEXT.md, and it holds only the terms no gate pins down — anything a gate already enforces is linked from there, never restated, for ADR-0026's reason. Decisions live in docs/adr/, one file each, carrying the original reasoning verbatim. See docs/agents/domain.md.
