The operating rules for any coding agent changing this repository. Tool-neutral: Claude Code reads it through CLAUDE.md's import, and other agents read it directly.
Not to be confused with docs/AGENTS.md, which documents the IN-GAME agents (roster, plan, negotiate, commit).
Local-first AI RPG: a deterministic engine holds truth and LLM agents narrate it. Seven stories ship under games/<slug>/; the engine is story-agnostic. Six can be played to an ending; READ RECEIPTS is endless (endless: true, no ending, by the owner's brief).
- Read docs/DESIGN.md — vision, mechanics, glossary, architecture
- Read docs/DESIGN_REVIEW.md — what the overhaul found and what is still open
- Read docs/CLAUDE_CODE_BRIEF.md — build spec and golden rules. Parts of it are historical and marked CURRENT: where they have drifted
- For visual/asset work, use docs/CLAUDE_DESIGN_BRIEF.md instead
- For story/content work — creating a story, editing a
games/<slug>/tree, or using the authoring tools — read docs/AUTHORING.md first. It is the document that replaces reading engine source for that job
Authority order when they disagree: the code, then DESIGN.md, then DESIGN_REVIEW.md, then CLAUDE_CODE_BRIEF.md.
-
Engine resolves mechanics; LLMs narrate. A choice that moves, spends or risks anything declares a structured
intent(engine/game/intents.py); the engine executes it through the@skillentry points BEFORE the next narration and hands the receipt back as prompt input. The enums are built per turn from what the engine will actually accept, so an illegal target is unsamplable. An illegal-at-execution intent produces an engine-authored refusal that reaches the prose — never a silent no-op. Do NOT reach fortool_calls: the turn grammar forbids that key, which is exactly how "a narration turn cannot change the world" survived for months. -
clock.advance_timeis the only writer of world time.world_day,world_hourandtime_of_dayare derived read-only properties. -
effects.apply_effectis the only writer of game state. Quest rewards, boons, encounter outcomes and death all funnel through it. -
Never use bare
random. Useworld_rng(state, STREAM)so a seed replays and one system's rolls do not shift another's. -
Never hardcode ports, model names, or paths — use
config/default.yamlviaget_config(). Machine-specific paths go inconfig/local.yaml(gitignored, deep-merged). -
Never gate rest. It is the only thing that restores stamina; a gate rebuilds a soft-lock the game shipped with.
-
Reuse patterns from CosySim and Archives of Anubis before writing new code.
-
Prove with tests — a bare
pytestis the fast tier while working;pytest --fullbefore declaring a release complete. Expect fully green, noxfail. -
Do not document a mechanism you did not wire. Mark it NOT WIRED with its file. A design doc describing code that never runs is how this codebase got into trouble.
-
Run
scripts/simulate.pybefore changing a balance constant. Every number here was originally chosen against a clock that did not tick. -
Platform-neutral, server-agnostic. Windows and Linux are both supported: no code assumes a path separator, a drive letter, a case-insensitive filesystem or an
.exe, and every script has a PowerShell and a POSIX way in (scripts/start.ps1,scripts/start.sh). LM Studio athttp://localhost:1234/v1is the default model server; vLLM, llama-server, Ollama and OpenAI-compatible servers are set byllm.provider(docs/MODEL_SERVERS.md). Local single-player is the default; hosted mode (hosting.enabled, docs/HOSTING.md) is opt-in. Usescripts/start.ps1/scripts/start.shorlauncher.py --stack. -
Never add a content-rating or "safety" layer. One was built on 2026-08-13 and removed on 2026-08-15 at the owner's instruction (release v0.3.0, 5207 deletions). Do not rebuild it in any form: no intensity tiers, no ceiling, no boundary sheet, no fade control, no
safety:block in a manifest, no register line in an authoring prompt.Why it is a rule and not a preference. It did not merely filter — it put content-rating language into the STORYTELLER system prompt on every turn (
"CONTENT LIMITS (product layer -- above every character's wants): Intensity in force: <tier>…") and into the story-DRAFTING prompt ("Allusive at most; nothing explicit."). Modelling "how explicit is this" as a first-class axis makes it the axis: the effect was narrators that treated the engine as a presumed sexual-content system. The test that asserted the prompt was unchanged skipped for exactly the three games where the injection fired, so it went unmeasured for two days.This is a LOCAL, single-player, single-author tool. The narration comes from whatever model the owner has loaded, on their own machine, for their own fiction. The register is set by
games/<slug>/prompts/storyteller.mdand by that model — it is not the engine's business, and it is not an agent's business to install a quieter version of one and document it as a feature.Note how it survived: each instance that came after inherited it as existing code, which this file's own authority order ranks above every document, and treated its unwired halves as debt to complete rather than as evidence nobody wanted it. If you find yourself specifying the fix for a missing piece of it, you have made that mistake.
These are how every change lands. Each one exists because its absence cost something measurable; the reasons are in CHANGELOG.md and docs/DESIGN_REVIEW.md.
Releases. A commit subject is a bare version number (v0.8.1); every detail
goes in the body. CHANGELOG.md is the authority from 0.4.0 on and is updated
IN THE SAME CHANGE as the code: work in progress goes under ## [Unreleased],
and a release renames that section to ## [x.y.z] — YYYY-MM-DD. Bump
pyproject.toml and ui/package.json to the same number.
tests/test_release_hygiene.py fails the suite when these disagree. A MINOR
bump is something a player or an author would notice; a PATCH is repair.
Describe engine SCOPE in commit bodies, never a content-policy decision (see
rule 12). Nothing is pushed without the owner's explicit word.
Docs move with the change. Each story keeps games/<slug>/CHANGELOG.md
and games/<slug>/README.md for its own content; the root CHANGELOG.md and
README.md cover the engine and the release. Any change updates, in the same
commit, every doc it makes stale: the affected story's CHANGELOG
(## [Unreleased]) and README; the root CHANGELOG and README for an engine
change; CLAUDE.md when the status, the roadmap or the deferred list moves;
and this file when a rule or convention does. "Updated" means reviewed and
cleaned up, not appended to: anything no longer true or relevant is removed.
A release renames each touched story's ## [Unreleased] to the release's
heading, as the root file does. It exists because docs that drifted from the
code were a finding in almost every review this repo has had.
tests/test_release_hygiene.py checks that every story keeps a README that
links its CHANGELOG, and a CHANGELOG whose ## [Unreleased] sits above its
releases, whose release headings are written the root's way (em dash and
date), newest first, never repeated, each a version the root CHANGELOG has
and none newer than pyproject.toml's.
How to run tests (v0.21.1). Use scripts/run_tests.py, never a hand-built
long pytest line:
- A task or a fix runs only the AFFECTED files:
python scripts/run_tests.py files <paths or node ids>. - Day to day:
python scripts/run_tests.py fast(the fast tier, about 6 min). - At a release, once:
python scripts/run_tests.py full(every tier, about 100 min on Windows since v0.22.0, most of it READ RECEIPTS'slowsimulator and save-size pins; CLAUDE.md's Status has the latest). It is a hybrid: a parallel phase (pytest-xdist), then a serial phase, then one automatic solo re-run of the serial phase's failures. A hosted test that passes on that re-run is reported as FLAKY, never hidden; an unmarked test is never re-run. No repeated full runs to "soak" a flake. - CI runs
full(--workers 2) on every push. - Tiers and markers:
slow(balance measurement),process(starts an interpreter, gunicorn or a hosted supervisor),mcp_server(a real skills server) andloopback(binds an in-process server on loopback).slowandprocessare out of the fast tier;process,mcp_serverandloopbackrun in the serial phase. Guards intests/tier_plugin.pyfail an unmarked test that starts an interpreter or binds a loopback server. - Every run is bounded: pytest-timeout per test,
run_tests.py's wall clock per run, and a Job Object (Windows) or process group (POSIX) that ends the whole tree. Each agent uses its OWN basetemp (run_tests.pydoes this automatically; with a barepytestpass a unique--basetemp), and stops every server or process it starts. - A bare
pyteststill works (serial, fast tier;--fullfor all).
Tests. A fix ships with a test that FAILED against the code before it, and a
guard is canary-checked by reintroducing the bug it guards. A test that
activates a story is cleaned up by tests/conftest.py::_no_story_outlives_its_test;
one that opens a connection to the model server, or resolves or connects to
any host that is not loopback, fails unless marked @pytest.mark.live
(_no_live_model_calls, default-deny: stub the client instead). LM Studio's mcp.json is
redirected into every test's temp directory, a write outside it fails the
test, and a real skills server starts only under @pytest.mark.mcp_server
(_no_owner_lm_studio_files). A child process is sandboxed too
(tests/conftest.py::pytest_configure): every process the suite starts, by
any route, inherits the CLOCKWORK_TEST_SANDBOX marker, under which the
engine never reads or writes config/local.yaml, reaches no model server
(but a loopback stub the test registered with sandbox_model_stub) or other
service, and writes mcp.json only in the temp root (a marker the suite did
not set stops it at conftest import), and a subprocess child's
CLOCKWORK_CONFIG also ends in the sandbox layer (CLOCKWORK_CONFIG itself
is never exported in-process); the owner's real storage, local.yaml and
mcp.json are compared at session end (_real_storage_is_untouched; under
xdist, once, in the controller). The
storage root is a temp directory everywhere -- except in a test marked
default_storage_root (tests/test_local_mode_golden.py) or a canary that
deletes the variable, which the audit hook still watches: per test
(_no_test_writes_real_saves) and, for a module-, class- or session-scoped
fixture set up before that, the session's own (pytest_configure); a
--basetemp that resolves under the real storage root is refused at
start-up (pytest.UsageError); an
audit hook records any write this process aims at the real root's
saves/, users/, hosting/ or media/, failing that test, or the next
test's setup when it happened outside one. A
canary that removes or weakens a guard runs only after every path that guard
protects -- the home directory (LM Studio's mcp.json), _CONFIG_DIR
(config/local.yaml), the storage root and the model endpoints -- is
redirected into the test's temp directory, in that process and its
children, with the owner's mcp.json checked before and after: a
wrapper-removal canary without that wrote the owner's real mcp.json in
v0.20.0 T5. An in-process hosted-mode test builds its app through
tests/hosted_app.py: hosting is turned on by a local.yaml layer in a temp
_CONFIG_DIR (never the owner's file) and CLOCKWORK_DATA_DIR points at
tmp_path, so users.json, its lock and the cookie key exist nowhere else.
A test never counts, waits for or signals a process by a bare pid: the OS
reuses pids, so a child is its pid AND its creation time
(tests/process_identity.py, over engine/hosting/process_identity.py,
reported by every probe), and only a verified child is ever terminated --
engine code included (engine/hosting/boot.py::stop_master).
Tiers. A test that starts an interpreter, gunicorn or a hosted
supervisor (itself or through a fixture) is marked process; the Popen
recorder in tests/tier_plugin.py fails one that is not, including every
later user of a module fixture that started one. It sees only the suite's
direct subprocess.Popen children: a grandchild (an sh or cmd /c that
starts python), or a route that bypasses Popen (os.system,
multiprocessing spawn), is not seen, so mark those by hand. A test that
measures balance is slow and keeps its length (rule 10); so is one whose
wall-clock wait is already at its setting's floor. A bare pytest leaves
both out; --full (or CLOCKWORK_FULL_SUITE=1), and any explicit path
(tests included), -m, -k, --lf or --sw, runs them; so does
pytest started inside tests/.
Every run is bounded. pytest-timeout gives every test a limit (300 s,
900 s for slow/process, times CLOCKWORK_TIMEOUT_SCALE; an explicit
@pytest.mark.timeout(n) is the test's own), so a hang fails with every
thread's stack instead of sitting forever. scripts/run_tests.py
(fast, full, files) gives each run its own basetemp and log and a
wall-clock limit. On Windows the run is in a kill-on-close Job Object, so
an overrun, an interrupt or the run's end stops the WHOLE tree. On POSIX
pytest leads its own process group, held unreaped while it is signalled
(the one place a number names processes): past the limit or on Ctrl+C,
SIGINT lets pytest's fixture teardowns run, then the group gets SIGTERM and
SIGKILL. A child in a session of its own (setsid, as the hosted
supervisor is) is outside that group: it is stopped by its fixture's
teardown, and the hosted test supervisor also dies with its parent on Linux
(tests/hosting_instance.py::parent_lifeline); a setsid child with neither
survives a POSIX overrun. A bare pytest on Windows is covered too: the
conftest puts the session itself in a kill-on-close job
(tests/process_jobs.py), so a timed-out test's children (the thread
method's os._exit skips finalizers) end with the session. A process
test's limit uses SIGALRM on POSIX so its fixtures still stop their
children; that method cannot interrupt C code that never returns to the
interpreter, and only run_tests.py's wall limit ends that.
Parallel runs (pytest-xdist). run_tests.py full is HYBRID: phase
parallel runs -m "not (process or mcp_server or loopback)" on
DEFAULT_WORKERS (6) workers with --dist loadgroup, then phase serial
runs -m "(process or mcp_server or loopback)" in one process -- the
hosted, skills-server and in-process loopback-server tests, which this
workstation's loopback stalls fail under xdist -- under one deadline,
each with its own basetemp and log, and one exit code (combine: 124, then
2, then 3/4/1). A user's -m is joined into each phase with and; the
expressions live once in tests/tiers.py (PARALLEL_MARKS,
SERIAL_MARKS). THE FAILURE RULE IS AUTOMATED, AND NEVER HIDES: after
the phases, each test that failed in the serial phase (marked process,
mcp_server or loopback) is re-run once, alone, inside the same limit;
if all pass the run passes, and the summary lists every one under
FLAKY (passed on solo re-run): -- a flaky test is reported, never
silently green. A test that fails again stays a failure; a failure in the
parallel phase (an unmarked, in-process test) is never re-run -- it is a
real failure, maybe an order bug; nor is a storage-check failure, nor more
than 40 ids (the machine is stalling). --no-rerun turns it off. Every
failure left is printed with a command that re-runs it alone.
fast is the same hybrid over the fast tier (-m "not slow and not process", so its serial tail is its own loopback and mcp_server tests);
files and --workers 0 are serial (full --workers 0 is the
order-independence check). A test that binds an in-process server on
loopback (a bus, a front door, a WSGI or health server), itself or through
a fixture, is marked loopback: tests/tier_plugin.py records every
loopback bind and fails an unmarked test that made one, so none drifts back
into the parallel phase. A bare pytest stays
serial, and pytest -n N alone means --dist loadgroup here. Under xdist
each worker is a whole suite (its own marker, basetemp popen-gwN, storage
root, audit hook, per-test checks and nested Job Object); the controller
runs no test, is not sandboxed, holds the guarded model ports for every
worker and compares the owner's storage once, after the last worker has
ended (tests/conftest.py::is_xdist_controller, pytest_sessionfinish; a
change fails the run with a red STORAGE CHECK FAILED section naming the
tests running when each file changed, as suspects). Tests that share a
module, class or package fixture share a worker, joined across every
fixture a test spans, so each such fixture is built once
(tests/tier_plugin.py::xdist_groups; a cheap per-worker guard can opt out,
UNGROUPED); every mcp_server test shares one (mcp_port). A test that
passes only after another file ran, or that writes into the live
games/ tree another worker reads, is a bug: under xdist the other test
is often running at the same moment elsewhere.
Module state is classified. One process serves many sessions on threads
(hosted mode), so a new module-level global or class instance, class-level
container, lru_cache function, or thread, timer or pool site in engine/ is
classified in tests/fixtures/module_state.yaml in the same change -- per
context, locked, warmed (with its WARMERS loader in
engine/games/caches.py), or one of the verdicts that say why nothing is
needed (docs/DESIGN.md § Many sessions, one process).
tests/test_module_state_inventory.py fails until it is. A lazy getter is
double-checked under its own lock; a per-call flag is a threading.local or a
ContextVar, never a module bool. A new module lock takes its place in
engine/locks.py's one lock order (and tests/lock_order.py's copy) and is
registered with renew_after_fork; a thread holding a lock takes only a later
one, and a new per-object lock is a leaf (nothing taken under it). A cache in
caches.py's NULLED_ATTRIBUTES is read once into a local and returned from
the local (tests/test_cache_reset_race.py): a reset nulls it with no lock.
Time and randomness in new systems. A new system advances on IN-GAME hours
inside clock.advance_time, never on the background world tick, which is
wall-clock by design (issue R-03). Whether a guard heard about a crime must
replay from the seed and the choices, not from how long the menu sat open.
Each system draws its own named stream from engine/game/rng.py.
The three audit questions. For any mechanic: (1) is it called? --
tests/test_reachability.py asks this mechanically of skills and constants,
not of plain functions; (2) does the narration know? -- a mechanic the prose
cannot refer to is one the player experiences as an unexplained event; (3)
does the prose agree with the receipt? -- the evaluator fails success
narrated over a failed check and arrival over a refused move.
Debt is written down, not implied. The repo carries zero TODO/FIXME markers
by convention. Unwired work is a row in a NOT WIRED table naming its file
(docs/GOVERNANCE.md, docs/STATE.md, docs/AGENTS.md), per rule 9.
The client. ui/src builds into the COMMITTED
content/scenes/clockwork/static/dist; rebuild and commit it in the same
change (see "Verify a checkout").
Shared play-screen surfaces (the wanted poster, the job panel, the casing
board, the people strip, the encounter's approaches, the roll card, the
negotiation panel) are engine panels enabled by data: a story lists them
in its manifest's ui.panels, and a plugin that draws one itself names it in
ownsPanels. A new panel joins engine/games/manifest.py::UI_PANELS and
ui/src/core/panels/registry.js together, which
tests/test_ui_panels_manifest.py holds equal. A layout change is gated by
npm run layout-check --prefix ui, which probes the play screen at four sizes
(1366x768, 900x600, 390x844 and the landscape phone 844x390) and fails on a
panel over the choices or an unreachable control; it expects
scripts/screenshot_runs.py --serve <slug> --exit-with-parent running as a
background task (never a bare &) and drives the installed Chrome
(playwright-core downloads no browser). The README's captures come from the
same pair: scripts/screenshot_runs.py makes the runs with no model, and
ui/tools/screenshots.mjs (npm run screenshots --prefix ui) photographs
them. When several agents run the suite at once, give each its own
--basetemp directory (ptmp-<agent>): pytest wipes its basetemp at start,
so two runs sharing one delete each other's temp trees.
Story content lives under games/<slug>/ and is written against
docs/AUTHORING.md, not against engine source. A story that does not declare a
paths.* key pays nothing for that system, and its turns stay byte-identical --
asserted by test for every optional system.
Windows (PowerShell):
.\.venv\Scripts\python.exe -m pip install -r requirements.txt -c constraints.txt
.\.venv\Scripts\python.exe scripts\doctor.py # environment, config, content
.\.venv\Scripts\python.exe scripts\run_tests.py fast # day to day: the fast tier, about 6 min
.\.venv\Scripts\python.exe scripts\run_tests.py full # at a release: every tier, hybrid, about 100 min; fully green (FLAKY is listed, not hidden)
npm ci --prefix ui; npm test --prefix ui # the client: plugins, reducer, veiled rule
npm run build --prefix ui # rebuild the COMMITTED dist after any ui/src change
.\.venv\Scripts\python.exe launcher.py --check # local services and what each outage costs
.\.venv\Scripts\python.exe scripts\simulate.py --policy all --turns 200
.\.venv\Scripts\python.exe scripts\simulate.py --game hue-and-cry # HUE & CRY's thief, over its own harnesses
.\.venv\Scripts\python.exe scripts\simulate_social.py --game read-receipts --sessions 6 --seeds 10 --policy attentive --model-seconds 30 # READ RECEIPTS' phone: phone-only sessions, stub voice
docker build -t clockwork-dark . # the hosted image (Docker; docs/HOSTING.md § Docker)Linux (POSIX sh; other POSIX systems, macOS included, should work but are untested):
.venv/bin/python -m pip install -r requirements.txt -c constraints.txt
.venv/bin/python scripts/doctor.py
.venv/bin/python scripts/run_tests.py fast # day to day
.venv/bin/python scripts/run_tests.py full # at a release: every tier, hybrid, bounded
npm ci --prefix ui && npm test --prefix ui
npm run build --prefix ui
.venv/bin/python launcher.py --check
.venv/bin/python scripts/simulate.py --policy all --turns 200
.venv/bin/python scripts/simulate.py --game hue-and-cry
.venv/bin/python scripts/simulate_social.py --game read-receipts --sessions 6 --seeds 10 --policy attentive --model-seconds 30
docker build -t clockwork-dark .The image build needs Docker and fetches the server's pinned wheels from
PyPI; CLOCKWORK_DOCKER_SMOKE=1 pytest tests/test_docker_smoke.py -s then
runs it end to end with a stub model server (docs/HOSTING.md § Docker,
Checking it).
scripts/start.ps1 and scripts/start.sh do the install and the suite in
one step. Install with -c constraints.txt always: it pins the versions the
suite was proven green on (re-pinning is a CHANGELOG'd edit).
The build output is content/scenes/clockwork/static/dist, and it is
committed so the game plays with no node installed. Change ui/src without
rebuilding and the change never reaches a player — so
test_the_committed_build_is_not_behind_its_source
(tests/test_ui_contract.py) fails the suite when any build input carries a
commit the build does not, and names the files that are ahead. It reads git
history rather than mtimes, because a fresh clone has no meaningful mtimes, and
it skips cleanly where history cannot be read: no .git, no git on PATH, or a
rebuild still sitting uncommitted. Rebuild and commit dist in the same change
and it passes.
- Agents:
clockwork_storyteller,clockwork_assistant - Locations:
forest_clearing,edgewood_square,edgewood_bakery,tinker_caravan,millhaven_gate - Evil phases:
dormant,stirring,spreading,consuming - Arcs:
quiet_life,whisper,march,convergence - Skills:
persuasion,stealth,sympathy,lore,craft,survival,nerve - Difficulty bands:
trivial,easy,standard,hard,severe,legendary