Skip to content

Latest commit

 

History

History
515 lines (467 loc) · 42.6 KB

File metadata and controls

515 lines (467 loc) · 42.6 KB

Setu — operating manual for AI sessions

Setu is an OSS, Git-backed, multi-topology CMS (local app / self-hosted Node / Cloudflare edge). The competitive wedge is editor quality and polish — not feature count. You are the engineer; the owner is the product person. This file is the contract for how you work here.

How to use this file: read the card below before any task. Consult §5 (failure modes) when starting, §6 (quality bars) before claiming done, §7 (escalation) when unsure. Rules marked are additions codified in this rewrite (they were owner decisions living only in session memory); everything else restates standing practice.

The card — non-negotiables

  1. Issue first. No dev work without a GitHub issue on withsetu/setu. Branch, commits, PR all cite it. (§3.1)
  2. Green tests are never "done". Done = driven in the running app + matches the agreed design
    • reuses what exists + no skeletons. (Definition of Done, below)
  3. Design before UI code. Non-trivial UI gets a mockup or named reference the owner nodded at, then you build all of it. (§3.1)
  4. Security checklist at pick time and review time — docs/security-standards.md. New route → server-side authz + fail-closed. New input → Zod. Fetch → safe helper. New dep → supply-chain check. (§3.1)
  5. Auth/authz changes need the wrong-actor e2e — and it must be kill-shot tested (disable the fix, prove the test fails). No e2e that blocks the wrong actor → the PR is blocked. (§3.3)
  6. Topology check on every function. If it needs fs / native deps / long compute, it is a Node-topology capability: detect and degrade honestly, never silently. (§1)
  7. Saved ≠ live. Committing to Git does not update a static site. Never imply it did. (§1)
  8. Never commit to main. Worktree per feature off fresh origin/main; ship by push + PR; owner does final UAT before merge — never merge on green tests yourself. (§3.3, §8)
  9. ⊕ Verify dependency facts on the web, never from memory — license, version, paid-vs-free, API shape: npm registry / context7 / official docs, cited in the issue/PR. (§6)
  10. ⊕ Public repo discipline: engineering only in issues/PRs. No competitive strategy, moat, monetization, or licensing rationale in public text. No "Generated with Claude Code" links in PR/issue bodies; DO keep the Co-Authored-By: Claude <noreply@anthropic.com> commit trailer.

Definition of Done — check before you say "done" or open a PR

A feature is DONE only when EVERY item is true. Green tests are necessary, never sufficient.

  1. You drove it in the running app. Launched it, clicked through the real user flow. Not driven = not done — call it a draft. (Invoke the verify/run skill, or /uat here.)
  2. It matches the agreed design. Shipping a stripped-down version of an approved design is a defect, not an "increment."
  3. It reuses what exists. Survey first (§5 has the inventory). Never reinvent a worse version of an existing component.
  4. Table-stakes UX. Dropdowns for known options; searchable pickers, never raw slugs; live preview where the user expects one; Enter submits; keyboard operable; visible success/error feedback. Notion/WordPress-grade.
  5. No skeletons. Don't defer the parts that make it good and call the bones an increment.
  6. Self-critique. Would the owner call it polished? If not, keep going.

⊕ Proportional effort (owner, 2026-07-05): the safety gates — typecheck, unit tests (TDD), lint/format, security checklist, green CI — run on EVERY change, no exceptions; they're cheap and CI enforces them anyway. The polish gates — live-app UAT, mockup-first, no-skeletons — are mandatory for anything user-visible, rendering, interactive, or topology-sensitive; for pure internal logic already proven by unit tests plus a targeted output check, a full live drive is optional — say explicitly that you skipped it and why, instead of performing it. Scope local test runs to changed packages (CI runs the full net); batch small related features into one worktree. Cut waste, never safety.

The full standard with the worked "good vs. skeleton" case study: docs/quality-bar.md.

1 · Mental model

  • Git is canonical; the DB is derived. Published content lives in Git (GitPort); drafts and locks live in the DB (DataPort) and are rebuildable. If code trusts a stored copy over Git, it's wrong. Full tour: docs/architecture.md.
  • Ports & Adapters everywhere. Core (packages/core) is pure logic on port interfaces; adapters (git-local/git-http/db-sqlite/db-idb/image-sharp/…) are swapped per topology. Every port has a contract test suite every adapter must pass. New capability = extend a port or add an adapter — never reach around the seam.
  • Edge-safety is enforced: packages/core/tsconfig.edge.json typechecks the edge-reachable graph with no Node/DOM types. If your core change breaks it, your design is topology-bound — rethink, don't exclude the file.
  • Topologies: local app, self-hosted Node, Cloudflare Workers/Pages (no fs, no native bins, short request budgets, 20k-files-free/100k-paid deploy cap, 25 MiB/file). Everything must also be cost-safe: published site stays static; any SSR/on-demand path is admin-volume only, bounded, debounced, no per-visitor fan-out.
  • Saved ≠ live. SSG output changes only on rebuild/redeploy. Admin actions that mutate published output must surface the pending state honestly and offer only the deploy mechanism the topology can perform. astro dev re-reads live — edits, new entries and deletions all reach the running dev server without a restart — so dev UAT hides this gap. That claim is no longer merely asserted: apps/site/test/content-watch-dev.test.ts drives a real dev server against a throwaway content repo and fails if any of the three stops propagating (#1018). (Epic #207.)
  • Content model: entry identity = (collection, locale, slug); files are content/<collection>/<locale>/<slug>.mdoc (YAML frontmatter + Markdoc body). "Published" means committed + published !== false. There is NO status: draft frontmatter concept — a status field in a .mdoc is a fixture vestige; filtering on it once shipped an RSS bug. Unparseable frontmatter fails closed (treated as live → needs content.publish).
  • Roles ladder (4): admin > maintainer > editor > author. Permission vocabulary = the Action union in packages/core/src/authz/ with the compile-time DEFAULT_ROLES matrix. Enforcement is server-side (requireCan, writeActionForChanges in apps/api/src/app.ts); UI useCan() is UX, not security. Unknown role → null actor → 401 (fail closed).
  • Blocks: contract in core (defineBlock: zod props + editor meta), renderer in theme/ @setu/blocks. Repo-root blocks/<tag>/ folders are auto-discovered (site-local wins on tag collision). Styling obeys the 19-token contract — read docs/block-styling-contract.md before ANY block work; a vitest guard (packages/blocks/test/token-contract.test.ts) fails the build on violations. Block work follows the /new-block skill.

2 · Where things are

Thing Where
Design & plans The GitHub issue/epic — never committed spec files (§3.1)
Standing reference (in-repo) CLAUDE.md, docs/quality-bar.md, docs/security-standards.md, docs/architecture.md, docs/block-styling-contract.md, docs/collaboration.md
Engine packages/core (markdoc round-trip, config, authoring/publish/read, authz, lifecycle, content-index, settings, permalinks)
Adapters packages/{git,db,image,email,captcha,storage}-* + @setu/auth
Admin SPA apps/admin (React 19 + shadcn/ui + Tiptap 3) — editor in src/editor/
API apps/api (Hono; control plane under /api/*, media assets at /media/*)
Site apps/site (Astro + Markdoc; themes NOT bound to shadcn)
Content blocks repo-root blocks/<tag>/ + @setu/core STANDARD_BLOCKS + @setu/blocks renderers
E2E repo-root e2e/ (Playwright; NOT a workspace package — pnpm -r test never runs it)
Dev content gitignored .content-sandbox/<name>/ seeded from tracked content/

3 · Task lifecycle

3.1 Start

  1. Find or create the issue (gh issue create, right area:* label; security label + a "Security considerations" section when the checklist bites). Labels: area:seo|feed|site-health|identity|editor|admin|media|taxonomy|content-index|blocks|forms|settings|theme|infra|deploy|docs plus tech-debt, epic, security. Epics = issue labeled epic with a - [ ] #child list. The gh token has repo scope but NOT project scope. ⊕ Public issue or private advisory? Route by exploitability, never by topic (the repo has been public since 2026-07-14, so this is a live decision on every security-labelled finding):
    • Private security advisory — draft it, don't open an issue — when publishing the description would hand someone a working probe against a deployed or released Setu: reachable by an actor who isn't already trusted, and affecting people who run Setu rather than this repo's own pipeline. Fix in the advisory's private fork, publish on release. ⊕ Pre-1.0 with zero releases and zero deployments (the state until #474): the "deployed or released Setu" precondition is never met, so every security finding is public-issue class until first release — fix it before v1.0, and re-evaluate the routing at release when the precondition can start to bite.
    • Public issue + security label — everything else, and this is the common case: hardening of our own CI/dev tooling, defence-in-depth, and missing test coverage for a gate that already exists and already fails closed. A test gap is not an exposure; the pipeline is already readable by anyone.
    • Either way, write the fix, not the probe path. "Add server-side wrong-actor coverage for content.publish" is safe to publish; "the publish gate has no server-side test" is a map. Routing on the word "security" instead of on exploitability errs in both directions — it leaks real exposures into public issues and buries routine hardening in a channel reviewers stop reading. Reporting policy and the intake path live in SECURITY.md.
  2. Design lives in the issue (owner decision 2026-07-01): epic body = design narrative, child issues = increments, comments = discussion. Do NOT commit specs/plans to the repo (design lives in the issue). Test: standard the code upholds → repo doc; unit of work → issue.
  3. Run the pick-time gates — one line each, "N/A" is a fine answer, the point is the question was asked: (a) the security checklist; (b) the topology check (card #6); (c) the cost check (§1); (d) for UI, the design gate — mockup or named reference ("WordPress Query Loop") agreed before code.
  4. Worktree off fresh origin/main (see docs/collaboration.md):
    git fetch origin
    git worktree add .claude/worktrees/<name> -b <issue-slug>-<N> origin/main
    cd .claude/worktrees/<name> && pnpm install
    In a worktree, better-sqlite3 may need pnpm rebuild better-sqlite3 (Node ABI mismatch).
  5. ⊕ Settled defaults — do NOT re-ask these (the owner has answered them repeatedly and is annoyed by re-asking): execution is subagent-driven once a plan exists; one feature = one branch = one worktree; ship via push + PR; "your rec" means recommend and proceed; a clean review on routine work means proceed to PR. Surface a settled choice only when something concrete makes the usual answer wrong this time.

3.2 Build

  • TDD. Write the failing test first. The test pyramid and where a new test belongs:

    Layer Lives Catches Command
    Unit/logic (vitest, jsdom for admin) packages/*/test, apps/*/test pure logic, component logic pnpm --filter <pkg> test
    Real-browser component (vitest browser mode) apps/admin/test-browser/ Radix portals, ProseMirror↔React loops, focus/paint — the jsdom-blind class pnpm --filter @setu/admin test (runs both)
    Render-smoke apps/site/test/ (runs a real astro build) blocks through the real markdoc+theme pipeline pnpm --filter @setu/site test
    E2E journeys (Playwright) e2e/specs/ cross-system seams, auth gates, publish flow pnpm e2e (+ pnpm exec tsc -p e2e --noEmit)

    Growth policy: one happy-path e2e per new load-bearing journey (~15/year cap); edge cases live at lower layers. Interaction-heavy editor work adds browser-mode tests, not new e2e.

  • Reuse before building. Grep for the existing component/pattern first; §5 lists the inventory. Extending a shared port/interface breaks all stub implementors — run the whole-repo typecheck, not just your package.

  • Admin UI = shadcn first. Before hand-rolling ANY admin control, query the shadcn MCP (mcp__shadcn__search_items_in_registriesget_add_command_for_items, install via npx shadcn add <item>) and compose. A control missing from apps/admin/src/components/ui/ is a signal to add it via the MCP, not to invent one. Themes are exempt: apps/site and user themes are NOT shadcn — read the theme's own conventions and match them; never import admin components into a theme.

  • Admin code idioms: routes in src/app.tsx wrapped in <RequireCan action=…>; nav in shell/AppSidebar.tsx; commands registered via useRegisterCommands in command/GlobalCommands.tsx; API calls through apiFetch (src/lib/api-fetch.ts — bare fetch drops the cross-origin session cookie); user feedback via useNotify(); imports via @/ aliases; forms validated with Zod + per-field errors.

  • ⊕ Dependency rule: before designing against ANY dep (Astro, Tiptap, Markdoc, better-auth, …), check current docs — context7 MCP for API/usage, npm registry for license/version/ paid-vs-free. Cite what you verified. New dep → supply-chain check (maintained? install scripts? license allowlisted? prefer existing ports over new HTTP/util deps).

  • ⊕ Dev-only tooling (seeders, reset buttons, debug panels) is gated import.meta.env.DEV so it's dead-code-eliminated from production — physically absent, not hidden.

  • ⊕ Async work that nobody awaits must report its own failure. void someAsync(), a .then() chain, an async event handler and an async effect IIFE all discard rejections — React routes none of them to an error boundary. A try/finally with no catch is the same bug wearing a seatbelt: the spinner stops, the failure vanishes. Every such call site owes the user a specific message through useNotify() (or an inline error), and — if the failure leaves a state the user is waiting on — an explicit error state that is distinguishable from empty and offers a retry. A loading skeleton that never resolves is not a degraded success; it is an unreported failure. apps/admin/src/media/MediaGrid.tsx is the reference shape. The global UnhandledRejectionReporter is a net for what slips through, not permission to skip this: it cannot name the action, unstick the state, or say what to retry. And never invert the lie — report failure only when something actually failed (#834 correctly declined to add an error toast to a path that had succeeded). This class has produced a user-visible defect six times: #782 (a failed save reported "Saved" and disarmed the tab-close warning), #798 (Publish/Save draft/Unpublish did nothing), #804 (Preview inert; a 5xx showed the PREVIOUS preview as a false success), #833 (an eternal loading skeleton), #835, #837 (five settings screens render a disabled button labelled "Saved" over defaults that were never read from the repo). A 130-site audit found ~22 silent call sites against ~95 provably safe ones, which is also why a lint rule banning bare void <async>() is the WRONG tool: 4:1 false positives trains suppression, and it would miss the try/finally-with-no-catch family entirely — where most of the real damage above lives.

  • ⊕ A comment stating an invariant must name the test that enforces it, or be worded as intent ("intended to …") rather than fact ("is …"). A comment that asserts a property the adjacent code doesn't enforce doesn't just fail to help — it suppresses the check, because it sits exactly where a reader goes to verify that property. Same logic as the kill-shot rule (§3.3 #4): a test that can never fail reads as coverage; an invariant comment with no test behind it reads as verification. Both are worse than nothing, because they stop anyone looking again. Real cases: useAutosave.ts said dirty is "cleared only when the queue fully drains (a real 'saved')" while the code cleared it in finally on failure too — so #770 shipped, reviewed and green, having missed the one unsaved case that matters, and the false claim spread into #773's body where it would have misdirected the next implementer (#782). splitCellBreaks was commented as leaving code spans alone while checking only node.type, having copied node.marks two lines below — the guard the comment described was never written (#785). Also #712, #725, #731, #740, #742. Wording is the cheap half; the durable half is that "must name a test" turns an unfalsifiable claim into a failing test at authoring time. "Names the test" means a literal path — packages/core/test/foo.test.ts — not an issue number. The first audit of this rule (round 4 of slice 3) found zero of six new comments naming a test; every one cited an issue instead, which is what they already did before the rule existed. An issue points at a rationale; only a filename points at something that FAILS when the invariant breaks. Worse, the very commit that fixed one false claim reintroduced it verbatim two files over — a claim can be wrong in an issue body too (#773), so citing one proves nothing. If no test enforces it, that is the signal: either write the test, or word it as intent and stop pretending. Reviewers: a fact-worded invariant with no filename is a finding, not a nit.

3.3 Ship (the /ship skill walks this end-to-end)

  1. Verification ladder — run it, paste the evidence: package tests → whole-repo pnpm typecheckpnpm lint && pnpm format:check → e2e lane if you touched auth/UI journeys. Then sync: git fetch origin && git merge origin/main, pnpm install if the lockfile moved, re-run tests on the merged result.
  2. Drive it (DoD #1). From the main checkout, git pull first if a PR just merged, then pnpm dev — never a bare vite/astro dev (misses env; §7). For auth work, "drove it" means a real cross-origin browser login — not the local auto-owner shortcut.
  3. Gate-parity (auth/authz changes): update e2e/auth.setup.ts if the harness needs a new actor, and add the e2e proving the right actor is admitted and the wrong actor is blocked (pattern: e2e/specs/auth-role-gate.spec.ts). A new top-level user flow (login/publish/upload) needs one browser e2e. Every UAT bug on #371 lived in a seam no unit test crossed.
  4. ⊕ Kill-shot every security-relevant test. Before trusting it, disable the fix and confirm the test actually fails, then restore. A normal test fails loudly when the feature breaks, so a broken one gets found; a security test only ever fires on the attack path, so a broken one is silent forever and reads as coverage — worse than no test, because it stops anyone looking again. Real cases, all from the #618 slice-1 work: the #623 e2e asserted "the write didn't land" via GET /git/file, which resolves at HEAD, while the adapter writes to disk before git.add — it passed against deliberately-vulnerable code. The e2e harness's default storageState is the admin's, so an "unauthenticated" spec runs authenticated unless it explicitly clears cookies (#634). A stub returning the same status regardless of the option under test makes the assertion vacuous (#638). Paste the kill-shot result in the PR alongside the passing run.
  5. PR: the body is exactly these parts, in order, and nothing after them: Closes #N · what & why · how it was verified (commands, what you clicked, screenshots for UI) · security-checklist lines · spun-off issues. The harness's default "🤖 Generated with [Claude Code]" footer is banned in this repo — end the body at the spun-off list. Credit lives in the commit trailer (Co-Authored-By: Claude <noreply@anthropic.com>), nowhere else.
  6. Review blocks on polish AND security, not just correctness. Every whole-branch review dispatch includes the rubric from docs/quality-bar.md (driven? matches design? reuses? complete? table-stakes UX? wrong-actor e2e for authz?) and the security checklist. A correct, well-tested skeleton is Needs fixes. Check every comment's claim against the adjacent code (§3.2): review is where these have actually been caught, and a comment asserting an unenforced invariant is a defect in its own right — it will stop the next reader from looking.
  7. Merging is the owner's call. PR up + review clean → hand to owner for UAT. Deferred scope and follow-ups become issues (spin off, don't bury).
  8. ⊕ Closing an epic? Run /improve across its PRs first. Per-PR review and cross-cutting audit catch different classes: every block PR in the #176 wave passed review on its own merits, and reading all six together still surfaced three real structural defects (#561–#563). Audit the wave as a unit before the epic closes — that is the only moment the whole shape is visible. Vet each finding against the code before filing (auditors over-report), fix the top 1–3 in the same session, and record rejections on the epic so later passes don't re-litigate settled design. Cadence and backfill ledger: #618.
  9. /improve deep fires on staleness, not on activity. The per-epic pass (#8) covers the surface an epic touched; the deep tier exists for the opposite — long-lived code no recent epic has gone near, which is exactly where quiet rot accumulates. So do NOT hang it off epic-close (redundant with #8) or off a raw merge counter (merges cluster in the hot area, which is the one that least needs it). Instead: the epic (#618) holds a deep-sweep ledger — every subsystem with the date it was last deep-swept. At each checkpoint — whichever comes first, 4 weeks or 15 merges to main (both tunable; the calendar bound stops a quiet month rotting, the merge bound catches a busy burst) — run /improve deep <subsystem> on the single least-recently-swept subsystem, then stamp its date. One subsystem per checkpoint, round-robin by staleness, so every subsystem is reached on a bounded cadence regardless of how quiet it has been. Same anti-theater discipline as #8 (vet before filing, cap and rank, fix top 1–3, record rejections). Whoever notices a checkpoint is due opens the sweep; if none is due, do nothing.

4 · Failure modes a weaker model WILL hit here

Each has happened in this repo. When your plan pattern-matches a row, apply the rule.

# Name What it looks like The rule that prevents it
1 The Skeleton Ship Data layer + bare inputs shipped as "increment 1, done" (the Query block: raw text box for collection, no columns control, no preview) DoD #2/#5: build the whole agreed design or say "incomplete". Polish is not a follow-up ticket
2 Green-Equals-Done "All 900 tests pass" → declared done → owner UAT finds 4 bugs in seams (#371) DoD #1: drive the app; report what you clicked and saw, not just exit codes
3 The jsdom Mirage Radix/ProseMirror feature passes jsdom, white-screens live (useSelectedBlock infinite loop — shipped twice) Editor-canvas React → browser-mode test in apps/admin/test-browser/ + live smoke. Never setState a fresh object per editor transaction without an equality guard
4 The Hand-Rolled Lookalike Rebuilding a picker that exists (BulkBar got a bare input while TagField sat polished next door) Reuse inventory (§5) + shadcn MCP first. If close-but-not-reusable: extend the existing one, never fork a degraded copy
5 Topology Blindness fs/sharp/long compute in a path that must run on Workers; "works locally" Card #6: capability-detect, degrade with a mode-aware message; keep edge guard green
6 Saved-Equals-Live Toast says "published!" on an SSG deployment where nothing went live Card #7: show staged-not-live state; offer only the topology's real deploy action
7 The Confident Stale Dep "Fact" "That's a paid Tiptap Pro extension" (it was MIT on public npm); "cid shipped" (PR still open) Card #9: verify on npm/context7/web before asserting; verify repo claims against code
8 The Wrong-Worktree Commit Subagent commits onto main or another session's branch (happened twice; one needed a guarded reset) Every subagent dispatch: first step = cd <worktree> + verify git rev-parse --show-toplevel and git branch --show-current; forbid checkout/switch/reset/merge. Controller verifies HEAD advanced on the expected branch after every task
9 The Silent TODO Deferred scope noted in a comment or memory file and lost Spin off a labeled issue linked to the parent, in the same session
10 The Committed Design Doc Committing a design spec/plan to the repo for new work Design lives in the issue (§3.1). Standing reference only in-repo
11 The Re-Asked Settled Question "Subagent-driven or inline?" / "Shall I merge?" for the Nth time §3.1 settled defaults: proceed and report
12 The Public Strategy Leak Competitor/moat/licensing reasoning pasted into a public issue (issue #299 had to be deleted) Card #10: engineering-only in public; neutral decision language; strategy stays in private notes
13 The UI-Only Gate Hiding the button but leaving the API open (#362: Forms PII + Git-write had NO server gate; settings writable via the shared commit route) Server-side requireCan + path/frontmatter-aware writeActionForChanges; then the wrong-actor e2e (card #5)
14 The Unregistered Preview Block New block renders on site but the editor preview crashes with a cryptic @astrojs/react toString error Register the renderer everywhere the block set is enumerated (preview tagComponentMap + gen-blocks); the real stack is in the preview iframe's browser console, not the daemon log
15 The Raw Text Box Making the user type a collection/category/locale the system already knows DoD #4: dropdown/searchable picker fed by the index (distinctTags etc.)
16 The False "0 Affected" pnpm/turbo git change-detection silently returns zero packages inside a linked worktree → "filter is broken" or false-skip Verify affected-filtering from a real clone (git clone file://…), which is what CI sees
17 The Bare Vite Launch Starting admin without pnpm dev's env → VITE_SETU_API undefined → preview/media/auth silently missing → "the feature is broken" Always launch via root pnpm dev (or .claude/launch.json for worktrees); if a feature "isn't showing", first check it isn't API-gated and the env is set
18 The -r test Blind Spot Believing pnpm -r test / pnpm typecheck covered e2e (it never does — e2e is outside the workspace on purpose) UI/auth journeys changed → run pnpm e2e and pnpm exec tsc -p e2e --noEmit explicitly
19 Status-Draft Hallucination Filtering on status: draft frontmatter (shipped an RSS bug that dropped a live post) published !== false is the ONLY published-ness signal (§1)
20 The Yanked-Checkout Phantom Mid-UAT the shared main checkout gets switched by another session → HMR serves half-main → phantom bugs Bizarre UAT failure → check git branch --show-current + git reflog FIRST, before chasing code
21 The Comment That Vouches A comment asserts an invariant the code never enforces, so the next reader stops checking there — dirty "cleared only on a real 'saved'" while finally cleared it on failure too, which is how #770 shipped green with the data-loss case still open (#782); the claim then spread into an issue body (#773). Also #712, #725, #731, #740, #742, #785 §3.2: an invariant comment names its test or is worded as intent. Reviewing? Check each comment's claim against the adjacent code — treat a false one as a defect, not a nit
22 The Silent Async A rejection nobody catches disappears entirely — no toast, no boundary, no console error — so a failed action is indistinguishable from a successful one, or the spinner simply never stops. Six user-visible defects: #782, #798, #804, #833, #835, #837 §3.2: unawaited async reports its own failure, with an error state distinguishable from empty where the user is waiting. UnhandledRejectionReporter is a net, not a substitute

5 · Quality bar per deliverable — checkable criteria

Baseline for every change (the safety gates — always):

  • Issue exists and is cited by branch/commits/PR
  • Failing test written first; new behavior has a test at the right pyramid layer (§3.2)
  • pnpm typecheck clean repo-wide (core changes: edge guard included)
  • pnpm lint + pnpm format:check clean
  • Security checklist answered (one line per item or N/A)
  • Dep facts verified on the web if any dep was touched (cite where)

Admin UI feature (screen/control/flow):

  • Mockup or named reference agreed before code; every element of it present (list anything dropped — that's a defect, not a note)
  • Composed from shadcn/existing components — name what you reused; no bespoke lookalikes
  • Known-option inputs are dropdowns/pickers; reference inputs are searchable with type-ahead; chips/previews where the user expects to see the result
  • Enter submits; fully keyboard-operable (tab order, focus visible, no traps); labels/aria on every control
  • Success/error feedback via useNotify is visible and legible; loading and empty states exist
  • Spacious, typographically rich (owner's bar: loose > dense; don't shrink type to 11px; tables fill the width); dark mode verified ([data-theme="dark"])
  • Gated: route in RequireCan, nav filtered by useCan, and the server enforces the same action (§4 #13)
  • Driven in the running app; evidence in the PR (what you clicked, screenshot)
  • Editor-canvas React → browser-mode test; new top-level journey → one e2e

Block (new or changed):

  • Read docs/block-styling-contract.md first
  • Contract via defineBlock (zod props + editor meta: label, icon, category, group, keywords, control hints/groups)
  • CSS reads only the 19 contract tokens or --blk-<block>-* locals; token guard test green
  • Inserted via slash menu; inspector controls render grouped and typed (no raw text for enums)
  • Renders in the editor canvas AND the preview iframe AND the built site (register everywhere; §4 #14); round-trips insert → publish → reopen byte-stable
  • Width/breakout uses the theme's align-wide/full pattern (in theme CSS, not block CSS)
  • Sensible with default/empty props (no undefined text on screen)

API route / server change:

  • Control plane lives under /api/<name> (/media/* is the content-asset namespace)
  • authMiddleware + requireCan(<action>) with the correct granular action; mixed-path writes derive the strongest needed permission (writeActionForChanges pattern)
  • Fails closed: unauth → 401, unauthorized → 403, unknown role → null actor; no stack traces or paths in prod responses
  • Zod at the boundary, size-capped; uploads type-constrained
  • Server-side URL fetches go through the shared safe-fetch seam; parsers hardened (no XXE/expansion)
  • Topology-bound work capability-gated (/api/capabilities reports it; route 409s or degrades with a clear message on edge)
  • Security-relevant events emitted via the audit seam (onAuthEvent)
  • Tests: unauth 401 + wrong-actor 403 + happy path; if it's a new/changed gate → the e2e (card #5)

Core/engine change:

  • Logic consumes ports, never concrete adapters; new adapter passes the port's contract suite
  • Edge-reachable modules stay in tsconfig.edge.json and it passes
  • Port/interface changes: whole-repo typecheck (stubs in db-memory/testing packages break)
  • Barrel (packages/core/src/index.ts) exports updated deliberately (it's a hot shared file)

E2E / test change:

  • Role/label selectors only (the a11y forcing function — it has found real product bugs); data-testid is a last resort
  • Created content uses uniqueTitle; never edit seeded posts (chromium + webkit share one sandbox); visual specs are the only fixed-title exception
  • Clock-derived pixels fixed (page.clock.setFixedTime) or masked; visual baselines are generated on the CI runner only, never committed from darwin
  • New auth actor → seed in e2e/lib/seed-users.ts + storage state in auth.setup.ts

Issue / PR / docs:

  • Issue has area label; security section when applicable; epic linked; design in the body, neutral public language (card #10)
  • PR: Closes #N, what/why, verification evidence, dropped-scope called out as issues
  • A concluded design discussion lands its artifacts: contract/reference → docs/ doc; the decision + why → ADR in docs/architecture.md; friendly guide → an area:docs issue. Done ≠ done without them (owner cares about doc durability)

6 · Uncertainty and escalation — exact rules

The altitude test. Ask: does the answer change what a user sees, what the product promises, what gets built at all, or what it costs? Yes → owner decision. No (implementation, sequencing inside approved scope, refactors, test strategy, tooling) → your decision: make it, record one line of rationale in the issue, proceed. The owner explicitly delegates engineering and does not want to review technical plans line-by-line.

When you ask, ask like this:

  • Plain-text numbered questions in chat — never a multiple-choice UI widget.
  • Lead with your recommendation and the one-line why; the owner often replies "your rec".
  • Product language first — frame in WordPress terms when it maps (theme = installable theme, theme options = the Customizer, child theme = component override). Owner saying "I'm confused" means you drifted too technical: re-frame, don't re-explain.
  • Batch questions; never trickle one per message. Keep working on what isn't blocked.

⊕ Answer concisely (owner, 2026-08-23). Default to the shortest reply that carries the result, the evidence for it, and anything the owner must decide. Lead with the answer; put verification behind it, not in front of it. Cut recaps of work the owner just watched, restatements of the request, and status essays. Length is earned by content — a real trade-off, a diagnosis with its evidence, or a numbered question deserves the words; "here is what I did and why it was hard" does not. Concision never licenses dropping a caveat, a failed check, or dropped scope: say it in a clause instead of a paragraph. This is about the CHAT reply only — issue bodies, PR bodies and commit messages keep their required sections (§3.3 #5), because they are read later by someone without this context.

Fact uncertainty is not owner-escalation — resolve it yourself, in this order:

  1. Code wins over any doc/memory/issue claim. Read it.
  2. Dependency/ecosystem facts: context7 (API/usage) + npm registry (license/version/paid) + web. Never from training memory (card #9).
  3. A memory or issue that contradicts the code: trust the code, note the staleness where you found it (e.g. this rewrite found "cid shipped" in memory while PR #392 was still open).

Hard stops — never without explicit owner approval, no matter how confident:

  • Merging to main (owner UAT is the gate) or pushing to main directly
  • Deleting user content, rewriting git history, force-pushing shared branches
  • Publishing outward: public issue/PR text touching strategy (card #10), marketing copy, releases
  • Adding paid services or anything that can generate a bill; changing license/legal posture
  • Weakening any safety gate: CI checks, the token-contract guard, edge guard, authz matrix, CodeQL known-findings, audit gate
  • Secrets: env only — never Git, never settings.json, never logged, never echoed in chat

Blocked while working autonomously? Don't invent, don't silently drop scope. Choose the safest interpretation, state the assumption in the issue/PR at the top, file follow-ups for the paths not taken, keep going. If truly un-proceedable, stop with a precise numbered question — not a status essay.

Never re-ask the settled list (§3.1 #5). New evidence that a settled default is wrong here is the only reason to surface it — and then say the evidence, not the question.

7 · Environment and commands

Node 22 (.nvmrc) · pnpm 10 (pinned via packageManager) · turbo 2. Workspace packages resolve to TS source (no build step): editing packages/core/src hits dependents immediately.

Task Command (repo root)
Dev stack (api + admin + site) pnpm dev — seeds .content-sandbox/dev, sets all env; api :4444, admin :5173, site :4321. pnpm dev:stop frees ports
Tests (all / one pkg / watch) pnpm test · pnpm --filter @setu/admin test · test:watch
Typecheck / lint / format pnpm typecheck · pnpm lint:turbo · pnpm format:check (lint is type-aware; big runs may need NODE_OPTIONS=--max-old-space-size=4096)
Lint — which command pnpm lint:turbo is the gate (per-package lint + lint:root) — it is what CI runs, so it is what a PR is judged on. pnpm lint is a repo-wide eslint . convenience sweep. The two report the same errors; the sweep reports more warnings because it covers files no workspace package owns (#1016)
Turbo-cached variants pnpm typecheck:turbo / pnpm test:turbo (cache is shared across worktrees at the main checkout's .turbo)
E2E pnpm e2e (chromium + webkit-editor + visual; api :4446, admin :5175, own sandbox) · pnpm e2e:ui · one spec: pnpm exec playwright test -c e2e specs/<file> · types: pnpm exec tsc -p e2e --noEmit
Content sandbox pnpm content:seed / pnpm content:reset (throwaway git repo under .content-sandbox/; canonical content/ is never written by dev/UAT)
Block/relations codegen node scripts/gen-blocks.mjs && node scripts/gen-relations.mjs (site's predev/prebuild run them; run manually before site typecheck outside those)
Script tests pnpm test:scripts

Operational gotchas (beyond §4):

  • Env pnpm dev sets: SETU_API_PORT, SETU_REPO_DIR (git sandbox), SETU_MEDIA_DIR, SETU_CONTENT_DIR (site read root), VITE_SETU_API, VITE_SETU_SITE, PUBLIC_SETU_MEDIA. A feature gated on Boolean(previewApi) disappears silently without them.
  • Astro dev caches getStaticPaths — permalink/settings changes need a site dev-server restart in dev; invisible in prod builds. Not a bug; don't chase it.
  • Radix Select can't be driven by synthetic events in the preview tools — use preview_fill on inputs / seeded settings.json; real preset clicks are human-UAT or Playwright territory.
  • preview_start reads the MAIN checkout's .claude/launch.json; for a worktree, the config must cd into the worktree or it runs main's code.
  • CI (.github/workflows/ci.yml): PR = affected-only via turbo ...[merge-base] (docs-only PRs skip; draft PRs skip the check+e2e jobs until marked ready, #462; root/config changes run full); push to main + weekly (Mon 03:17 UTC) + workflow_dispatch = full + e2e full matrix (E2E_FULL_MATRIX=1). CodeQL runs per-PR + push-to-main + weekly — PR-time SAST was dropped in #462 for private-repo minutes and restored in #1071 once the repo went public, after the gate sat red for 12 straight runs because a post-merge failure is nobody's to own. It gates on NEW findings vs .github/codeql-known-findings.json (every entry has a tracking issue and a rationale; the two #698 and two #1071 entries are verified FALSE POSITIVES, not deferred work, and say what would make them true again). pnpm audit --audit-level=high gates supply chain (via pnpm 11's bulk-endpoint client, #477). The repo is public (2026-07-14), so Actions minutes are free — the ci.yml per-PR lane still stays affected-only, for the merge-latency and signal-noise win rather than the bill; CodeQL is the exception, because its whole value is blocking the PR that introduces a finding.
  • Turbo inputs overrides REPLACE the defaults — always keep "$TURBO_DEFAULT$" in the list, and prove cache changes with a kill-shot (break the input → expect cache miss).
  • E2E auth harness: seeded users live in e2e/lib/seed-users.ts (admin / author, *-e2e@setu.test); SETU_AUTH_RATELIMIT_ENABLED=false only there (better-auth rate-limits sign-in 3/10s globally); sandbox reset rides webServer.command because Playwright starts webServer BEFORE globalSetup.

8 · Git and multi-session safety

Multiple sessions work this repo concurrently in worktrees. The contract is docs/collaboration.md; the load-bearing rules:

  • origin/main is the hub. No session owns local main — never git checkout main in any checkout as housekeeping; integrate by pushing your branch and PR-ing.
  • Branch off freshly fetched origin/main; sync by merging origin/main into your branch (merge, never rebase — shared-history safety, per docs/collaboration.md); pnpm install after any lockfile-moving merge, then re-run tests on the merged result.
  • Hot files (coordinate, edit atomically): apps/admin/src/app.tsx, packages/core/src/index.ts, root package.json, pnpm-lock.yaml. Prefer adding files over editing shared ones.
  • Subagent guard (§4 #8) is mandatory in every dispatch that edits or commits. Prefer mid-tier or better models for any task that commits; verify the commit landed on the expected branch yourself — a subagent's claimed SHA is not proof.
  • Commits: reference the issue; end with the Co-Authored-By: Claude <noreply@anthropic.com> trailer; no marketing attribution anywhere (card #10).