Roster
Browse the docs

Working on roster itself

Layout, template classes, and the rules that matter.

git clone <the framework> && cd roster && npm install
npm test          # the whole suite
npx tsc --noEmit  # typecheck

Tests are node:test run through tsx. There is no build step for development, and the portal has no build step at all.

Layout

src/cli.ts             command table and help
src/commands/          one file per command
src/lib/               shared: workspace, memory, render, merge, gh, templates, docs
templates/ops/         what a tenant's ops repo is generated from
templates/brain/       what a staff member's repo is generated from
templates/portal/      the portal: a shell, css/, and js/ as ES modules
docs/                  these pages
test/                  one file per area

The portal has no build step, on purpose

templates/portal/ is a plain index.html, one stylesheet per area under css/, and one ES module per screen under js/views/. roster portal serves them from /assets, reading each file per request. Editing a stylesheet and reloading the page is the whole edit loop.

No bundler, because the browser resolves the module graph itself, and a bundler would be a build step in a tool whose selling point is that it does not have one.

templates/portal/
  index.html           the shell: <link>s, the sidebar's bones, one <script type="module">
  css/base.css         tokens, reset, buttons, inputs, chips
  css/layout.css       the frame: sidebar, main, tree-beside-viewer
  css/<area>.css       markdown, brain, inbox, graph, diff, health
  js/state.js          every screen's state in one object, and the URL that mirrors it
  js/router.js         one indirection so a view can repaint without importing the shell
  js/dom.js  api.js  md.js  refresh.js  icons.js
  js/app.js            boot, the sidebar, dispatching a view into <main>
  js/views/*.js        one per screen

js/icons.js holds two dozen Lucide glyphs as inlined path data, ISC licensed. Vendored rather than fetched, because the portal is offline-first and an icon that needs the network is an icon that is sometimes a blank square. They take colour from currentColor and size from a --ic custom property, so a component styles them in CSS and never touches the SVG. Nothing in the UI should draw with a text character.

Two rules keep the module graph a tree rather than a ring: state lives in state.js, and a view never imports app.js. It calls render() from router.js, which the shell registers into at boot.

The rule that matters

Never fix a generated file in a tenant. compose.mjs, agents.mjs, runner-plan.mjs, inflight.mjs, run-record.mjs and session.yaml live in templates/ops/. Fix them there and run roster upgrade.

A fix made in the tenant's copy goes unnoticed until the framework next touches that file, and then it is a conflict. So roster upgrade reports an edit to a framework-owned file whether or not anything has collided, and roster upgrade --check fails on it.

Template classes

Ownership decides what roster upgrade does, and getting it wrong is how you either lose somebody's work or never ship an improvement.

Where Class Upgrade behaviour
templates/ops/org/, prompts/ seeded three-way merge
templates/ops/ everything else managed reported even when nothing collided
templates/brain/.github/workflows/ generated regenerated wholesale
templates/brain/ everything else scaffold added when new, never rewritten

scaffold is the important one. A working agent rewrites CHARTER.md and memory/INDEX.md beyond recognition, and merging a template into that would be vandalism.

Tokens in brain templates

templates/brain/ is rendered with %%TOKEN%% substitution, including filenames, which is why the callers are %%STAFF%%-daily.yaml.

An unfilled token throws rather than being left on the page. A workflow containing a literal %%SCHEDULE%% is a file GitHub accepts and never runs.

A line containing %%TOKENS%% is a note to whoever reads the template and is dropped on render, along with the # separator above it. Every line of a multi-line note needs the marker, or the leftover line is stranded.

How the tests are meant to work

Three habits, each of which guards against a test that passes without testing anything.

Test the harness, not just the code. The portal's state is an exported object, so the tests import the real modules under a DOM shim and drive the same state the page does. A test that sets a property on something the code never reads cannot fail.

Run it against reality when you can. The suite builds a temporary tenant by default, so it runs for anybody who clones the repo. Set ROSTER_TEST_WORKSPACE=<dir> to run the portal and doctor tests against a real workspace instead, which is how you catch real data growing a shape the code cannot handle: templated filenames, for instance, that a comparison forgot to render.

Mutation-test the invariants. For anything asserting "this behaviour must not regress", break it deliberately and check the test fails. If it does not, the assertion is decoration.

Adding a command

  1. src/commands/<name>.ts, exporting <name>Command and <name>Help.
  2. Add both to the tables in src/cli.ts.
  3. Document it in docs/commands.md. A test fails if you do not, and another fails if the flags you document are not ones the parser accepts.

Prefer plan-then-apply. Every command that changes anything prints what it would do and needs --apply, and that is the convention that makes them safe to run in someone else's org.

Adding an agent preset

templates/ops/agents.mjs. Three fields: install, run, token_env. The run command reads the prompt from $AGENT_PROMPT_FILE.

Read the flags off the tool's own help or docs. Every preset that ships was verified that way, and the tests say which flag exists for which reason. docs/agents.md has to quote the same install line, and a test checks it does.

Before pushing anything that touches a live org

roster prompt cto --kind daily     # composed prompt, byte for byte
roster upgrade                     # what would change, and where
roster doctor                      # is it still wired up

A prompt change with no visible diff in the composed output is the only kind that is safe.

Edit this page on GitHub