Repository navigation
Decisions Code and tooling
Part of Decisions. Each decision has a name, which issues and code comments use to refer to it.
The coding practices cover all code in the Project Buddies software ecosystem, not only the project-buddy site (Axlog, 2026-10-15). That includes the site in apps/web/, the issue pipeline in apps/issue-pipeline/, the software factory's agents and prompts, and every script and tool beside them. Each decision on this page applies to all of that code unless it names the site's files or frameworks. The general rules in CONTRIBUTING.md (Design, Types, Lint, and test-first, flake-free tests) apply to every app. Its Product, UI, Data and Prompts sections describe the site. Code that doesn't meet the practices yet is tracked as an issue, as the issue pipeline is in #162, and isn't exempt from them.
Ousterhout's A Philosophy of Software Design, and the strictest available TypeScript and Biome settings (Axlog, 2026-10-10), for all code (practices-cover-all-code). Both are written out in CONTRIBUTING.md, and reviews check every change against them.
Biome lints and formats instead of ESLint and Prettier, because eslint-config-next pulled in braces@3.0.3 (GHSA-vfj7-8cjw-p6xm), which had no patched release.
Every Biome group is set to error, so info diagnostics also fail npm run check. Axlog ruled (2026-10-10) that no rule is turned off, the UI may be redesigned to satisfy the rules, and tests get no looser rules. Rules for other frameworks stay off.
The Biome config is authoritative (Axlog, 2026-10-10): the root biome.jsonc, which holds the policy, and apps/web/biome.jsonc, which extends it with the site's scoped overrides. Where a description of the lint setup differs from it, the config wins, and reviewers don't report the difference as drift. The Astro, Svelte, React Native, Turborepo, Solid, Qwik and Vue domains stay off. A new override or biome-ignore still follows best-practice-over-lint.
drizzle/meta/ stays excluded from Biome, because drizzle-kit rewrites it on every generate (Axlog, 2026-10-10).
Axlog, 2026-10-10: "Go with the stricter option, especially if it causes a rewrite that improves the general design. Don't hack around the failing rules, design a better system. Default exports are okay, as it is the prevailing setup. Best practices overrule having a lint rule."
-
noDefaultExporthas a scoped override for Next's special files and the tool configs. - Next's Metadata API exports (
metadata,generateMetadata,viewport) are allowed throughuseComponentExportOnlyModules'allowExportNames(orchestrator's reading). -
noNodejsModules,noProcessEnvandnoConsoleare confined to the smallest platform boundaries, which alone get scoped overrides:src/env.ts,error-log.ts, CLI entry points, and the email outbox (src/email/outbox.ts,e2e/outbox.ts). Anthropic's snake_case usage keys get auseNamingConventionoverride onsrc/quota/anthropic-usage.ts, its test andspend-meter-test-kit.tsonly. In the issue pipeline (apps/issue-pipeline/biome.jsonc, #166), the boundary is the CLI entry points (install.mjs,build.ts, the ops CLI's entrysrc/ops.ts, #187, and the report CLI's entrysrc/report.ts, #302), the spec writer's commit checksrc/roles/spec-writer/check.ts, which runs git and the configured commands and writes their logs (#331), and itschecks-git.ts, which runs git, the commit check and the test command in the job's checkout and resets the issue's branches (#334), and the ops CLI's modules that run git, gh and commands or keep files on disk (pipeline-root,process,config,comment-only,locks,checkouts,wiki,issue-context, and the opsops/sweepandops/claim, #183), the modules that end process groups and run commands in them (process-group, which signals groups and lists them withpgrep,shell-supervisor, the entryshellruns each command through, and the job'sjob/ops-process, which spawns ops children, #371), and the orchestrator's entrysrc/orchestrate.tswith its modules that spawn jobs, check pids or keep files (orchestrator/args,orchestrator/snapshot,orchestrator/ledgerandorchestrator/log, #234, andorchestrator/escalation-consult, which keeps the escalation agent's reply schema and session folder and gives its pi the orchestrator's environment less the gh tokens, #282); its pure modules get no exception. GitHub's snake_case comment times (created_at,updated_at) get the sameuseNamingConventionoverride onsrc/comments.tsand ontest/fake-gh.mjs, the fake gh that answers in that wire format (#219), only. The tests and their kits (test/**) and the installer (install.mjs) also get the pipeline's Node-API override, because they neednode:test,node:fs,child_processandprocess(Axlog, 2026-10-15, #219). -
as conststays on the<SECTION>_COPYobjects insrc/ui/copy/, as every copy module already does; everywhere else annotates the type (orchestrator, 2026-10-12). Rejected: annotating every copy section. - No lint dodges: no computed keys, bracket reads or
process.getBuiltinModuleto slip past a rule. A Biome bug with no supported fix goes back to Axlog.
Biome's noUndeclaredClasses and noUnusedClasses follow imports, and pages escape both because nothing imports them. Roles are plain classes in layered CSS files (page-roles.css, chat.css). Axlog approved (2026-10-11) a scoped override of both rules on the root layout, the site frame it imports (header, footer, theme picker) and the global CSS files, so the layout can use Tailwind utilities.
use-cases/ (one file per area, conventions in its README.md) drives the tests. A case is tested by a Vitest component or browser test (src/**/*.test.tsx; browser tests are *.browser.test.tsx, run by npm test in headless Chromium and WebKit) titled by its slug, or by a Vitest server test or a Playwright e2e test as e2e-visual-and-auth-only decides. A server test is src/**/*.server.test.ts, run in Node against the test database by npm run test:server (and first by npm run test:e2e, never by npm test), and use-cases.test.ts reads its titles as it reads the other src tests' (#342). That decision replaced the earlier rule "a case that needs a real server, database or navigation stays a Playwright e2e test" (2026-10-17). use-cases/use-cases.test.ts checks that every case has a test or group titled exactly its slug, in an e2e file or a src test, or is pending in e2e/pending-cases.ts, never both; that every slug title names a case; that every e2e test outside e2e/visual/ sits under a slug title; and that every link resolves. In src files only the titles of Vitest's test, it and describe are checked: a slug title there must name a case, other src tests keep plain titles, and other text may name any slug. Rejected: keeping every use case in Playwright, because it was too slow (#131). Version 1 is not done until every case has a passing test. Tests use the mock model against a non-production database; only smoke tests use real Claude. A test that needs the browser's time zone is *.zoned.browser.test.tsx, which the plain browser project skips; ZONES in vitest.config.mts gives each zone its own Vitest project, browser-<zone>, whose Chromium and WebKit instances each set the zone and a locale through Playwright's context options (#140). Likewise a test that needs a touch screen is *.touch.browser.test.tsx, which the plain browser project skips and the browser-touch project runs, with hasTouch in its Chromium and WebKit instances' context options, since Vitest can't turn touch on per test; isMobile stays off, because Vitest renders the test in an iframe, so it would scale the runner's page rather than the page under test (#348). The phone checks (src/app/phone-pages.touch.browser.test.tsx, over PHONE_PAGES in src/app/phone-pages-test-kit.tsx) run there. A browser test that measures a layout calls installThemeFonts() once and then awaits loadFonts() before measuring, both from src/themes/theme-fonts-test-kit.ts, since the fallback fonts lay text out differently (#140). The screen sizes and MINIMUM_TARGET_PX live once, in src/viewports-test-kit.ts, for both the browser tests and Playwright.
Axlog, 2026-10-17: "e2e should only test for visual regressions and the auth workflows."
Playwright e2e tests cover only two things. The first is visual regressions: the screenshot tests in e2e/visual/. The second is the auth workflows: sign-up, sign-in, sign-out, the sign-in limits, and the signed-out access gate, where a signed-out visitor, or one whose session has lapsed, is sent to log in. Every use case still has a test titled by its slug (use-cases-drive-tests), wherever that test lives.
Every other case is tested in Vitest:
- A case that needs a real server or database gets a server test against the test database in
.env.test. The test calls the Server Action, route handler or store with real users and sessions, not through a browser. - A case that needs only the UI gets a component or browser test (
*.browser.test.tsx). - Account isolation (
use_case_private_data) becomes a server test with two users, so it stays covered.
Reason: the e2e suite is the slowest one. It runs twice in the flake gate on every pipeline verify (e2e-test-database-speed), and it has timed out under load. Most of its cases don't need a browser.
Rollout:
- The rule applies to new work from 2026-10-17.
- Issues already past spec on that day (#309, #316) keep the e2e tests their specs name.
- #341 moves the existing e2e tests out, about 85 of them, in parts. Its first part, #342, built the server-test harness:
vitest.server.config.mts, whose setup file checks.env.test's environment and mocks next/headers'cookies(), and the kit insrc/test-database/, which gives each test its own users (testUser), their data through the stores (testData), real sessions (signIn,signOut,lapsed), requests that carry the session (requestWithSession), and the redirect and not-found checks (expectRedirect,expectNotFound), and deletes them after the test. Since #310 the setup file also mocks next/server'safter(), which Next's own throws outside a request scope, with the kit'safter-test-kit.ts: it runs each task at once and keeps it, andafterTasksDone()waits for every task, and the tasks they start, so a test can read what a reply saved; the setup'safterEachawaits it before cleanup, so no task outlives its test. Before it, no Vitest test reached a real database, because the store tests stub it withsrc/db/fake-neon-http.ts. Rejected for the kit: a Vitesttest.extendfixture object like Playwright's, because every server test would then import a kit-specifictest; plain functions keep Vitest's owntest, with cleanup in the setup file'safterEach. Rejected for the config: aserverproject invitest.config.mts, which would need a--projectfilter on everynpm test. - #346 moved the project, interview, memory and buddy cases into server, component and browser tests. It left
src/test-database/alone, so the parts could run side by side, and added three kits beside its tests for the last part to merge:-
src/tutor/after-tasks-test-kit.ts: stand-ins for next/server'safter()andconnection(), plusfinishAfterTasks(). Each test file that callsPOST /api/chatmocks next/server with it. -
src/tutor/tutor-chat-test-kit.ts:tutorReply,tutorPromptandtutorEdits. Each one posts a message to the route ascurrentUser()and returns the saved reply, the prompt or theupdate_memoryoutput. -
src/memory/memory-form-test-kit.ts:saveFromEditorandrestoreFromHistory, over the memory Server Actions. Their test files mock next/cache'srefresh(), which throws outside a Server Action. - The chat kit reads the owner from
currentUser()rather than taking it as an argument, because every caller is signed in anyway. That keeps the interface smaller. Rejected: anownerfirst argument, which only repeated the session.
-
- #350 moved
use_case_private_datafrom the threee2e/accounts-isolation*.spec.tsfiles tosrc/accounts/private-data*.server.test.ts, split by layer to stay within 300 lines: pages and lists, buddies, chats and the chat API, Server Actions, and the stores. Each test makes two real users, with their data through the stores, and a real session; another user's id must get exactly the answer an unknown id gets. Pages are called as async Server Components, insideexpectNotFound, and the lists are rendered withrenderToStaticMarkup. It leftsrc/test-database/alone and added one kit beside its tests for the last part to merge,src/accounts/two-users-test-kit.ts:-
twoUsers(): two users made withtestUser()andtestData(), with the first signed in. -
postChat(chatId, text): posts toPOST /api/chatas the signed-in user and returns the status and the whole body, whatever the status, once everyafter()task is done.tutorReplythrows on any status but 200, so it can't check a 404. -
deleteAccount(user)andreopenAccount(user): delete the user's row, and make a new account at the same address, which is deleted when the test finishes, sincetestUser's cleanup knows only the account it made.
-
- #345 moved the manual answers, chat names, the long chat's header, working notes, hiding the extras and the failure cases of
when-things-go-wrong.mdinto server, component and browser tests. Onlyuse_case_session_expired, the lapsed-session gate, stays ine2e/when-things-go-wrong.spec.ts. #345 also leftsrc/test-database/alone, and added two kits beside its tests for the last part to merge:-
src/app/api/chat/chat-request-test-kit.ts: stand-ins for next/server'safter()andconnection(), andsendChatMessage(chatId, text). It posts her message throughPOST /api/chatwithrequestWithSession, reads the body to its end, then awaits every keptafter()task, nested ones included, so the chat's automatic name is saved too. It also holdscitedSections(a saved reply's citations, parsed with zod),messageTextandmanualSection. It overlaps #346'safter-tasks-test-kit.tsandtutor-chat-test-kit.ts, which the last part merges with it. -
src/db/storage-down-test-kit.ts:stubStorageDown()pointsDATABASE_URLat port 1 of the test database's host, so every query fails at once as unreachable.restoreStorage()unstubs the URL and resets the modules, sincegetDb()keeps its first client.unstoredUser()gives a session to a user that is never stored, so the setup's cleanup makes no query while storage is down. It replaces the second web server ofe2e/storage-down-server.tsforuse_case_storage_unavailable; that server stays until the last part deletes it. - The unpinned chat header's check (
chats/[chatId]/page.browser.test.tsx) compares two element shots of the conversation one screen down, unpinned and pinned, as the e2e test compared a band of the page. Rejected: anelementFromPointhit test alone, which can't see the header's fade, since the fade haspointer-events: none.
-
- A server test that needs a whole database to itself, such as a full reindex of the manual or a migration from an old schema, runs in a scratch database of its own beside
.env.test's, named by a suffix no other test uses, fromscratchDatabaseinsrc/db/scratch-database-test-kit.ts; it resets the database first and drops it at the end (#347). So no server test needs the shared test database to itself or last, and #347 left no e2e test taggedWHOLE_DATABASE_TAG. Rejected: a whole-database Vitest project run alone after the others, which would serialize the suite as Playwright'swhole-databaseproject did. - Its last part, #351, makes
use-cases.test.tsfail any e2e test outsidee2e/visual/that isn't an auth case.
Rejected: the earlier rule that a case needing a real server, database or navigation stays in Playwright (#131), because that rule put most cases in the slowest suite.
Tests pin promised behaviour, not internals (#230, #255): what the wiki and the README promise, the ops replies' shapes ({ ok, op, posted, ... }, status, code, label), stop codes and their agent:stopped-<control> labels, caps and limits (lease hours, the no-checks grace, the CI wait's deadline, the comment length clip), GitHub-visible effects, and past bugs named by issue number. A GitHub-visible effect is checked through gh's writes only (the calls ghIsRead doesn't count as reads: comments posted or edited on which issue or PR, labels added or removed, claims closed, issues and sub-issues opened), so a test passes whatever reads come between them; in the issue pipeline's tests writes() (test/fake-gh.mjs, test/ops-cli-test-kit.mjs) and test/gh-effects.mjs name them. A comment's body is checked for the facts the wiki promises it holds, such as a stop comment's control, label, what it checks, reason and how to resume, not copied whole. A test that asserts internals is rewritten to assert the promise, or removed when it protects nothing promised. This complements use-cases-drive-tests, which says which behaviour a test covers. Rejected: copied constants, prompts, comment or log text the wiki doesn't promise word for word; call-order, gh-argv or launch-key assertions and object key order that nothing promises; and whole-trace snapshots, which the issue pipeline's tests no longer use (#259): its workflow-stages-* scenarios assert the summary, the ops requests, the state saved at release and the sessions that ran, through test/stage-outcome-test-kit.mjs.
The test database's password is hashed with one SCRAM iteration, in compose.test-db.yml only, because the local Neon proxy authenticates every HTTP query (about 8 ms per query instead of 76) (orchestrator, 2026-10-12). The visual projects run in parallel, and the chat screenshots are built from stored rows. Rejected: switching the tests to the websocket Pool, which production doesn't use; and dropping the second full run at merges, which is the flake gate.
Local runs use 80% of the cores as workers, 8 on a 10-core laptop: a full npm run test:e2e took 69–70 s instead of 80–81 s with the default of half the cores, and 73 s with 10 (issue #20, 2026-10-13). CI keeps half the cores, unmeasured there. Rejected, as neither moved the full run's wall time: halving MOCK_SLOW_DELAY_MS to 4 s (70–72 s; the slow test isn't on the critical path, and 4 s is only twice the slowest second-tab exchange seen, 1.9 s), and polling toPass() every 100 ms (71 s; the summed test time stayed at about 472 s). Locator assertions such as toHaveText poll on a fixed schedule Playwright 1.64 doesn't let a config change.
A flake is a failure: fix its cause or the measurement, and never rerun until it passes (Axlog). A passing repeat adds no evidence, and a diagnosis gets two hours before it goes back to Axlog.
A web test waits for the event that marks async work done, where one exists, rather than for a clock (orchestrator, #355). For example, chat-message.browser.test.tsx awaits loadCodeHighlighting("python") (src/components/code-highlighting-test-kit.ts, which warms the very highlighter Streamdown uses, from src/components/code-highlighting.ts) in a beforeAll, and chat-view-citations.test.tsx awaits the chat reading the stubbed reply's body to its end. Neither wait has a clock of its own.
A clocked wait that remains covers only work with no event, such as Streamdown's lazy code-block chunk and one React commit. Its timeout is a top-level named constant, with a comment that gives what it covers, the worst time measured, and the load it was measured under (how the load was made and its load average), and it is set to at least 3 times that worst time. Under load (1-minute load averages of 43-83 on 10 cores), the colour poll as it was before #355 failed in both browsers at expect.poll's default 1000 ms on a first run, with a cold grammar import inside it.
The server tests (vitest.server.config.mts) set their own test and hook timeout, SERVER_TEST_TIMEOUT_MS, at 40 000 ms (#382). Their work is database round trips and POST /api/chat calls, plus the setup file's afterEach cleanup, with no event to wait on in place of a clock, so the test timeout is their one clocked wait and follows the rule above. Under Vitest's 5 000 ms default, use_case_switch_buddy and use_case_project_interview timed out at loads of 26-29 and again at 34-44. The worst test measured, with the timeout lifted, took 12 742 ms, its afterEach included, at 1-minute load averages of 38-50 on 10 cores (other pipeline suites running beside it, plus one busy loop per core). 40 000 ms is that time 3 times over, rounded up. The unit, browser, browser-<zone> and browser-touch projects in vitest.config.mts keep Vitest's defaults. apps/web/AGENTS.md says how to keep server tests fast, so the timeout doesn't hide the next slow test.
Rejected: raising the unit or browser projects' Vitest timeouts, or Testing Library's, which would hide the next slow wait as well; and rerunning until green (flakes-are-failures).
In the issue pipeline's tests, what a test starts ends with it, and what it waits for has a deadline (#372). test/bounded-test-kit.mjs holds the rule: endsWithTest(stop) registers a cleanup that one top-level afterEach runs, newest first, when the test ends, whether it passed, failed or timed out; waitFor(what, check, withinMs?) rejects naming what at its deadline. The job kit (test/job-test-kit.mjs) uses it, so a job startJob started and every fake pi in the place's pi.log are killed at the test's end, and runJob's spawnSync has a bound, since a blocked spawnSync blocks the event loop and the test timeout with it. mise //apps/issue-pipeline:check gives node --test a --test-timeout. The timeout, waitFor's default deadline and runJob's bound are each sized as web-tests-under-load says, and the deadline and the bound sit below the timeout, so a missed marker fails naming itself first.
Rejected: --test-force-exit, which would let a leaked job or pi outlive its test unseen; and startJob(t, place, args) with t.after, because every caller would pass t for something the kit already knows.
The browser test projects (browser, browser-<zone> and browser-touch) pre-bundle a fixed list of dependencies with Vite's dependency discovery off (#375). The list is PREBUNDLED_DEPS in apps/web/vitest.browser-deps.mts, sorted, and holds only the app's own bare imports in the browser: the browser tests, vitest.browser-setup.ts and the src/ code they load. Vitest and @vitejs/plugin-react add their own entries. The module's one export, the Vite plugin prebundledBrowserDeps(), sets optimizeDeps.noDiscovery: true and that include. In each browser server's client environment, it fails an import, from one of the app's own files, of a package that the resolved optimizeDeps lists neither include nor exclude. The error names the package, the importing file and the list's file, so a new bare import fails at once rather than being served unbundled. To add a dependency, add it to the list.
Cause: Vitest 5 gives each project its own Vite server and optimizer cache (node_modules/.vite/vitest/<sha1 of the project's name>), and a project's Chromium and WebKit instances share that one server. With discovery on, an import the startup scan missed made Vite optimize again mid-run. That changed the deps' browser hash, so Vite turned away the other instance's in-flight imports of the old version: #346's verify failed in Tokyo/Chromium with "Failed to fetch dynamically imported module" under load. next/cache had been added to include alone for the same reason.
Rejected: a pre-bundle step before the tests, which adds a command before every npm test and still leaves discovery on; and leaving discovery on with a longer include, which can't stop the next missed import from re-bundling mid-run. Out of scope: two runs at once in the same checkout, which share the optimizer cache dirs, and which Vite doesn't support (vitejs/vite#12603). A test file that fails to import with a SyntaxError is not this case (#386): see served-syntax-check.
The browser test projects (browser, browser-<zone> and browser-touch) check that every JavaScript module their Vite server serves to the browser parses as an ES module (#386). The check is the Vite plugin servedSyntaxCheck() in apps/web/vitest.browser-syntax.mts, beside vitest.browser-deps.mts, in BROWSER_PROJECT's plugins. When a served module doesn't parse, it logs an error through Vite's logger that names the module's file relative to the app, the line and column (both from 1) of the parse error in the served, transformed code, the parser's message, and the lines around it. It serves the module unchanged, so the test file still fails to import as before; the log says where.
Why: on 2026-10-17 two runs failed to import a whole browser test file with a SyntaxError (site-header.browser.test.tsx in #359's verify, "Illegal return statement" in Chromium and "Unexpected token ')'. Expected a ';' following a return statement" in WebKit; chat/chat-message.browser.test.tsx in #350's implementer, "Illegal return statement" in Chromium). A third came the same day, after #375 had merged: flags/hide-extras.browser.test.tsx, "Unexpected token '('" in Chromium only, in a fresh checkout of #386's spec branch with its new test file taken out, where no config or test imported the branch's one new module. The branch's own run, with the test file, two minutes before in another checkout, had passed every browser test. All three files mock a module with vi.mock(path, () => import("…-test-kit.ts")), as eight other browser test files do. The browser's error names neither the module nor a position, and neither failure reproduced, so the cause stays unproven. Ruled out, with the evidence in #386's spec:
- #375's case. An optimizer re-run mid-run makes Vite answer the stale imports with 504 "Outdated Optimize Dep", which the browser reports as "Failed to fetch dynamically imported module", not a SyntaxError. The third failure ran with #375's fixed pre-bundle list and discovery off. So
browser-deps-prebundleddoesn't cover this. - A transform cache shared between runs or checkouts. Vite keeps transformed modules in memory only, in its module graph;
node_modules/.vite/vitest/<sha1 of the project's name>holds pre-bundled dependencies, and each pipeline job checkout runs its ownnpm ci, so it has its own. - A race between concurrent transforms of one mocked test file, as far as it can be driven without load. Each browser imports the test file with its own
?browserv=query, which Vitest'svitest:browser:testsplugin resolves to the bare file, so Chromium's and WebKit's imports are one module in Vite's graph. Vite dedupes pending transforms by URL, though, so both can transform it at once. Six concurrent transforms of each of the two files under different?browserv=queries gave one output, which parses. The mock hoisting (vitest:mocks) and the dynamic-import wrapping (vitest:browser:esm-injector) each parse and rewrite their input with a fresh MagicString, with no state shared between calls.
The check sees the code the browser is sent: the result of the client environment's transformRequest, after every plugin, Vite's import analysis included. A plugin's own transform hook can't, because Vitest's browser plugins (such as vitest:browser:esm-injector, which wraps every dynamic import()) run after the projects' own post hooks.
Rejected: a Vitest reporter that parses the module graph's cached transforms when a test file fails to import, since a reporter is wired for the whole run, not for the browser projects, and its tests would need fake test modules; and a transform hook, for the reason above. Out of scope: as in browser-deps-prebundled, two runs at once in one checkout. If a run fails to import a test file with a SyntaxError and the check logs nothing, the server sent code that parses, so look at what happened to it on the way to the browser.
Biome's noDeprecatedImports checks imports, not calls, so it reports drizzle's primaryKey because one of its overloads (the variadic one) is deprecated, even where code calls the documented primaryKey({ columns }). Biome closed biomejs/biome#7636 as a trade-off of the rule's design, so no release will fix it (Axlog, 2026-10-14). src/db/tables/monthly-spend.ts calls primaryKey({ columns }) and suppresses the rule on that one import with a biome-ignore that gives this reason and links the issue; src/db/monthly-spend-table.test.ts checks it is the only biome-ignore in src/. The rule stays on. Rejected: drizzle's undocumented PrimaryKeyBuilder class, turning the rule off, and waiting for a Biome release.
GitGuardian's NODE_CRYPTO_TOKEN alert on src/auth/session-token.test.ts is a false positive: a known-answer token signed with the in-repo test secret (Axlog). Don't rotate it, rewrite history, or derive the fixture at test time.
The repo is a monorepo of npm workspaces, with mise monorepo tasks across the apps (Axlog, 2026-10-14). The site lives in apps/web/ and the issue pipeline in apps/issue-pipeline/ (#160). The root holds only the shared tooling and the top-level docs: one package.json that declares the apps/* workspaces and holds Biome and the overrides, the one package-lock.json, mise.toml (the monorepo root, pinning node), the Biome policy in biome.jsonc, AGENTS.md, CONTRIBUTING.md and the README. Each app has its own mise.toml with its tasks, run from anywhere as mise //apps/web:check, or mise //...:check for every app.
Rejected: Turborepo, because its cache would replay the flake gate's second e2e run, its hashes miss the test database and .env.test, fresh pipeline worktrees never hit the cache, and two packages gain nothing from its task graph. Nx and moon, which are built for many projects and languages and have the same caching concern. Bazel, Buck2 and Pants, which need BUILD files per target and a build engineer to maintain them.
Scripts come first: each issue-pipeline stage job declares its boundary, its role (reader, writer or merger), its checkout (since #250 its own per-job checkout, work/<slug>/jobs/issue-N-<stage>, cloned from a bare mirror and removed on exit), the names of the environment its model sessions get and its network, so it is ready to run in a sandbox, and the sandbox comes later (orchestrator, #231, #249). src/job/roles.ts holds one table from stage to role and two rules for sessions: commitsOf(agent), true for issue-pipeline-writer and issue-pipeline-specifier, gives a session pipeline_commit and its commit scope, and heldToLimits(agent), true for the writer and the specifier (#335), holds it to contextLimits and records its context budget; every other session reads only, and no session runs as merger (#334, which replaced sessionRoleOf). Since #303 no session gets a token: every session's env drops gh's token variables and every role's jobRoles.<role>.tokenEnv variable, and only the job, with its ops, holds a token. A writer session commits through the pipeline_commit tool, and the job pushes for it over its control socket. tokenEnv now only names a variable hidden from sessions. Before #303 a writer or merger session got the token its tokenEnv named, as GH_TOKEN. The network lists are recorded, not enforced. job.mjs --print-boundary prints the boundary (see Issue-Pipeline).
Rejected: a role table keyed by launch names (StageName | "conflicts" | "ci-fix"), because those two are launches inside sync and checks, not stages --stage accepts, so nothing could look them up; stage-only session roles, which would give the writer's token to the reader sessions inside fix and checks; and, in #303, keeping GH_TOKEN in writer sessions, because a session that can push can skip the job's commit checks and its record of pushes.