Spec 002 — Monorepo wiring
Status: implemented Branch: 002-monorepo-wiring
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
pnpm-workspace.yaml declares apps/* and packages/*, but neither directory exists — the globs point at nothing. There is no way to add shared code and have it typechecked, linted, and tested alongside the rest of the repo, because the root only knows about scripts/ and tests/. Before any app or domain package can be built, the monorepo needs a spine: a place shared packages live, a single way to typecheck the whole tree, a single way to run every test, and a linter. This spec builds that spine and nothing else — it is the workbench, not the product.
User stories
Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.
P1 — A shared package is consumable across the workspace, as source, with no build step
As the developer, I want to add a package under packages/ and import it by name from anywhere in the monorepo — with no build, no dist/, and no publish step — so that sharing code costs nothing and a package stays plain TypeScript source, exactly the way scripts/*.ts already run under Node's type stripping.
Independent test: create packages/domain exporting a value as @gw2priory/domain, import it by name from a file in the root tests/, and run pnpm typecheck and pnpm test; both see the package, the import resolves, and no dist/ is produced.
Acceptance scenarios
- Given a package
packages/domainwhosepackage.jsonexportsmaps its entry to./src/index.ts, when a file elsewhere in the workspace imports@gw2priory/domainby name, then the import resolves to the package's TypeScript source with no build step in between. - Given that package, when
pnpm typecheckruns, then the package's source is typechecked under the shared config and no emit (dist/or.js) is produced. - Given a co-located test
packages/domain/src/index.test.ts, whenpnpm testruns from the root, then that test is discovered and executed. - Given a test in the root
tests/that imports@gw2priory/domainby name and asserts on it, whenpnpm testruns, then the import resolves across the workspace and the test passes.
P2 — Each cross-cutting check is one root command over the whole tree
As the developer, I want a single command each for typecheck, test, and lint that covers every workspace at once, so that I — and later CI — verify the repo with a handful of commands rather than one per package.
Independent test: with at least one package present, pnpm typecheck, pnpm test, and pnpm lint each run once at the root and each covers packages/** together with the root scripts/ and tests/.
Acceptance scenarios
- Given the root scripts, when
pnpm typecheckruns, then it typechecks the whole tree (root plus every package) under one config in a single invocation, and passes clean. - Given the root scripts, when
pnpm testruns, then one Vitest configuration discovers and runs the tests under bothpackages/**and the roottests/in a single invocation. - Given the root scripts, when
pnpm lintruns, then Biome checks the whole tree in a single invocation, and aformatscript applies Biome's formatting.
Requirements
- R1 — Packages under
packages/*are consumed as TypeScript source: each package'spackage.jsonresolves its public entry throughexportsto a.tsfile, emits nodist/, and is markedprivate: true(never published). Confirmed — research.md V1: a package whoseexportspoints at raw.tsresolves via the pnpm symlink under bothtsc(TypeScript 7,nodenext) and Vitest 4 with no build step, provided the consumer declares the packageworkspace:*(F3). Theexports-conditions fallback was not needed. - R2 — Shared compiler options live in a single
tsconfig.base.json; the roottsconfig.jsonextends it, and itsincludecoverspackages/**alongside the existingscripts/andtests/, while remainingnoEmit. - R3 — There is exactly one typecheck entry point: a root
typecheckscript runningtsc --noEmitonce over the whole tree. No per-package typecheck scripts and no TypeScript project references. - R4 — There is exactly one test entry point: a root
testscript whose single Vitest configuration discovers tests underpackages/**and the roottests/. - R5 — Lint and format are provided by Biome through a single
biome.json; rootlintandformatscripts run it over the whole tree. Biome is the only linter/formatter — no ESLint, no Prettier. Confirmed — research.md V2: Biome 2.5.5 runs cleanly on Node ≥22.18 from one config and is a bundled Rust binary independent of thetscAPI, so TypeScript 7 poses no compatibility risk. - R6 — Task orchestration uses plain pnpm only (
pnpm -r/--filter). No Turborepo, Nx, Moon, or other build/cache orchestration tool is introduced. - R7 — A seed package
@gw2priory/domainexists underpackages/domainwith at least one exported type and one pure function, plus a co-located test — a real shared-code seed, not a throwaway fixture. - R8 — A test in the root
tests/imports@gw2priory/domainby name and asserts on it, so cross-workspace resolution is exercised end to end and not merely assumed. - R9 — The package scope is
@gw2priory/*. - R10 — spec 001's conformance suite continues to pass unchanged, and no artifact is written under any
docs/superpowers/path.
Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:
[NEEDS CLARIFICATION: specific question]— only the human can answer. A product decision, a scope boundary, a preference. Blocks step 1.5.[NEEDS VERIFICATION: specific question]— only reality can answer. Whether the codebase works that way, whether an endpoint returns that field, whether that number is achievable. Answered inresearch.mdwith cited evidence, never by assumption. Blocks the approval gate.
Any success criterion stating a number carries a [NEEDS VERIFICATION] until a measurement in research.md backs it. An unbacked number is a guess wearing a criterion's clothes.
Success criteria
Measurable and technology-agnostic — outcomes, not implementation.
- SC1 — A package placed under
packages/is importable by its@gw2priory/*name from anywhere in the workspace with no build step; across all packages, the count of emitted output directories (dist/or built.js) is zero. - SC2 —
pnpm typechecktypechecks the entire tree — root plus every package — in a single invocation and passes clean, with noanyand no unexplained escape hatches added. - SC3 —
pnpm testdiscovers and runs tests under bothpackages/**and the roottests/in a single invocation, and the cross-workspace import test passes. - SC4 —
pnpm lintruns Biome over the whole tree in a single invocation; the count of other linters or formatters (ESLint, Prettier) in the dependency tree is zero. - SC5 — The count of build or cache orchestration tools (Turborepo, Nx, Moon, and the like) in the dependency tree is zero.
- SC6 — spec 001's suite still passes, and the count of files under any
docs/superpowers/path stays zero. - SC7 — Every acceptance scenario and success criterion above maps to a named automated test, and the suite passes.
Out of scope
- Any build-cache / task orchestration tool. Turborepo is the intended later upgrade — a drop-in wrapper over these same pnpm scripts — but only once build or CI time earns it. Deferred to its own spec, mirroring spec 001's "no Redis until the caching story earns it".
- App scaffolds.
apps/api(NestJS) andapps/web(React) are not created here; each gets its own spec. This spec only makes theapps/*glob safe to fill later. - CI wiring (GitHub Actions running lint/typecheck/test). Still its own spec, per spec 001's out of scope. This spec only makes the commands those workflows will call exist and pass locally.
- TypeScript project references and compiled package boundaries. Not needed while every workspace member shares one compiler profile (Node — no DOM, no JSX, no decorators). They return with the apps:
apps/web(React —lib: DOM,jsx, bundler resolution) andapps/api(NestJS —experimentalDecorators+emitDecoratorMetadata) require compiler options that cannot coexist in a singlecompilerOptions, so each will carry its owntsconfig.jsonextendingtsconfig.base.json, and the root typecheck graduates from onetsc --noEmitto a per-project run (project references +tsc -b, orpnpm -r typecheck). Divergent app profiles — not typecheck speed — are the real trigger, and it is the app specs' work, not this one's. - Any GW2 domain modelling beyond the minimal seed of R7. The domain graph, pricing, and optimizer are later specs;
packages/domainhere holds just enough to prove the wiring. - Publishing any package outside this repo. Every package is
private.
Assumptions
This is a workbench spec: like spec 001, it names its tooling because choosing that tooling is the point, not an implementation leak. The following are fixed by prior decisions in the architecture docs and are taken as given here.
- pnpm workspaces is the monorepo mechanism, with
apps/*andpackages/*already globbed inpnpm-workspace.yaml(docs/architecture/stack.md). - Node ≥22.18 runs TypeScript directly under unflagged type stripping, and
tsconfig.jsonisnoEmit— "TypeScript typechecks, Node executes" (docs/architecture/workflow-tooling.md). Source packages extend this exact model topackages/*rather than introducing a new one. - TypeScript 7 (
^7.0.2), Vitest 4, and pnpm 11 are the pinned toolchain (rootpackage.json). The two claims that depended on how these behave together were the verification questions on R1 and R5, both confirmed inresearch.md(V1, V2) before approval and resolved in place above. - No claim here rests on a measured number, so the only open questions were those two behavioural verifications; there were no
[NEEDS CLARIFICATION]questions for the human. - The single root config and single
tsc --noEmit(R2, R3) are correct because 002's scope is packages only, and every member —scripts/,tests/,packages/*— shares one Node profile. This is not a claim that one config serves the whole repo forever: when apps with divergent profiles arrive (each out of scope here, per "Out of scope"), they extend the same base with their own overrides.tsconfig.base.jsontherefore holds only environment-agnostic rules (strictness, syntax); the environment-specific keys (lib,jsx,module/moduleResolution,types) live in each profile's own config, starting with the root Node config today. This keeps the first app spec a clean "addapps/<name>/tsconfig.jsonextending base" with no base refactor.
Traceability
Each acceptance scenario and success criterion maps to a named test. Where a criterion's live proof is a command run at step 5 (pnpm typecheck / test / lint), the named test is the automated floor that guards it; the command run and the human's diff review are the rest, per the Test strategy in plan.md. SC7 (below) asserts this table has no empty cell and that every test it names exists.
| Criterion | Test |
|---|---|
| P1 #1 | P1 #1/#4, R8, SC3: @gw2priory/domain resolves by name across the workspace |
| P1 #2 | SC1/R1/R7/R9: packages are private, source-only via exports, under @gw2priory, and emit no dist |
| P1 #3 | P1 #3: netSellPrice takes the 15% TP tax off |
| P1 #4 | P1 #1/#4, R8, SC3: @gw2priory/domain resolves by name across the workspace |
| P2 #1 | R2/R3: tsconfig base split, root extends and stays noEmit with packages included |
| P2 #2 | P2 #2/R4: one Vitest config discovers packages and tests |
| P2 #3 | SC4/R5: Biome is the only linter and formatter |
| SC1 | SC1/R1/R7/R9: packages are private, source-only via exports, under @gw2priory, and emit no dist |
| SC2 | SC2: no any in the source this spec adds |
| SC3 | P1 #1/#4, R8, SC3: @gw2priory/domain resolves by name across the workspace |
| SC4 | SC4/R5: Biome is the only linter and formatter |
| SC5 | SC5/R6: no build or cache orchestration tool is present |
| SC6 | SC3: no workflow artifact is written outside specs/NNN-<slug>/ |
| SC7 | SC7: the spec 002 traceability table is complete and its tests exist |