Commit Graph
101 Commits
Author SHA1 Message Date
parththakkar106andClaude Opus 5 f1bebe18d0 Drop the eight legacy columns SP8 left behind
Migrations 66 to 73 drop `actions.index`, `variants`, `variant_index`,
`variant_count`, `state_before`, and `world_state_before`, plus
`adventures.memory_cursor` and `summary_cursor`. `index` is a keyword in
SQLite, so migration 71 quotes it.

Nothing outside the migrations read these. `models.py`, `schemas.py`, and
`ACTION_LIST_COLUMNS` lose the same eight fields, `Adventure.actions` orders
by `id`, and `attempts.renumber`, `context.history.max_action_index`, and
`nodes.next_index` are deleted.

Two changes keep the migration replayable on a `create_all` database:

- `_split_variants_into_siblings` wrote through the live ORM table, so it
  stopped compiling once migration 66 removed five of its columns. It now
  writes through `_ACTIONS_AT_60`, a frozen `Table` with its own `MetaData`.
- Five data passes read columns these migrations drop. Each now calls
  `_has_columns` and returns early when the columns are absent.

`bootstrap` takes a `through` version so a migration test can stop at the
schema it asserts on.

555 tests pass, up from 549. Eight of the new cases assert each column is
gone after a real schema-45 database migrates all the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198qDK3gmgSo7EtQ4GTPqqK
2026-08-29 17:15:49 +05:30
parththakkar106andClaude Opus 5 e0bf2b61d9 Add a useDebouncedSave hook and delete Settings.stream
Two items from Stage 2 of `plan/17-refactor.md`.

`frontend/src/hooks/useDebouncedSave.js` replaces three copies of the same
debounce. `PlotPanel` and `ScenarioEditor` held identical per-key timer maps.
`ScriptEditor` held a single shared timer, so editing two fields inside the
same 600 ms window canceled the first save. The hook gives every key its own
timer, which fixes that.

`Settings.stream` was dead state. Nothing read it and every turn streams. This
removes the column, both schema fields, and adds migration 65 to drop it. It is
item S1 in `docs/self-review.md`.

Migration 65 needs a new guard. `_column_already_gone` is the counterpart to
`_column_already_there`: `create_all` builds the current schema, which is
already missing every dropped column, so a fixture that stamps an old version
and replays would fail on a column that is not there.

Verified: 549 backend tests pass, lint and build are clean, and migration 65
runs both ways, once against a database that still has the column and once
against one that does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
2026-08-29 02:12:32 +05:30
parththakkar106andClaude Opus 5 2c57b1ceab Remove three kinds of duplication in the backend
Stage 2, items 1, 4, and 5 of `plan/17-refactor.md`.

**One path resolver in `worldstate`.** `apply_delta` and `apply_override` routed
`flags.<name>`, `milestones.<id>`, `world.<stat>`, `player.<stat>`, and
`npc.<id>.<stat>` with parallel code, about 100 lines each. `_resolve` now says
what a path points at and returns either a target or the rejection to report.
Each function keeps its own write rule, because the rules genuinely differ: an
override sets a number rather than adding to it, ignores `cooldown`,
`max_delta_per_turn`, and the rule that a counter only counts up, and can un-set
a milestone.

A differential check ran both implementations over 3960 payloads: twenty paths,
fourteen values, three starting states, plus every three-path combination. The
results are identical except that 674 rejections from `apply_override` now carry
a `fix` string. `apply_delta` already worded those, and the world-state editor
renders them, so an override that names an unknown flag now explains itself the
way a delta does.

**`sse`, `SSE_HEADERS`, and `turn_error` move to `app/sse.py`.** Two routers
stream, and `chat.py` had to import from `routers.adventures` to reach them.

**`get_adventure_or_404` becomes the `current_adventure` dependency.** All 32
handlers repeated the call as their first statement. The ownership check now
reads in the signature and runs before the body. FastAPI caches a dependency for
one request, so the handler's `db` is the session the adventure came from.

The generated OpenAPI document is byte-identical except on `rename_branch`,
where `branch_id` is now listed before `adventure_id`, because that handler no
longer names `adventure_id` itself. Parameter order in the document is
cosmetic.

Six tests in `test_state_revert.py` call `undo_turn` and `retry_action`
directly rather than over HTTP. They pass the adventure they already hold
instead of an id.

549 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
2026-08-29 02:04:02 +05:30
parththakkar106andClaude Opus 5 0623dd8b78 Split Play.jsx into a directory of twelve files
`frontend/src/pages/Play.jsx` was 2280 lines holding 27 components. It is now
`frontend/src/pages/Play/`: `index.jsx` with the page component, five panels,
two drawers, the reports, the take pager, the refresh dialog, and the shared
formatting helpers.

Every line moved verbatim. A coverage check confirms every non-blank line of
the original appears exactly once, in order, across the twelve files, and a
name-resolution check confirms every identifier each file references is defined
or imported there, with no unused imports.

The line split stranded a comment at six of the boundaries. A leading comment
sits above the section it describes, so each cut left one at the end of the file
before it. All six moved to the section they describe, rewritten in the house
style.

`usePlaySession.js` is not here. The page component still owns all of the
session state. Moving eighteen `useState` calls and seven `useEffect` calls is a
rewrite rather than a move, and no frontend test would catch a mistake in it
today, so it waits for Stage 5.

Four stand-in providers under `backend/tools/` now define `last_usage = None`.
The turn engine reads that attribute after every call, and the fixtures never
defined it, so `tools.tree_fixture` crashed. That break predates this branch.

Verified by 549 passing tests, a clean `npm run lint` and `npm run build`, and
by driving the Play screen: the story view, all five panels, both drawers
including the world-state edit form, the branch map, the refresh dialog, and the
take pager stepping onto a take that lives on another branch. No console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
2026-08-29 01:33:14 +05:30
parththakkar106andClaude Opus 5 8422ff24f6 Split the world-state engine into four modules
`backend/app/worldstate/engine.py` held 918 lines covering four separate jobs:
reading a scenario's schema, parsing the block the model writes, applying a
change within the schema's limits, and rendering state as prompt text. Each is
now its own module, the largest 418 lines.

An AST comparison against the old file confirms all 29 definitions are
identical. No call site changes, because `worldstate/__init__.py` exports the
same names it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
2026-08-29 01:09:41 +05:30
parththakkar106andClaude Opus 5 2fa812c056 Split the adventures router into a package
`backend/app/routers/adventures.py` held 2353 lines and 35 endpoints. It is now
a package of 14 modules, the largest 443 lines.

The split moves text rather than rewriting it. An AST comparison against the
old file confirms all 86 definitions are identical, and the OpenAPI schema
still lists the same 35 operations.

Names a test replaces now live in `turns.py` only, and other modules reach them
as `turns.<name>`. Rebinding a re-exported alias changes the alias and leaves
every caller reading the original, so the package root does not re-export them.
A patch aimed at the old target raises `AttributeError` instead of passing while
doing nothing. Tests and the fixtures in `backend/tools/` say
`adventures.turns.<name>`.

The same rule keeps the turn lock working. One module owns `_active_turns`, so
one lock guards one set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
2026-08-29 01:05:50 +05:30
parththakkar106andClaude Opus 5 32cd7c1077 Give the tests one setup instead of thirty-five
Every test module carried the same eight-line prologue redirecting the
database to a temp file. Only the first one to be imported ever took
effect: `app.database` reads `AIDND_DB_PATH` at import and builds `engine`
from it once, so by the time the second module ran the engine already
existed. The other 34 copies created a temp file that nothing opened and
nothing deleted, and leaked one per module per run.

`conftest.py` now does it once, which is early enough because pytest
imports conftest before any test module. It also deletes the file when the
run ends. The tests still share one database, exactly as they already did:
each `client` fixture calls `create_all` on setup and `drop_all` on
teardown, so no test sees another test's rows.

`tests/fakes.py` holds the one `ScriptedProvider`. Nine modules each had a
copy, and the copies had drifted into four feature sets, so a test that
needed to raise a provider error had to be written in one of the files
whose copy supported that. The shared one is the superset. The two
`FakeProvider` copies were the same class with a fixed reply, so they use
it too. `test_chat.py` keeps its own, which implements `chat` rather than
`generate` and records what it was constructed with.

An autouse fixture resets the fake's class state between tests, so a stale
reply list can no longer reach the next test.

435 lines out of the suite. 549 tests pass. Verified live by sabotage:
breaking the shared fake fails 13 tests across four modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
2026-08-29 00:47:46 +05:30
parththakkar106andClaude Opus 5 b1772c6e21 Plan the readability refactor, and clear the tree for it
Phase 17 splits the four files that hold most of the code, finishes the
schema migration SP8 left half done, and stops the published guide from
drifting away from its Markdown source. `plan/17-refactor.md` carries the
plan and the progress table, and `plan/STATUS.md` points at it.

Stage 0 is hygiene only. Both abandoned worktrees are gone, which freed
about 104 MB. Removing `sp7-tree-ui` needed one extra step: a Vite dev
server had been running out of it since 2026-08-18, holding
`frontend/.vite` open and owning port 5173, and serving a tree 54 commits
behind `main`. The three stale `.db` files are deleted; `data.db` is not.

`AIDND_TRUSTED_PROXY_HOPS` is now documented. It was read at `limits.py:55`
and named in no `.env.example`, README, or blueprint. It sets how many
proxy hops the rate limiter trusts in `X-Forwarded-For`, so a deployment
that adds a hop without setting it gets the bucket-rotation bypass back.

The 19 squash-landed branches are still there. `git branch -D` is blocked
by the permission classifier; the verified command is in the plan file.

549 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
2026-08-29 00:36:21 +05:30
parththakkar106andClaude Opus 5 9398c13da5 Delete seeded scenarios no seed file claims any more
`previous_titles` stops a rename stranding the row it left behind, but the rows
already stranded still had to be deleted by hand on every deployment. The
seeder now removes them on the next boot, which retires the stale "Road to the
Champion" demo without a database console.

Only rows with a NULL owner and `is_public` are considered, and a player's own
scenario is neither, so nothing anybody created is reachable. An adventure
started from a deleted demo survives: `adventures.scenario_id` is `ON DELETE
SET NULL`, and the adventure holds its own copies of the cards and scripts, so
it loses only the inherited cover art.

Two cases skip the sweep, because neither is an instruction to remove live
content: a seed file that fails to parse claims no title, and an empty seed
directory reads as a packaging failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 19:19:23 +05:30
parththakkar106andClaude Opus 5 c2d3f0d8b9 Show the starter adventure the artwork of the demo it came from
An adventure has no cover art of its own and inherits its scenario's, and a
bundle carries no scenario id, because an id means nothing in another database.
The starter card therefore fell back to a monogram tile while the demo beside
it showed the Pokeball.

The starter file names its source under `scenarioTitle`, and the copy is linked
to the seeded scenario with that title. If no seed answers to the name, the
adventure keeps a NULL `scenario_id`, which is the state every imported bundle
is in and costs only the artwork.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 19:19:23 +05:30
parththakkar106andClaude Opus 5 ef4da7fda2 Keep the local Claude test rig, and record what it found
`backend/tools/claude_shim.py` serves an OpenAI-compatible endpoint backed by
the local `claude` command line tool, so a demo can be played against a real
model with no API key. Each request spawns one `claude --print` process, which
suits the turn engine: the app assembles the whole prompt every turn and
expects a stateless endpoint.

Playing the Pokemon demo through it made all five of the world-state changes in
`plan/16` work, and the refusal loop ran end to end for the first time: turn 3
clamped to nothing, turn 4's assembled prompt carried the correction verbatim,
and the model's next delta was right. Two failures previously blamed on the
code were the demo model. One is a schema fault and is still open: Milo's three
Pokemon share one `active_hp` stat, so a switch leaves the newcomer at 0 HP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 18:42:24 +05:30
parththakkar106andClaude Opus 5 6cbf6d996f Give every new guest an adventure that is already played
An empty account gives a visitor nothing to read, and the daily demo turns are
limited, so learning what the app does cost one of them. `app/starter.py` now
copies a shipped export bundle into each new guest at the point the row is
created. The bundle is two exchanges of the Pokemon demo, which ends on a
knockout and shows an applied change, a refused one, and a milestone.

The guest row is committed before the copy is attempted, so a failure there
still leaves them with an account, and the copy runs inside a savepoint.

The row building that `POST /adventures/import` did inline moved into
`bundle.materialize`, which both callers use. The rate and size checks stayed
in the endpoint: the starter writes a file the server ships, so it has no
untrusted list to cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 18:42:15 +05:30
parththakkar106andClaude Opus 5 ae39514d1e Name the Pokemon demo, and land a seed rename on its own row
`seed.py` matches a seed file to its scenario by title, so renaming one
inserted a second public scenario and stranded the first. The stranded row
stays public forever and has to be deleted by hand on every deployment, which
is what happened to "Road to the Champion". A seed file now lists its old
titles under `previous_titles`, and the rename updates the existing row.

The cover art is a PNG data URI. `app/images.py` accepts raster formats only,
because SVG can carry script and the bytes are served from the app's own
origin, so an SVG stores but yields an empty `image_url`.
`tools/make_pokeball.py` draws the ball with `zlib` alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 18:42:06 +05:30
parththakkar106andClaude Opus 5 ab546d6f3c Ask the Pokemon demo for changes, not for totals
The model sent a total rather than a change for almost every number:
`npc.ivysaur.hp: 96`, `npc.milo.active_hp: 88`, `player.potions: 2`. Because
every `hp` starts at its maximum, each one clamped back to where it started, and
the potion count rose when the player spent one.

`EMIT_RULE` does say "deltas (not new totals)", but the scenario contradicted it
at closer range. `milo.active_hp`'s description said "Reset this to the
newcomer's full HP", which asks for an absolute and is injected every turn. The
bullets said "drop the HP, and raise it when healed", naming a direction but no
sign. Five HP descriptions said only whose HP it was. The one line that said
"not a delta" covered `player.active_pokemon`, so naming the exception made the
rule look optional. `pokemon_fainted` is the control: its description says "add
1 each time", and it is the only number that behaved.

Every stat description now states the sign, and the lead-in gives a worked
example. `milo.active_hp.max_delta_per_turn` goes 65 to 98, because a switch
moves that stat a full bar from 0 and the old cap made the reset unreachable in
one turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 17:22:02 +05:30
parththakkar106andClaude Opus 5 1988979aeb Store the refusals, so the chips and the model can see them
`world_delta_of` wrote `delta` and `applied` only. Every consumer that tells a
refused change from a successful one reads the two lists it dropped:
`Action.world_changes` marks a clamped chip from `clamped` and builds its
refusal chips from `rejected`, and `worldstate.refusals` reads both. So no chip
could report a limit, no rejection chip could appear, and no correction ever
reached the next prompt. The three mechanisms merged last session were live in
the code and unreachable in production.

Found by playing the Pokemon demo. Turn 3's snapshot held two clamped entries
with correct `fix` text, both chips came back `clamped: false`, and turn 4's
prompt carried no correction, so the model repeated the same mistake.

The 21 tests passed because `action()` built the column by hand with every list
present. It now fills the column through `world_delta_of`. Removing the two new
lines fails 9 of the 23 tests; that was checked by sabotage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 17:22:02 +05:30
parththakkar106andClaude Opus 5 47c7800903 Report the world-state changes the engine refuses
`apply_delta` records three outcomes for every change the model sends:
`applied`, `clamped`, and `rejected`. Everything downstream read only
`applied`. A refused change reached the player as an ordinary chip, and
reached the model on the next turn as a change that had succeeded.

Five parts:

- `Action.world_changes` reads `clamped` and `rejected` beside `applied`.
  Accepted stats carry a `clamped` flag; refusals become `kind: "rejected"`
  entries. The `fix` key is present only when the engine wrote one, because
  this property runs for every action of every list response.
- The UI separates the three outcomes. A clamp to a standstill reads
  `no change - at its limit` on a dashed chip, a partial clamp is marked
  `(limited)`, and a rejection carries its reason. Dashed and dimmed rather
  than red: a refused change means the rules are working.
- The goals line names the milestone id, as `milestones.<id>`. The ids
  appeared nowhere in the prompt before, so the model could not send one.
- Each rejection, and each clamp that moved nothing, builds a `fix` string
  from the stat definition at the point of refusal. `render_refusals()`
  renders them into the next prompt above `EMIT_REMINDER`.
- `_history_text` replays `applied_delta()` instead of the sent delta, so a
  past turn's state block shows only what the engine accepted.

A clamp that reduced a change but still moved the value reports nothing. If
you tell a model its 80 damage became 30, it can treat the shortfall as a
debt and send the remaining 50 next turn, which is the swing
`max_delta_per_turn` prevents.

In the demo scenario, `pokemon_left` becomes `pokemon_fainted`
(`type: counter`, `initial: 0`). Starting at the ceiling turned a wrong-signed
delta into a silent no-op; counting up puts the wrong sign on the counter
rule, which refuses it out loud. The instructions also now ask for
`world.turn`, which sat at 0 for a whole playtest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 16:26:34 +05:30
parththakkar106andClaude Sonnet 5 2ca2dadc72 Fix stale description wording after the flags-to-text-field change
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 04:15:40 +05:30
parththakkar106andClaude Sonnet 5 2805eb1fb1 Nest the player's Pokemon under npcs instead of flattening them
Each teammate is its own named entity with hp/status, the same shape
Milo already uses, rather than <name>_hp/<name>_status flattened
under player. Also replaces the five mutually-exclusive _active
flags with a single player.active_pokemon text field, mirroring
npc.milo.active_pokemon, so the AI no longer has to self-enforce
"exactly one flag true."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 04:14:04 +05:30
parththakkar106andClaude Sonnet 5 cd1ae219ad Rework League Championship demo into a real Pokemon battle
Each of 5 team Pokemon now tracks its own HP and status condition,
one flag marks who's active, and potions are a depletable resource.
The opponent NPC tracks its active Pokemon the same way. Drops the
crowd-favor stat, which didn't fit a battle scenario.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 04:09:54 +05:30
parththakkar106andClaude Sonnet 5 246a355a49 Add League Championship demo scenario
Showcases world-state stats and bands, a counter, a text stat, flags,
two NPCs with different stat shapes, and milestones in one scene.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 03:10:08 +05:30
Parth e7d75c3b05 Rewrite Python comments in Google developer documentation style (#12)
* Rewrite comments in Google developer documentation style

Rewrite the comments and docstrings across the backend core modules so they
read plainly. The previous prose was accurate but dense and figurative, which
made it slow to skim.

Applies the Google developer documentation style guide: short sentences, active
voice, present tense, American spelling, and no metaphors, idioms, or
rhetorical asides. Replaces em-dash chains with separate sentences.
2026-08-26 15:37:25 +05:30
parththakkar106andClaude Opus 5 a408c7b6f7 Lay the prompt out so the endpoint can cache most of it
Prompt caching bills on a shared prefix: the endpoint reuses the request up
to the first byte that differs from last time and no further. The live
world-state block sat third from the top of the system message, so every turn
re-priced the instructions, the plot essentials and the whole story history
underneath it. The retrieved memories and the rewritten summary did it again.

Everything fixed is emitted first now, and everything that moves goes after
the history, ordered least-volatile first — which is also where recency serves
it best, the reasoning that already put the emit reminder last. The three tail
sections that are last for their own reasons stay last. The moved sections are
still charged to the token budget; only their position changed.

Two smaller halves of the same problem. OpenRouter serves a model from
whichever upstream is free and each upstream holds its own cache, so a
deepseek model now names deepseek as its preferred upstream — a preference,
not a restriction, so a turn still runs if that upstream is down. And the
endpoint's usage block is read back off the response and kept per attempt, so
the hit rate shows up in Insights and the debug log instead of being assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
2026-08-23 06:31:30 +05:30
parththakkar106andClaude Opus 5 041f9e25f3 Count the visits, and say whether anyone got anywhere
A hosted demo raises a question a local app never does: is anyone using it,
and do they reach the part that matters? `/analytics` answers it — visitors,
pages, referrers, countries, devices, which shared scenarios get played, turns
and demo-key spend, API and turn errors, and a funnel from visited to played a
turn to signed up.

Not a third-party script, for reasons specific to this one. The CSP allows
`script-src 'self'`, so a tracker means loosening it; adblockers eat the
popular ones, which silently biases exactly the technical audience this
project gets shown to; and none of them can see the measurement that actually
matters here, which is a turn, not a pageview.

**A visit is a write and never a read.** After the 189x egress fix it would be
perverse to add a feature that reads rows per request, so counts accumulate in
a process-local dict and flush every 60s as UPSERTs. Storage is a generic
`(day, metric, label) -> hits` counter, so measuring something new later costs
a constant rather than a migration, plus one row per visitor per day for the
funnel flags. Every dashboard query is a GROUP BY returning tens of rows
however much traffic sits behind it; a month reads back in a few kilobytes.
The buffer's cost is that a hard restart can lose up to a minute — the flusher
also runs on shutdown, and a tier that sleeps when idle sleeps on an empty
buffer anyway.

**The counters are anonymous; the access log beside them is not, on purpose.**
A visitor is `HMAC(secret, "visitor:<user id>")` truncated to 32 chars —
one-way, so `analytics_daily` and `analytics_visitor_days` cannot be joined
back to `users`, and keyed, so no client can compute one. Story content never
reaches that module, and the only content it ever names is a seeded public
scenario's title; a player's own titles are theirs. `accesslog.py` is the
identifying half and is a separate module writing a separate table so that
separation is a property of the code rather than a convention: `access_events`
records sessions, sign-ins, registrations and failed attempts with address,
email and device, read on a second tab of the same page behind the same gate.

Both halves are gated on `AIDND_ANALYTICS_EMAILS`, not `POWER_USERS`. An
unmetered tester is not automatically someone who should see the traffic. The
route 404s and the nav link is absent for everyone else, the same treatment
AI Chat gets; unset in a hosted deploy means nobody sees it, including me.

Three things came out of building it that a test would not have suggested.

**A failed turn is an HTTP 200 with a bad ending.** The status-code middleware
cannot see one, so a demo whose model had started refusing every request would
look perfectly healthy from outside. All five SSE error paths in
`_generate_turn` now go through a `turn_error()` helper that counts on the way
out. Error buckets elsewhere are labelled by the matched route template rather
than the requested path — one bucket per endpoint instead of one per adventure
id, and, the reason it isn't merely tidier, an unmatched path is entirely
attacker-chosen, so labelling by it would let anyone mint rows.

**The funnel counts people, not clicks.** A player who starts six adventures
is one person who started an adventure. That is the whole reason the
per-visitor-day table exists; its flags only ever turn on, and `is_new` is
settled by the first write of a visitor's first day.

**The tests run on SQLite and production is Neon.** A flush that raises is
caught and logged, so a dialect mistake in the UPSERTs would have stayed
invisible until the dashboard quietly never filled.
`test_the_upserts_compile_for_postgres` compiles both statements against the
Postgres dialect without connecting to one.

Two things this leans on elsewhere. `limits._client_ip` is now public
`client_ip`: the access log needs the same answer, and two functions both
deciding which hop is the caller's is how one of them ends up trusting a
header it shouldn't. And the cleanup sweeper now starts if *either* job has
work — a deployment can keep every guest forever and still want its
visitor-day rows aged out.

No migration. Both tables are new and `bootstrap()` calls `create_all` on
existing databases too, the route `branches` took in Phase 14, so
`LATEST_VERSION` is still 64.

497 tests green, frontend lint and build clean, driven by hand against a
synthetic 90-day fixture at 1568px. The narrow-screen layout follows the
existing 720px block but is unverified: `resize_window` is ignored on a
maximized Chrome and `frame-ancestors 'none'` rules out checking it in a sized
iframe. Also repaired here: a rename in test_ratelimit_hardening.py had run
through the test names themselves, leaving `testclient_ip_*` — still collected
by pytest, which is why it passed unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
2026-08-22 16:24:42 +05:30
parththakkar106andClaude Opus 5 3b9e6b3d50 Give the length hint a floor, not just a wall
A ceiling alone is a one-sided instruction, and models read it in opposite
directions. A verbose one is held back by it; a terse one has nothing to act
on except "write only as much as the moment needs -- a typical turn is much
shorter" and collapses to two paragraphs. Same prompt, wildly different turn
lengths depending on which model is behind it.

State a floor as well, so the guidance is a band. The two bounds are
deliberately asymmetric -- "must not exceed" for the wall the endpoint
enforces, "should not stop short of" for the floor -- so neither reads as a
number to hit, which is the property the earlier A/B says decides whether
this hint helps or hurts. "Prefer the lower end" inherits the anti-overshoot
job the deleted "much shorter" line was doing, but now with a number under
it, so a terse model lands on the floor instead of at forty words.

Below MIN_LENGTH_FLOOR_WORDS the floor is dropped and the tight-cap wording
is left byte-identical: at a tight cap a short turn is the correct turn, and
that phrasing is the one measured to keep the state block alive (0/6
truncations at cap 250 against 2/6 unhinted). So this only moves loose caps.
MAX_LENGTH_FLOOR_WORDS keeps the share from demanding 555 words minimum at
cap 2400 -- a big cap means long turns are allowed, not compulsory.

Shipped without an A/B run, deliberately. Two things to watch live: whether a
stated range invites landing mid-range on verbose models (drop the share to
~0.25 if so), and whether the state block still survives -- nothing reads
finish_reason yet, so truncation is silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
2026-08-21 00:14:56 +05:30
parththakkar106andClaude Opus 5 40d2555f84 Say what the app is now, everywhere it is published
The README, the project page and the engineering guide all describe a linear
story. The tree shipped two days ago. Every published surface is a phase
behind, and the guide is not merely behind — it is wrong in a way that costs a
reader time.

Its 2.2 was "Two coordinate systems, and the bug class they create", and it
explained the codebase through position_of_index, note_action_removed and
settled_story_actions. All three were deleted in SP3. 2.3 explained retry
through Action.variants and state_before. Somebody reading either would go
looking for machinery that is not there, which is worse than a gap.

So 2.2 is now "The story is a tree", written at the depth 1.2 and 1.3 are
written at: the seven bugs that turned out to be one bug, the lineage clause
and the two properties that make fork count free, why takes group by parent_id
rather than by coordinate, cursors becoming anchors, and a closing list of what
the design is honest about. 2.3 is rewritten around state_after and takes, and
1.1 and 1.5 follow, because the pipeline no longer snapshots before the call
and the memory bank no longer holds an action back.

The numbers were simply old: 151 tests where there are 440, 37 migrations where
there are 64, twelve phases where there are fourteen. They appear in four
places across the README, the project page's stat tiles and the guide's results
table. The measured branch cost — 103 B, and 1.007x the page load of the same
story flat — is added beside the egress and turn-cost figures it belongs with,
since it is the number that answers "what does branching cost me".

Three screenshots, on a new tools/shots_fixture.py: the Bandit Camp demo driven
through eight written turns with written deltas, three discarded takes forked
onto branches of their own, one off a branch so the map has to nest. Same
reason tree_fixture.py is committed — the shots have to be reproducible and the
frontend still has no test runner. play-world-state.jpg is reshot because it
predates the entire tree UI; the map and the branches panel are new.

Note for next time: docs/guide.html is hand-written, not generated from the
Markdown, so every guide edit is two edits in two vocabularies. Both files were
checked for tag balance and both pages rendered locally before this landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
2026-08-20 04:19:53 +05:30
parththakkar106andClaude Opus 5 c8e081e9d6 Draw the tree the branch list can only spell out
The Branches panel says which lines exist. It cannot say where they parted
or how much story each one is, because those are the two numbers `GET
/branches` already answers and a list has nowhere to put them. A ⌗ See the
tree button opens a map: one horizontal lane per branch, running from the
moment it left its parent to the moment it ends, joined to the parent by an
elbow at the fork. The horizontal axis is the story's own clock, so two
lanes at the same x are at the same moment and a short branch reads as
short.

This is a branch map, not the per-node map SP7 refused, and that is the
whole reason it was cheap. Lanes are bounded by branch count, not node
count, so the 600-node windowing problem never arrives. It reads the single
request the rail already made and nothing else.

`branches.js` holds the tree maths and `BranchMap.jsx` the drawing. Play.jsx
gives up its private copies of branchLabel and orderBranches: the panel and
the map now label and order a branch through the same functions, so a branch
cannot be called two things by the two views. The three operations stay in
BranchPanel and are passed down, and `run` answers whether it worked so
neither view clears a half-typed name on a refusal.

Three things came out of driving it, none of which a test could have seen.
`clientWidth` counts the canvas padding the ResizeObserver leaves out, so
the first paint drew an svg 24px wider than its box — and the observer's
initial observation never arrived here, so dropping the seed left the map
never drawing at all. Both are needed and the seed subtracts the padding.
Only the name was being clipped, not the meta line under it, so a late fork
ran its text off the right edge; both are clipped now, and a lane starting
in the right third hangs its labels back over the fork, where its own band
guarantees nothing to collide with. And the delete rule lived in the server
and in the map but not in the list, which offered Delete on a branch the
head was forked from and answered with a toast from the server's 400.
`headLineage` is the client's copy of that rule and both views use it; the
server stays the authority.

`tools/tree_fixture.py` is the counterpart to `tools/branch_fixture.py` —
four branches at three fork depths, one forked off a fork. With no frontend
test runner it is the whole of the map's coverage, and it exists to be
looked at. Nothing in the backend changed; the 440 tests pass unmoved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
2026-08-20 03:38:17 +05:30
parththakkar106andClaude Opus 5 ff190d2453 Edit the take you are reading, not the one it replaced
The pager parks a turn on take 2 of 4 and the row draws that take's words,
but the row itself is keyed by the live node — the take the story tells. The
edit button seeded from that node and saved back to it, so opening the editor
on a 2/4 turn showed 4/4's text and saving overwrote 4/4. The take being read
was never reachable.

It has an id of its own, carried on the preview, and the edit endpoint takes
any row by id whether or not the path runs through it. So an edit opened over
a preview carries the take's id, the transcript matches the editor to the row
through the preview rather than the node, and the saved text goes back into
the preview because there is no row in `actions` to put it in. The pager
caches the take list it fetched, so it is told to drop it — otherwise
stepping away and back showed the words from before the edit. Leaving the
take at all drops a half-typed edit with it.

The fork button had the same seed and now takes its text from the screen too.
Its id stays the live node's: a take branches just above the turn, and the
server only accepts a turn that is on the path.

The tests are on the promise the fix leans on — a take is an ordinary row to
the edit endpoint, addressed by its own id, and the group listing says so
afterwards. That already held; nothing in the backend changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:37:36 +05:30
parththakkar106andClaude Opus 5 c0cd6fa7ce Stop a full memory bank from shutting itself
Eviction ranked on use_count first. A memory written this turn has never
been used, so once every survivor in a full bank had been retrieved even
once, the newborn was the lowest row in the bank and was marked forgotten
by the same post-turn run that wrote it — one pass after the one that
embedded it, before retrieval ever saw it.

That state never ends, because counts only go up. The bank an adventure
happened to hold when it first filled is the bank it keeps for good, and
everything the story does afterwards is summarized, evicted and never
ranked. A test now plays four turns against a full bank and asserts the
survivors are the new memories, not the opening ones; before this it kept
the opening three and forgot all four.

Order on last-touch instead, with the count as the tiebreak. A new memory
carries the newest timestamp there is, so it is the safest row in the bank
rather than the most doomed, and it has until something outlives it to
prove itself — the "protect the newest" behaviour falls out of the
ordering rather than being a count of rows to spare. Little is given up:
a memory that is genuinely used stays recently-used by being retrieved,
so the two orders only disagree about memories that mattered once and
have not been wanted since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 23:22:00 +05:30
parththakkar106andClaude Opus 5 c4936f75f5 Fix two things only a person clicking could find
Driving SP9 by hand found both, and neither was reachable from a test that
did not exist yet.

**The pager arrived one page load late.** A retry's reply *is* the second take
of its turn, so it comes back needing a pager -- but the SSE stream builds its
own ActionOut and so never got the annotation. The numbers only appeared once
the page was reloaded, which is the one moment nobody reloads. That is the
third place this codebase builds an action payload a different way; the other
two were patched when `take_count` was added, and this one was missed because
nothing read it from the wire.

**"> You > You follow the pulse down the corridor."** The editor is seeded from
the stored text, which is already the formatted form -- the same text plain
edit puts in the box and writes back verbatim. Running it through the formatter
again doubles the prefix, so `run_player_turn` learns `preformatted`.

And the question the driving raised: does the shared state follow the branch?
`test_take_state.py` answers it with a script that adds ten gold a turn, which
is the clearest possible witness -- a take that stacks instead of replacing
says so in one digit. Three of its five fail with `roll_back_before` removed;
the other two pass either way on purpose, one being the premise and one going
through `stand_on`, which has restored state since SP5.

Confirmed against the running app too, on the HP-script demo: a path crossing
three branches carried exactly the damage of the nine AI nodes on it, and none
of the 66 points sitting on takes those branches never told.

433 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 21:34:12 +05:30
parththakkar106andClaude Opus 5 f9b836fc0b Put the pager back, and let any turn be played again
SP7 replaced the pager with chips, on the grounds that a chip could also offer
"take this path" while a pager could only step. Driving it by hand said
otherwise, and the reason is worth keeping: the chip meant two different
things depending on where the reader was standing -- a real switch at the tip,
a preview needing a second button above it further back. Two meanings in one
control is what made the tree unusable.

So: one control that does one thing. Stepping reads a take and nothing else,
and it tells the server nothing, because reading is not a decision. The
transcript below a take that is not live simply ends -- such a take is a leaf
by construction, since whatever was played after the turn was played after the
take that *is* live. The decision is made by writing, and `after_id` carries it.

One step does reach the server and is still not a fork: a take with a story of
its own lives on its own branch, so going there is a branch switch and only the
server can say what is underneath. `branch_id` on the take is what tells the
two apart without asking first.

And a fork button on every turn but the opening. On the AI's it regenerates; on
your own it opens the text so you can say something else. What the story made
of the old take is kept, on the line it was written on.

`selectVariant` and `forkFromAttempt` leave the client. Both endpoints stay --
tested, and `stand_on` is shared with the write path -- but the pager needs
neither.

426 backend tests; lint and build clean. Not yet driven by hand: the frontend
still has no test runner, so this needs the `--keep` fixture and eyes, exactly
as SP7 did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 21:34:12 +05:30
parththakkar106andClaude Opus 5 ea7336e5d3 Let any turn be played again, not just the newest
`retry` only ever saw the last action: mid-story there was no way to ask for
another take at all. Now the take endpoint takes either kind of node, and the
kind decides what happens -- an AI turn regenerates, a player turn takes the
text supplied. The client asks the same way for both.

The tip is the only case that needs no branch, and only for an AI turn, where
the takes are still leaves nobody has built on. That is the existing retry,
reached by another road. Everywhere else `branch_at` leaves the path just
before the turn so the story after it keeps the take it was written for.

Both paths roll the shared state back to before the turn ran, which the fork
endpoint was already doing and the player-take path was not -- a script would
otherwise have stacked this take's output mutations on the one being replaced.

426 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 21:34:12 +05:30
parththakkar106andClaude Opus 5 c3c9b310eb Put the pager's numbers on the page
`2/4` needs the shape of a turn's take group for every message on screen.
Asking `attempts.group` per row would put a query behind each one -- the exact
cost `variant_count` was cached to avoid, and the reason SP8 could not just
drop that column and be done. So one query per page, keyed on the parents the
page mentions, and the count and ordinal land on the rows before they are
serialised.

`take_count` / `take_index` rather than reusing the old pair, because they do
not mean the same thing: the old ones cache a coordinate's siblings and say 0
for a turn nobody retook, these count the *turn's* takes across whatever
branches they ended up on and say 1/1. SP8 still drops `variant_count` and
`variant_index`.

Two traps, one of them mine. `parent_id` was not in ACTION_LIST_COLUMNS, so
reading it off a windowed row would have been a lazy load per action -- an
N+1 hidden behind the thing `load_only` exists to prevent. And the adventure
GET does not build `ActionOut` at all: it hands the window to the relationship
with `set_committed_value` and lets Pydantic walk it, so patching the three
places that do build ActionOut missed the one path every page load takes.
The test caught it by reading the wire instead of the helper.

424 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 21:34:12 +05:30
parththakkar106andClaude Opus 5 2726840c60 Let a player's own turn be played again
Retry has always given an AI turn another take. Nothing gave one to the
message the player wrote, so the only way to change something you had typed
was to overwrite it -- destructively, losing whatever the story made of it.
That is the half of the tree the player asked for first, and it never existed:
`POST /actions/{id}/fork` answers 400 unless a second take is already there,
so "branch from here" was not a thing the API could do.

`tree.branch_at` is the missing piece. `fork` moves a node that already exists
onto a line of its own; this is the same branch with nothing in it yet, for a
take that has not been written. The head lands one depth short, so the next
node written is the new take -- same depth, same parent, resolved from the
path by `place_action` without being told. The line being left keeps its node,
keeps it live, and keeps everything played after it. Nothing is copied.

Two things the tests said rather than confirmed. A player turn is never the
tip: the reply to it is, so retaking even the newest thing typed still owes a
branch, and the guard against forking for nothing only fires for a player
action with no reply under it. And player text is stored formatted, so a test
that compares it whole is testing the formatter.

421 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 21:34:12 +05:30
parththakkar106andClaude Opus 5 e126bda387 Make the branch happen when you write, not when you look
Stepping between takes used to be two controls and two meanings. At the tip a
chip switched; above the tip it only *previewed*, and taking that line needed a
second button next to it. Which one you got depended on where you were standing,
which is the thing that made the tree unusable when it was driven by hand.

So the server stops caring that anyone is looking. Reading a take the story
moved past changes nothing and creates nothing. `ActionCreate.after_id` names
the node a turn is played after, and naming a take the story left is the first
moment the player has said which line they mean -- so that is where the fork
happens, and only there.

`stand_on` is the old fork endpoint's body, lifted out whole. It already knew
the two cases and got them right: at the tip the takes are leaves nobody built
on, so it is a switch and no branch is made; past the tip the line being left
keeps every turn it has, so the take needs a branch. Both callers now share it,
which is the point -- a fork asked for and a fork arrived at are the same move.

Six tests. Five fail with the grouping reverted to the coordinate, and the one
that does not is deliberate: naming the tip in `after_id` must stay an ordinary
turn that forks nothing, which guards against over-correcting rather than
against the original bug. The nesting test passes both ways too and is kept for
what it says, not for what it catches -- a coordinate separates C1's takes from
C2's by accident, because the fork has already put them on different branches.

415 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 21:34:12 +05:30
parththakkar106andClaude Opus 5 6fa213f6db Group a turn's takes by their parent, not by where they sit
SP7 shipped a tree nobody could use. Driving it by hand found three things,
and the schema is what the third one needs.

A take forked onto its own branch leaves the coordinate its siblings are still
at. `attempts.group` filtered on (branch_id, depth), so that take read as the
only one of its turn -- 1/1 where the player is owed 1/3, with the other takes
unreachable from the line they were taken on. Nesting has the same shape from
the other side: takes under C1 and takes under C2 share a depth, and only the
parent says a pager under C2 reads 2/2 rather than counting C1's three too.

So `actions.parent_id`, and `group` keys on it. The alternative -- pointing a
branch's fork at a node instead of a depth, so a promoted take never moves --
was rejected: `lineage` exists so a read is an OR-clause per branch rather
than a walk up parent pointers, and moving the fork point changes path
resolution itself, which drags in cursors, memory depths and both bundle
formats. This column is read to group takes and for nothing else. One indexed
lookup, never a walk, and no read of the story changes.

Two writers had to learn it. `add_attempt` places a sibling by hand rather
than through `place_action`, and copies the parent, because a take belongs to
the turn it is a take *of*. `place_action` resolves it from the path for a
genuinely new node, which is the honest default.

And one reader had to be kept out of it. `delete_turn` meant "every attempt at
this coordinate"; the group spans branches now, so undoing a turn would have
deleted a take that another branch is telling. `attempts.on_branch` scopes it
back.

409 tests, unchanged from main and green -- a linear story has one take per
parent, so none of this is reachable until a second one exists. The migration
adds one column, backfills the linear case, and leaves NULL where it cannot
honestly place a row; `group` falls back to the coordinate there, which is the
rule those rows were written under. One `VACUUM FULL actions;` owed after
deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 21:34:12 +05:30
parththakkar106andClaude Opus 5 4af6e17406 Answer the review, and keep the opening node's bank
Nine findings from a review of the phase-14 stack. The one about a retry
withdrawing a memory is not a bug — a memory anchored to a node describes that
node, and it goes when the node goes. The root is the exception, and it is the
only one: migration 62 parked every memory written before memories had
coordinates on depth 0, so withdrawing the opening node would retire a whole
bank nobody attached there. A memory with no source range covers no story and
now stays; a summary that genuinely ends there is still withdrawn.

The rest are repairs.

* The adventure list quoted whichever attempt was written last rather than the
  one the story tells, so switching back left the index disagreeing with the
  page.
* A v1 import gave a typed memory no depth, rebuilding the NULL the migration
  exists to remove — invisible until the imported adventure forked.
* The action cap counted a v1 file's turns, and a turn expands into a row per
  saved attempt, so a file inside the cap could write a multiple of it.
* Forking a live node on a borrowed ancestor promoted a sibling on a branch the
  caller never named. It is a branch switch, and now says so.
* Switching attempts left the state, status and memory panels reading the
  previous take: the story does not change length, so nothing keyed on its
  length noticed. Same class as the branch-switch bug this phase already fixed.
* A retry after switching back numbered the new attempt into the middle of the
  group instead of the end.
* The cursor backfill numbered every action in the table once per adventure;
  correlated to the adventure being updated, it is an index lookup instead.
* Renaming a branch answered own_actions=0.

And one behaviour change recorded rather than repaired: script-visible history
and actionCount no longer count blank-text rows. That is the right shape and
there is no reading compatible with both, so plan/14 says so.

409 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 2d38a162d4 Give every memory a node, and show only the ones on your path
A hand-written memory used to carry a NULL depth, described in the model as
"belongs to the adventure rather than to a path". That sounds harmless and
is not: a NULL is a coordinate no fork can cap, so a note typed on one line
followed the reader onto branches whose story it never described. It takes
the head now — the story you were reading when you wrote it — and obeys
exactly the rule a summarised memory obeys.

The unanchored escape clause in lineage.Path.clause existed for that single
case and is deleted rather than left unused. Its docstring argued that a
capped depth would drop a typed memory the moment its branch stopped being
the newest entry; anchoring answers the same worry better, because the
memory is not exempt from the path, it is on one.

The drawer now shows the path being read and nothing else, filtered by the
clause retrieval itself uses, so the bank you can see is the bank the model
can see. Nothing is stranded: a memory lives on a branch, switching to that
branch shows it, and deleting the branch deletes it. Pinning decides order,
the path decides existence.

Migration 62 lands existing NULL-depth memories at depth 0 of their branch
rather than at the tip. 0 is at or before every fork point, so every memory
stays visible from exactly the paths it is visible from today — nobody's
bank loses a row on deploy. The tip is the tidier-sounding choice and would
have emptied them out of every branch forked earlier than they were typed.

This supersedes the on_path flag and the "another branch" badge from
earlier today; anchoring makes them redundant, and they are removed.

Four tests changed because they asserted the old contract, not because
they broke. The one worth reading is the pair replacing
test_a_hand_written_memory_is_not_lost_at_the_first_fork: typed on shared
trunk it still survives a fork, and typed on ground the fork never
travelled it no longer follows you.

402 tests. Verified on tools/branch_fixture.py: each branch's drawer holds
its own memory and not the other's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 1d1367ce3a Say which memories the model can actually see
Retrieval has been path-scoped since SP3: a memory on a branch this story
never travelled is never sent to the AI. The drawer listed every memory
alike, so on a fork you read "Fell down the cellar stairs; badly hurt" and
reasonably concluded the model knew it. It does not. That is worse than
either hiding the row or retrieving it — it is the screen claiming
something the engine contradicts.

Hiding them is not the answer either; that was the original comment's
point, and it stands. A memory nobody can list is a memory nobody can
delete, in a phase whose rule is that nothing is removed automatically.

So the whole bank still lists, and the rows off the current path are set
back, dashed, and labelled "another branch". MemoryOut.on_path carries it,
computed from the predicate retrieval itself uses rather than a second
spelling of the same idea — two spellings drift, and the failure mode here
is a badge that says the opposite of what the model gets. Pinning does not
override it: the path clause runs before pinning is considered.

The relationship is asymmetric and there is now a test that says so. A fork
borrows its ancestors, so a memory written on the parent is on the fork's
path too; the reverse never is. Worth pinning before somebody makes it
symmetric on the grounds that it looks wrong.

399 tests, three new, egress ceilings intact — the flag costs one id-only
query. Verified in a browser on tools/branch_fixture.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 811368d048 Refresh what a switch changes, not what a turn changes
A branch switch does not change the length of the story. It changes which
story it is. Four panels keyed on actions.length and so could not tell the
difference: the Branches panel drew one branch while the reader was already
on a second, Insights showed the prompt built for the path just left, the
script-state drawer kept the other line's numbers, and the Memory Bank did
not notice a deleted branch taking its memories with it.

Only the world-state drawer was right, and only because it happened to
carry stateKey already. They all key on the pair now, and deleting a branch
bumps it too — that is the one operation that changes what is stored
without a turn being played and without the story on the current path
moving by a single action.

tools/branch_fixture.py is the thing that could show it. The stress
fixture's world state is empty, so it cannot answer whether a switch puts
the scoreboard back, and its story is one branch. This builds a small
bootable adventure with a stat schema, a gold script, two takes on one turn
that differ by 35 hit points, a fork, and a memory on each side — with both
branches the same length on purpose, because equal length is precisely the
case a length-based key cannot see.

Verified in a browser with the drawer open: hp 60 to 95 and back, the bar
redrawn, the story swapped to the other take, Insights carrying the scratch
and not the beating. The Memory Bank deliberately does not change on a
switch: the drawer is adventure-wide so a memory is always findable to
delete, and retrieval is the path-scoped half.

396 tests, build clean, no new lint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 84827f0f37 Put the tree on the screen
The pager could only step between attempts, and stepping has nothing to say
about the thing the tree exists for: taking a path the story moved past and
keeping both. Every attempt has been its own node since SP4, so a chip is a
node now, and "take this path" forks — or simply switches, when the turn is
still the tip and its attempts are leaves nobody has built on.

Beside it, a Branches panel: every line the story has taken, with where each
left its parent and where it ends, and switch, rename and delete-with-confirm.
It sits with Plot/Memory/Scripts/Insights rather than inventing a new place to
put a rail. An unnamed branch is drawn from its fork depth, never from its
position in the list — a position shifts the moment a branch above it goes.

A spatial per-node map was considered and deliberately not built. At the size
this has to be verified against it is a second windowing problem, and it can be
added later without a new endpoint, since the rail and a map read the same
GET /branches. VariantOut grows an id because a fork is addressed by the node
being taken, not by an ordinal in a group that renumbers.

Driven by hand against the 602-action fixture, which found one bug that no test
could: the panel refreshed on actions.length, and a fork swaps a 60-action
window for another 60-action window, so it went on drawing a one-branch tree
while the story was already on the second. It keys off the counter adoptWindow
bumps now.

The scroll path was driven at the same time — three prepends of ~16,200 px, the
same node holding viewport top 792 to 787, never thrown to the end. That closes
the standing gap in this project. Console clean.

396 tests, build clean, no new lint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 cf3d52171e Let a branch be named, and thrown away
SP7 needs three branch operations and SP5 built one. Switching exists;
naming and deleting had no column and no route between them.

A name is stored because a player chose it. An unnamed branch keeps NULL
rather than a generated "branch 4" — a generated label is derived, and it
would go stale the moment a branch before it is deleted and the ordinals
shift underneath. The client draws those from the fork depth, which
nothing can shift. The v2 bundle carries the name for the same reason it
carries the fork points and leaves `lineage` out: it is a decision, not
something computed from one.

Delete is what stands between a tree and unbounded growth, since nothing
prunes one on its own. It refuses two branches: the root, which holds the
turns every other branch borrows, and the one being read — including any
branch the head was forked from, which is the same mistake in disguise and
the one that would cascade the head away and leave head_branch_id pointing
at nothing. The nodes, memories and descendants go through the foreign
keys that already cascade.

A cursor standing on a deleted branch is cleared. On Postgres a stale
branch id would simply never resolve; SQLite hands the freed id to the
next fork, and then the anchor resolves onto a branch it has never seen
and calls a stretch of story already summarized.

396 tests, 15 new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 a7bf47a35e Let a backup carry a story that went two ways
A bundle had one list and a forked adventure has two stories, so export was
emitting every branch's turns interleaved by index — a mangled story rather
than lost data, and unreachable only because forking has no UI yet.
`ai-dnd-adventure-v2` carries the branches, the depth each one left its
parent at, which attempt at every turn is the story, and what each node
left behind. That last one is not decoration: the after-snapshots are what
a branch switch puts back, and a bundle without them imports a tree nobody
can switch inside.

`app/bundle.py` owns both formats and nothing else knows either. The v1
reader stays — those files are already on people's disks — and it is now
the only place a `variants` array exists anywhere.

The rule the module is built on is that a bundle carries what was chosen
and never what is derived. The head branch, the fork points, the live flags
and the anchors are decisions somebody made. The lineage, the head depth,
the legacy `index` and the variant ordinals are computed from those and are
rebuilt on the way in, because a bundle is a text file anybody can edit and
a derived field shipped beside its source is a chance for the file to
disagree with itself where no read would report it.

`index` is the one that stops being academic here. It agreed with `depth`
until SP5, and this is the first writer that has to fill it for a forked
story, where two branches both hold a node at depth 4. It is allocated one
per turn instead: siblings share it, no two coordinates do.

Everything a hand-edited file can get wrong about the shape of a tree is a
400 raised before the adventure row exists, because a half-applied import
is exactly the failure this phase exists to end — a story that goes quiet.
A file wrong about which attempt is live is corrected rather than refused;
that is an invariant of the database, not of the format.

Measured on the 600-action fixture: 587 kB to 911 kB, and all of the
increase is the outcomes at 489 B a node — the coordinates themselves save
57.5 B a node against the old turn-and-variants shape. Twenty forks add
660 B. 4.3% of the import body cap.

381 tests green, 16 of them new in test_bundle_v2.py. No migration, no
vacuum owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 ffb2fd5b0e Let a story go two ways at once
Attempts pile up at the tip as leaves and cost nothing. The moment the
player takes the story down one the line has already moved past, the two
futures have to coexist — so `tree.fork` gives that attempt a branch of
its own, forked at the depth just before it, and the line it leaves
keeps every turn it has.

One row is inserted and one row is moved. Nothing is copied: everything
before the fork is borrowed through the lineage cached on the branch
row. Measured on a 40-turn story forked twenty times — 21 branches, 140
rows, an 80-action story — the page load costs 31,652 B against the
31,433 B the same story flat costs, and a branch is 103 B of ancestry.

Nothing derived moves either, and that is the part worth keeping: a
memory hangs off the coordinate the parent's attempt still occupies, and
the lineage caps the parent one depth short of it. The fork simply
cannot see it, so it resummarizes that ground from the text it actually
tells, without a line of bookkeeping.

Two things had to change underneath. A new node's depth now comes from
the tip of its branch rather than from the adventure-wide `index`, which
would have left a hole in a fork's path the width of the other branch.
And undo stops at the fork — the turns before it belong to the branch
this one grew out of.

`GET /branches`, `POST /branches/{id}/switch` and
`POST /actions/{id}/fork` are the endpoints SP7's tree view is drawn on.

365 tests green, 18 of them new in test_branch_forking.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 0a12d9cd47 Make a retry a node, not a rewrite
Every attempt at a turn is now its own row at the same (branch, depth),
with `live` naming the one the story tells. The JSON repeating group on
`actions.variants` is read one last time, by a migration that writes it
out as the sibling rows it always described, and then goes unread.

The snapshots turn around with it: an action carries the state it left
behind rather than the state it started from, because attempts at one
turn share a starting position and differ exactly in their outcome.
Rolling back is "what the node in front left behind", one lookup on the
path, and it is what undo and retry now both read.

And the memory holdback goes. It existed because retry rewrote a row
under a mark that had already moved past it; a retry writes a sibling
now, and replacing what a coordinate says withdraws what was derived
from it — the same repair undo and delete already made.

The assembled prompt is still stored once per turn: it moves with the
live flag, so a superseded attempt keeps only the few hundred bytes that
were its own. Measured on the 600-action fixture: 700 rows for the same
600-turn story, prompt archive byte-identical at 0.50 MB, index 1.8 kB
and page load 62.7 kB unmoved.

347 tests green. `tests/test_story_tree_baseline.py` and
`tests/test_retry_variants.py` pass unmodified — SP4 was allowed to move
the baseline for the variant-count semantics and did not need to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 c51531709d Mark the story with a node, not with a count
The memory bank and the story summary each kept a cursor: how many story
actions they had already covered. A count is a position in a list, and this
list moves — delete an action in front of the mark and every later one slides
down a slot, so the mark now covers one it has never read. All the cursor
bookkeeping existed to patch that up.

Both marks are now (branch_id, depth): the node up to and including which the
work is done. A depth is a coordinate along a path, not an offset into a list,
so nothing in front of it can move it. That deletes rather than rewrites
`position_of_index`, `note_action_removed`, `_rewind_cursors_to_index`,
`prune_dangling_memories` and the every-pass clamp in `run_post_turn`.

A memory hangs off the node its block ends on, so a fork inherits its
ancestors' memories without copying any, and retrieval selects through the
branch clause over the *whole* lineage — recall is long-range by definition and
cannot be windowed. Measured: 1,807 B on a story forked twenty times against
1,823 B on a flat one of the same length.

Migrations 53-56 translate the old counts into nodes. They rewrite `adventures`
and not `actions`, so this one needs no VACUUM FULL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 05a2a77e4c Resolve the head once a flush, not once a node
The identity map holds weak references. A branch row nobody keeps a strong
reference to is collected between two nodes, so resolving the head inside the
placement loop read it back from the database for every node in the flush: 201
SELECTs on `branches` to write 200 actions, and 36 s -> 45 s on the same 297
tests. Nothing about any result changed, which is why only a stopwatch found
it, and why there is now a test counting the reads.

Also: the two reads left un-pathed on purpose say so where they live —
`max_action_index` allocates the legacy `index` and must stay adventure-wide
or two branches issue the same number, and export is a flat v1 bundle whose
reader has no idea branches exist. And `Adventure.actions` keeps its `index`
ordering, because ordering the collection by depth would not make it a story:
it is every branch's actions, and a path is a selection out of it.

318 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 563b9af9cf Read one story, and know which one
Every read of an action now goes through a single module. `context/lineage.py`
turns a branch's stored lineage into the OR-of-ranges that is "this story", and
history, paging, the newest-action lookups, the index screen and the scripting
history API all select through it. A forgotten clause does not raise — it
quietly assembles a page, or a prompt, out of two different stories — so the
clause lives in one place rather than in a convention.

The read that mattered most was the shortcut: `_from_memory` sliced
`adventure.actions`, which is every branch's actions, not the path. It now cuts
the loaded collection down with the same predicate the SQL uses. Same trap one
layer up, and user-visible: `pipeline._history()` hands user scripts the story,
and was handing them the collection.

Tail reads window the lineage as well as the rows: the newest few entries cover
the context budget, so a story forked twenty times reads its tail with one
clause and costs 1.07x what an unforked story of the same length costs. The
estimate is depth arithmetic, and where a deleted action leaves a gap the read
notices it came up short and widens to the whole ancestry.

Ordering moves from `index` to `depth`, with `id` breaking ties. The two hold
the same numbers until retry stops mutating rows in SP4, but only one of them
is a position along a path.

One thing SP1 did not anticipate: wiring the writers was not enough. From here
a row without a branch is a row no read can see, and "every writer remembers"
has to hold for every fixture, script and test ever written — including the
SP0 baseline, which writes its actions straight to the database and must pass
unmodified. So the session enforces it: `tree.place_new_nodes` runs from
before_flush and places anything unplaced. The call sites keep their explicit
calls, because a node placed at the call site is placed before the code around
it reads it back.

316 tests green: the 297 from SP1, plus 19 in test_branch_clause.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 d3756abdaa Give every action a branch and a depth
Phase 14 SP1. The tree goes into the schema and nothing reads it yet: a
`branches` table, `branch_id`/`depth` on actions and memories, a head pointer
on adventures, migrations 46-52, and a server-side backfill that re-reads every
existing adventure as a tree with one branch. `depth` holds the number `index`
already held, gaps included, so no story changes — a linear story *is* a tree
with one branch, which is what makes the SP0 baseline passing unmodified the
pass condition rather than a hope.

The writer had to come with it. No migration will ever visit a row written
after it ran, so columns backfilled today and populated next subphase would
leave a hole exactly the width of one deploy, and from SP2 on a row without a
branch is a row no read can see. `app/tree.py` owns that: one module, because a
node written without a branch fails by disappearing rather than by raising.

Three things the schema itself insisted on:

- `adventures.head_branch_id` is a plain integer, not a foreign key. Pointing
  both ways makes the two tables a cycle create_all cannot order, and its
  escape hatch needs an ALTER SQLite does not have. It is a cache, and a head
  naming a branch that is gone recovers onto the root.
- `lineage` is NOT NULL, so the backfill inserts `'[]'` and fills it in a
  second pass guarded on `json_array_length(lineage) = 0` — not `= '[]'`,
  because Postgres `json` has no equality operator.
- SQLite will not drop a column a foreign key names, which is how two existing
  tests broke: they simulated an old database by rewinding the stamp while
  leaving the new columns in place. Every ADD COLUMN migration is now
  idempotent, and `tests/test_tree_migration.py` builds a genuine schema 45 by
  rebuilding three tables from frozen DDL so the real ALTERs run.

297 tests green, 14 of them new. `branches` costs 0.1 kB of a 733.5 kB turn;
page load and index are byte-identical to the recorded figures.

The deploy that ships this needs one `VACUUM FULL actions;` on the direct
endpoint afterwards — it rewrites every row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 5c1bcf7305 Build a fixture that can witness what a tree changes
The stress fixture is sized from production and exists to weigh bytes, so it
leaves every column it does not weigh at its default. Checked against a freshly
built one, that is exactly the set phase 14 has to migrate: state_before and
world_state_before NULL on all 600 rows, no scenario and so no RPG layer, no
adventure scripts, both cursors 0, and a hundred retry histories whose two
attempts carry byte-identical text with variant_index pinned at 0.

That last one matters most. "Which attempt is live?" is the question SP4's
migration answers when it decides which sibling node becomes the head of a
turn, and against that fixture the question had no observable answer -- a
migration that picked wrong would have looked correct.

--rich fills in those columns and no others. A real RPG scenario read from the
seed data rather than invented, with a world state played forward so hp
declines and flags flip; monotonic per-action state snapshots, so a rollback
that does not happen reads as a wrong number instead of as nothing; a gold
script, story cards, non-zero cursors, pinned and forgotten memories; retry
attempts with distinct texts, counts of two and three, and a live attempt that
is often not the last written. Plus a second adventure, because a branch clause
that forgot its adventure still looks right on a database holding one.

The invariant SP4 reads -- text mirrors variants[variant_index] -- is asserted
at build time rather than assumed.

The plain fixture is untouched and re-measured unchanged at 1.8 kB, so the
egress ceilings stay comparable. All six shapes run clean on --rich, including
the turn path with the RPG layer and script pipeline now live. 283 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30
parththakkar106andClaude Opus 5 1940e222a7 Pin down what a tree must not change
Phase 14 replaces the action list with a tree, which touches the memory bank,
the context builder, undo, retry and the UI at once. Before any of that moves,
write down what "unaffected" means and prove it holds today.

tests/test_story_tree_baseline.py drives the product over HTTP the way a player
does and asserts only on API responses, never on how anything is stored: the
turn engine, retry and variant switching, undo, paging all the way back to the
start without gaps or repeats, edit/delete, memories, export/import round-trip,
world state. 283 green with it added, from 259.

It must pass unmodified through the schema change, the branch clause and the
move of memories onto nodes. A linear story is a tree with one branch, so those
subphases have no licence to change behaviour, and this is what says so.

The plan itself records four decisions that were open: structural-first with no
runtime flag, legacy columns kept one release, full tree visualisation, and a
v2 bundle with a v1 reader. Plus four traps found by reading rather than
running -- history._from_memory and the scripting pipeline both slice
adventure.actions, which under a tree is every branch rather than the path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30