Contributor guide
Conventions, workflow, and verification for a first change.
The operational contract lives in AGENTS.md. This is the human-readable
how-to for making a change.
Setup
make setup
make check
make setup installs the git hooks, creates the scratch area, and writes the
local assistant shims. It is idempotent, so re-run it whenever hooks or tooling
change.
Development targets
make build: builds the binarymake test: runs the test suitemake clippy: runs the Rust linter with warnings as errorsmake fmt: applies formatting fixesmake fmt/check: fails if formatting differsmake docs/check: validates the docs bundle against OKF v0.2make web/units: unit tests over the pure client functions, imported fromweb/where they live, and the PWA's static checks: the stylesheet's design contract and the build and asset checks. Node only, no browser.make web/styles: the stylesheet's design contract, held against acss-treeparse ofweb/*.cssrather than against the source text. It reads the token-only colour rule, the token contrast pairs, the one palette, the 12px type floor, the 44px target, the transition bound, reduced motion, token resolution and the focus ring as parsed declarations, so a renamed selector passes and a changed value fails. Node only, no browser.make web/types: type-checks the client sources withtsc --noEmitovercheckJs. It is not inmake checkyet: it lists the errors the JSDoc phase has to clear, and a target that cannot pass is not a gate. As of 2026-10-05 it reports 273 errors over 31 files, so the JSDoc phase has that many to clear before it becomes a gate. Most are one class: a DOM property read off an element aquerySelectorreturned, which wants a JSDoc type on the lookup rather than a cast at every use.make web/e2ealso runs the accessibility audit:e2e/a11y.spec.mjswalks the main screens with@axe-core/playwrightand asserts no WCAG A or AA violation. It replaced the standalonea11y.py, so the audit runs in the same browser pass as the behaviour checks.make web/e2e: the browser behaviour checks, on Playwright Test. Two projects, one per width (1440x900 and 390x844), each against its own seeded hub. It holds the list filter, the single-key verbs, the search key and the filter chips. Needs Node and a browser; without either the target says so and passes, andHUB_REQUIRE_BROWSER=1turns that skip into a failure.make check: the full gate: hooks, linter, formatting, and tests
The client toolchain
The PWA is served as vanilla ES modules with no bundler, so there is nothing to
compile and nothing to bundle. The Node tree is development tooling only, held
in package.json as devDependencies with the lockfile committed and
node_modules/ ignored. The shipped binary and container never read it.
| Concern | Tool | Where |
|---|---|---|
| Pure client logic | Vitest | make web/units |
| PWA build and assets | Vitest reading the files | make web/units |
| Stylesheet rules and tokens | css-tree AST walk under Vitest |
make web/styles |
| Client types | tsc --noEmit over checkJs |
make web/types |
| Browser behaviour and layout | Playwright Test | make web/e2e |
| Accessibility | @axe-core/playwright under Playwright Test |
make web/e2e (e2e/a11y.spec.mjs) |
The unit tests import the modules from web/ directly, so a test and the page
cannot drift apart, and jsdom supplies the document and the media query the
modules read at import time. A rename no longer fails a test, and a branch that
is dropped does.
The browser checks live in e2e/ and run on Playwright Test. Three things are
worth knowing before writing one:
- Wait on a condition, not on a number of milliseconds.
expect(locator) .toBeVisible()and.toHaveText()retry until the page says so, so a slow paint is waited out and a missing one fails with a message. A fixedwait_for_timeoutis the thing this phase removed; do not add one back. - Find what a reader reaches by role or label, the way
getByRole("searchbox", { name: "Filter inbox" })does, and fall back to a class only where the markup has no accessible name to ask for (a row's position in a group). A locator that names a class is a screen rule, not a behavioural gate. - A hub per project, seeded by the Python harness.
e2e/hub.mjsstarts one throwaway hub per project throughe2e/hub-bridge.py, which reuseshub_harness.seed(), and writes a descriptor the tests read. So the fixture strings a check names are the harness's constants, and a project's tests do not move another project's data.workers: 1keeps a project's own tests in order, which matters because the hub draws Home's unread dots from what is still above the reader's cursor.
The specs are .spec.mjs, so npx playwright test finds them, and the
throwaway data directory and the failure artefacts land under target/tmp.
The stylesheet gate reads the same way: it parses web/*.css with css-tree
and asserts parsed declarations, so a check names the property and the value it
means rather than a substring of a file. Two consequences are worth knowing
before writing one:
- A selector is a convenience, not the thing matched. A rule is found by the declaration it carries, so renaming a class or moving a rule to the end of the file keeps passing. An assertion that has to name a screen's own class is telling you it is a screen rule, not a design gate, and belongs in the next porting phase rather than here.
- A waiver is held, not assumed. Where the design waives a rule (the 9px card preview, a scrim over content the hub does not draw), the gate reads the condition that earns the waiver rather than trusting the name, so the waiver cannot outlive the thing that justified it.
The build and asset checks read the same way:
.agents/js-tests/web-assets.test.mjs holds every required asset, the licence
markers, the external-origin and emoji ban, the shell's accessibility basics,
the service-worker registration and its server-stamped placeholders, the parse
of every first-party script, the served asset table, the glyph set and the
vendor manifest. Each reads the files and asserts a parsed or structural
property. The old check-web.py and its
make web/check target are gone: the regex versions of the type floor, the tap
target and the token gate are deleted rather than left beside their
replacements, so a new gate has one place to live.
The workflow
- State the scope in one sentence before starting: the unit of work, the target, and the outcome. Anything outside it is out of scope.
- Work in a dedicated worktree with a conventional branch name (
feat/,fix/,docs/,chore/,refactor/), rebased on the latestmain. - Keep the change small. One logical unit. Fix up or amend into the owning commit rather than stacking fix commits on an active branch.
- Write the tests and update the wiki. A behaviour change that leaves the docs stale is not finished.
- Run
make checkand keep the output. A change is done when the gate passes and you have the captured output to show for it.
Conventions
- Commits follow Conventional Commits, summary first, no trailers. Stage
explicit paths, never
git add -A. The commit-message hook enforces this. - Verification is automated.
pre-commitowns file hygiene, commit message shape, and the editorial and privacy patterns.make checkruns the same hooks CI runs. Do not hand-polish what a tool enforces. - Respect the invariants. One engine, wrap AgentFS rather than
reimplementing it, the wrapper as the single writer per session file, no
automatic expiry, no chat, and engine-native search.
AGENTS.mdis the authoritative list. - Tests keep their files under the build tree. A test's throwaway
directory comes from the shared
TempDirintests/common, and a check's fromscratch_root()in the browser harness; both sit undertarget/tmpand are removed when the test ends. The system temp directory is often memory, and a run that is killed leaves its files there for good, so a hook rejects it insrc/,tests/and the scripts.cargo cleanempties what a killed run left behind. - Vendored assets are integrity-checked. Third-party scripts under
web/vendor/must be registered inweb/vendor/MANIFEST.jsonwith their upstream URL, license, banner-derived version, and SHA-256 hash. - Committed files are public-ready. Committed code, tests, documentation, and commit messages must never reference internal tracking, task or ticket identifiers, wave or lane names, or scratch area paths. Internal process stays in the gitignored scratch area.
- Docs are public-ready by default.
docs/is an OKF v0.2 bundle: no internal names, hostnames, absolute paths, tokens, or task identifiers. Links are relative or rooted at the bundle; nofile://and nothing pointing outsidedocs/. Wiki changes are recorded in the log. - The scratch area is local. Working notes, task breakdowns, and the internal progress log live in the gitignored scratch area and are never committed.
Related
- Maintainer guide - the review gate
AGENTS.md- the authoritative operational contract