Working in the repo
Layout
packages/ the shared tiers, published to npm as @titan-design/<name>
products/ apps composed from the tiers (private)
templates/ the uniform per-package scaffold that scripts/new-package.mjs stamps
scripts/ new-package.mjs, dag-check.sh, gen-docs-reference.mjs
site/ this documentation site (VitePress)
.codewatch/ check.json: the DAG and fitness rules codewatch enforcespnpm workspace. Root scripts fan out with pnpm -r run <script>; there is no task runner. One shared tsconfig.base.json (ES2022, NodeNext, strict, verbatimModuleSyntax).
Before you finish any change
All six must be green:
pnpm build && pnpm typecheck && pnpm lint && pnpm test && pnpm dag:check && pnpm docs:buildpnpm dag:check runs both checks of CI's dag-check job: the DAG check, then the dead-export check on the same graph. Zero lint warnings in files you touched. pnpm build has to precede pnpm test and dag:check, because an import of a workspace package by its published name resolves through that package's built dist/*.d.ts entry.
For a fast local signal, pnpm test:affected runs only the tests your change reaches (vitest --changed against origin/main; pass a base or set TEST_AFFECTED_BASE to override). A change to the lockfile, a vitest config, a tsconfig*.json, a package.json or scripts/fixtures/ runs every test. Git-heavy tests are named *.repo.test.ts and sit in the repo project, which test:affected runs only when their package changed. CI's full pnpm test runs all of them and stays the required check.
CI also runs pnpm lint:types as a step of the dag-check job. It applies the type-aware rules no-unsafe-type-assertion, no-unnecessary-type-assertion and no-unnecessary-condition (eslint.typed.config.js) to source and tests. It needs pnpm build first. It runs one eslint process per chunk of workspace src dirs, in sequence, so no process nears the V8 heap cap; it takes about a minute and names any chunk that runs out of heap. Existing hits live in eslint-suppressions.typed.json and are fixed when the file is next touched. Fix a new hit with a type guard or a parse, not a disable comment: pnpm lint does not run these rules, so it reports such a comment as unused.
The egress scan
This repository is public. egress-scan refuses pushes that add an absolute home path, a path into the active-work data directory, or a term from your private list. A finding names only file:line and the rule, never the text.
- The pre-push hook.
pnpm installbuilds the package and installs the hook through the rootpreparescript, on a fresh clone as well. It goes into git's shared hooks directory, so every linked worktree uses it. A worktree withoutnode_modulescannot push until you runpnpm installthere; the hook fails closed rather than skip the scan. The hook is never installed in CI. If the install printed "pre-push hook not installed", runpnpm build, thenpnpm run prepare. - The CI step. A step of the
validatejob scans every commit of a pull request, from its base sha to its head sha, and every push to main. It is a merge gate, not an egress control: a branch is public as soon as it is pushed. CI does not have the private term list. - The allow file.
.egress-allowat the root holds<glob> <rule-id> <reason>lines, one per file and rule. The reason names a task id.private-termhits are never allowable; rewrite them. Prefer a placeholder segment (/Users/you,/home/<user>) or a string built at runtime over a new allow entry. - The private term list. Keep it outside every repository, at
$XDG_CONFIG_HOME/titan-egress/private-terms(default~/.config/titan-egress/private-terms), mode 600, one term per line.TITAN_EGRESS_TERMSoverrides the path, andTITAN_EGRESS_REQUIRE_TERMS=1makes a missing, unreadable or empty list fail the push, even whenCIis set. Do not list your GitHub handle: it is in everypackage.json. - By hand.
pnpm egress:scan range <base> <head>scans a range;pnpm egress:scan treescans every tracked file.
When a push is refused:
- A branch cut before the scanner existed. Its checkout has no scanner, so the hook refuses the push. Merge main into the branch, run
pnpm install, and push again. - The first push to a new remote that shares no history. No ref of that remote holds any of your commits, so the hook scans the whole reachable history. It can report old findings in files the allow file does not cover.
- The CI job is not the control. CI runs after the push, so the hook is the egress control and the CI job is a merge gate. A pull request can edit the job itself, so reviewers read workflow diffs.
Before adding code
- Read the capability catalog and the reference page of every unit that looks close.
- Name the existing unit you reuse, or the gap you fill and its task, in the plan and the PR.
- Verify runtime and auth assumptions with the path's smoke check before you build on them.
Adding a package
Never hand-copy a package. Stamp it:
pnpm new:package <name> --tier <0|1|2|ui|product> --description "..." --task TP-n
pnpm installThat copies templates/package, fills in the name and tier, registers the package in .codewatch/check.json under its tier, stamps site/reference/<name>.md from templates/reference-page.md, and regenerates the index and sidebar. Edit the $tiers map, never layers directly.
The stamped reference page is a placeholder with every section heading and no content. Fill it in before the package ships; a second stamp of the same name leaves an existing page untouched, so hand-written prose is never flattened.
The stamp also copies templates/package/CAPABILITY.md: the package's "use this when" line for the capability catalog. Replace its placeholder in the same pull request; pnpm new:package regenerates the catalog so the new row shows up at once.
The docs build is a pull-request gate, not just a deploy step: validate runs pnpm docs:build on every pull request, so a public package with no reference page turns the required check red before merge. See the reference index below.
Checking the DAG locally
A package may import only packages in its own tier or a lower one. pnpm dag:check enforces it, self-hosted against this repo's own @titan-design/code-graph:
pnpm build && pnpm dag:checkpnpm build has to run first; dag:check imports the package's built output. It then runs the dead-export check on the graph it just built, so it fails wherever CI's dag-check job would. pnpm dag:check:graph is the DAG check alone, and the one to call with --json or --report <file>, since the dead-export report follows on stdout. Set BASE_REF=origin/main to report only violations that are new relative to main, which is what CI does; with BASE_REF unset, dag:check still passes origin/main, when it exists, to the dead-export step for its baseline-growth check. scripts/dag-check.sh remains for one release as a fallback that needs CODEWATCH_CLI pointed at a built codewatch checkout.
Checking for dead exports
pnpm dead:check lists exports that no other module imports, and CI fails the pull request on a new one. That includes an export used only inside its own file: drop the export keyword. pnpm dag:check already runs it; run it alone with pnpm dead:check, which indexes the tree itself. Like dag:check, it needs the built @titan-design/code-graph:
pnpm build && pnpm dead:checkExports that predate the check sit in .codewatch/dead-exports.json. The baseline only shrinks: pnpm dead:check --update removes entries you have fixed and never adds one, and CI fails a pull request that adds an entry to the file. If an outside consumer needs an export, export it from the package entry so it has an importer.
Conventions
- ESM only,
verbatimModuleSyntax,import typefor type-only imports. - Tests live next to source as
*.test.tsand run from the root with vitest. Test behaviour, not implementation. - Functions stay under about 30 lines. Comments explain why, never what.
zodis a peer dependency of packages that use it, never a regular dependency.- Products own surface wiring — commander, MCP SDK transports, hono servers. Packages expose surface-independent cores.
- Every CLI surface a package exposes takes
--jsonand returns the JSON envelope. The one exception istitan-egress-scan: it printsfile:lineand the rule to stdout, notices to stderr, and an exit code, with no--json, because its only callers are the hook and CI.
Changesets
Any change under packages/* needs a changeset or the pull request fails:
pnpm changesetPackages version independently; there is no fixed group.
Releasing
Merging to main lets the Release workflow open or refresh the "Version Packages" pull request from pending changesets. Merging that pull request publishes the bumped packages to npm. The Release workflow runs only after CI passes for the push to main, so it doesn't repeat the checks. Every push to main gets its own CI run, which finishes and starts a Release run. A Release run for a commit that is no longer the main tip skips its job, and the run for the tip releases everything that is pending.
Publishing uses npm trusted publishing (OIDC), so there is no npm token secret: each package has a trusted publisher on npmjs.com pointing at HJewkes/titan-platform and release.yml. Never add an npm token to release.yml. The workflow does use a GitHub App key, only to push the release branch and open the pull request (see the loop setup guide).
A red lint, typecheck, or test on main blocks every publish, since the release job runs them first.
Publishing stranded versions
A version can strand on main without reaching npm. If a newer push cancels the CI run for a "Version Packages" merge, that merge's Release run never starts. Once a later changeset is pending, every Release run refreshes the pull request instead of publishing. A manual dispatch of release.yml on main publishes those versions:
gh workflow run release.yml --ref main -f dry_run=true # list what would publish
gh workflow run release.yml --ref main -f dry_run=false # publish itThe dispatch runs only when a push CI run at the main tip succeeded. It then runs scripts/release-stranded.mjs, which publishes every public workspace version the npm registry lacks, without changeset version, git tags, or GitHub releases. The run summary lists each version it publishes. A package npm has never seen cannot use trusted publishing until its first hand publish (below), so the dispatch skips it and any package whose runtime dependencies reach it, and reports both instead of failing. The dispatch stays in release.yml because each trusted publisher names that file.
The first publish of a brand-new package
npm accepts a trusted publisher only for a package that already exists on the registry, and it has no pending-publisher feature that would let one be added ahead of time (npm/cli#8544). So a package's very first version is published once, by hand, from the owner's own terminal. Land the package on main first, then:
- Log in interactively.
npm login --auth-type=webfrom the owner's terminal. This needs a human present for the browser challenge and cannot run in CI. - Publish from the built package. From the package's directory,
pnpm publish --access public --no-git-checks. Always pnpm, nevernpm publish: only pnpm rewritesworkspace:^dependency ranges into real version ranges, so annpm packtarball for an unreleased package is uninstallable. - A new package sits at
0.0.0on main until its "Version Packages" pull request merges. Publishing0.0.0by hand as a placeholder version is fine; the changeset release bumps it from there. - Add the trusted publisher. On the new package's npmjs.com settings page: GitHub Actions, user
HJewkes, repositorytitan-platform, workflowrelease.yml, no environment, "Allow npm publish" checked. - Every later release of that package goes through
release.yml. No npm token is created at any point in this procedure.
One ordering trap: hold the "Version Packages" pull request until every new package it depends on already exists on npm, or the dependent package's publish fails looking for a version that isn't there yet.
Do not bump the pnpm pin casually
packageManager pins pnpm@9.15.0. From v11, pnpm publish is implemented natively and no longer delegates to the npm CLI — and that delegation is what performs the OIDC exchange trusted publishing relies on. Any pnpm upgrade needs a real publish to verify.
The docs site
pnpm docs:dev # local server with hot reload
pnpm docs:build # regenerates the reference index, then builds to site/.vitepress/dist
pnpm docs:preview # serve the built output.github/workflows/pages.yml deploys the site to GitHub Pages on every push to main. The build itself is guarded earlier: ci.yml's validate job runs pnpm docs:build on every pull request with no path filter, so a change under packages/ that breaks the reference fails the required check rather than the deploy.
The reference index
pnpm docs:reference reads .codewatch/check.json and every workspace package.json, then writes these committed files:
site/reference/index.md— the tier tables on Packagessite/.vitepress/reference-sidebar.json— the sidebar nav- the
<!-- generated:<name> start -->…endblocks insite/guides/architecture.md(the dependency graph, the packages with no titan dependencies, the product list) and the session miner's package count there and insite/guides/index.md
A new package therefore never needs a hand edit to the nav or the graph. The script fails if a public package has no site/reference/<name>.md, which is the mechanism that stops an undocumented package from shipping. pnpm new:package stamps that page and reruns the script, so the default path is green without a hand edit.
docs:build runs the script with --check instead, which writes nothing. It fails when a committed file differs from what the script would write, and when a package under packages/ is not named in package families. The first is fixed by running pnpm docs:reference and committing; the second needs a line of prose.
The capability catalog
pnpm capabilities writes CAPABILITIES.md at the repository root and site/guides/capabilities.md from the same data. It reuses the reference index's package discovery, then adds each unit's version, its key exports parsed from src/index.ts (capped at twelve, runtime values before types), and the text of its CAPABILITY.md. The runtime paths and known gaps come from scripts/capabilities-data.json, which is edited by hand.
Both outputs are committed. validate runs pnpm capabilities:check, which regenerates in memory and fails when either file differs, so edit the sources and rerun the script. The "Version Packages" pull request stays green because pnpm version-packages regenerates the catalog after bumping versions. The script fails when a unit has no CAPABILITY.md.
The same script writes each public package's API snapshots, packages/<dir>/api/<entry>.d.ts, from the built dist/, so it exits 2 when a package is unbuilt. Callers that run before a build (pnpm version-packages, pnpm new:package) pass --catalog-only; after stamping a package, run pnpm build && pnpm capabilities to add its snapshot.
Page bodies are hand-written on purpose. Generating them from type signatures produces a list of exports, not an explanation of when to reach for the package.