M4: add durable named Save Points

A Save Point is a name for a story position, and restoring one is head
movement. That is the whole architecture, and it is what ADR 012 and
BUILD-MILESTONES' note on M4 asked for: M3 made the head a stored
(branch, depth) and made arriving at one a row lookup plus a state restore,
so a Save Point needs no restore machinery of its own.

What the user gets:

- Name the moment they are reading, keep playing, restart the app, and come
  back to it. Restoring moves the story back and deletes nothing: the later
  turns stay, Redo still walks forward into them, and writing something
  different is what starts a new line while the old one is kept.
- Rename, delete, and a list, in a Save Points panel beside the branch panel,
  with a Save Point button next to Undo and Redo. Both confirmations say what
  is *not* destroyed, because that is the part the screen cannot show.
- Save Points survive export and import.

What was deliberately not built:

- No second restore path. `head.move_to_node` is the only new movement: its
  depth half is M3's `head.move_to` unchanged, and its branch half is the
  single assignment `switch_branch` already makes. No head field is written
  in the checkpoint router, nothing reconstructs state, nothing prunes a
  memory, nothing copies or deletes a turn, and restore never forks — the
  first write below the restored head does, through `fork_if_behind_head`.
- No automatic cleanup. A Save Point behind the head, or naming a line the
  story left, is doing its job (STORY-BRANCH-SEMANTICS §19). The one removal
  is a cascade: deleting a branch takes its Save Points, as it takes its
  memories, because the story they named went with it.
- No new ADR. ADR 012 already decides the architecture, and a table is not a
  decision.

The one call the planning package did not already make: restore moves the
branch half of the head only when the coordinate is off the path being read.
Doing it unconditionally would quietly hand back an abandoned continuation
whenever a Save Point in a shared prefix was restored; never doing it would
make a Save Point on a departed line unrestorable, which contradicts §19.
TECHNICAL-DESIGN §8.8 records it.

Schema: a `checkpoints` table holding a name, an optional note and a
(branch, depth) coordinate — no copy of any story. `create_all` builds it as
it did `memories` and `branches`; migration 80 adds the index. No backfill,
because nobody had named a position before M4.

The coordinate is deliberately not an action id: one coordinate holds every
attempt at a turn and exactly one is live, so a coordinate follows a retry
where a row id would pin a take the story no longer tells.

Tests: 680 pass (638 before). 42 new in tests/test_save_points.py covering
D11-D14, I04, L03, E-series lineage and memory isolation after restore and
divergence, the edge cases, and an M3-database migration. One pre-existing
fixture in test_tree_migration.py needed `checkpoints` added to its drop
list — SQLite refuses to drop a table another table references.

Not verified: the browser. No session has had a usable one, so the Save
Point panel's DOM behaviour is unobserved — as M3's Redo control still is.
The twenty-step sequence was driven over HTTP against a live server with a
real process restart instead, and all seventeen checks pass. M4 is
implemented, not accepted: no review has been written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
This commit is contained in:
JesseMarkowitz
2026-09-03 18:48:54 -04:00
co-authored by Claude Opus 5
parent 3c8e91f644
commit e08d49c3eb
20 changed files with 2272 additions and 26 deletions
+96
View File
@@ -255,6 +255,102 @@
flex-basis: 100%;
}
/* Save Points (M4). Deliberately the same furniture as the branch panel: a Save
Point is a name for a position, and it should not look like a different
species of thing from the lines it names positions on. The confirmations
borrow the branch panel's shape but not its danger colouring — restoring
destroys nothing, and only deletion is red. */
.save-point-panel { display: flex; flex-direction: column; gap: 14px; }
.save-point-new { display: flex; flex-direction: column; gap: 7px; }
.save-point-new label {
font-size: 0.64rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-dim);
}
.save-point-new input {
padding: 5px 9px;
font-family: var(--font-story);
font-size: 0.95rem;
}
.save-point-new input.save-point-note {
font-size: 0.82rem;
}
.save-point-hint, .save-point-intro {
margin: 0;
color: var(--text-dim);
font-size: 0.78rem;
line-height: 1.55;
}
.save-point-list { display: flex; flex-direction: column; gap: 8px; }
.save-point-row {
display: flex;
flex-direction: column;
gap: 5px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-input);
}
/* A Save Point naming a moment on a telling the story has left. Dimmed rather
than hidden: it still restores, and hiding it would be the automatic cleanup
this milestone deliberately does not do. */
.save-point-row.elsewhere { opacity: 0.72; }
.save-point-head { display: flex; align-items: center; gap: 7px; }
.save-point-name {
font-family: var(--font-story);
font-size: 0.98rem;
color: var(--text);
}
.save-point-rename {
flex: 1;
min-width: 0;
padding: 3px 7px;
font-family: var(--font-story);
font-size: 0.95rem;
}
.save-point-meta {
font-size: 0.72rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.save-point-note-text {
font-size: 0.78rem;
color: var(--text-dim);
line-height: 1.5;
}
.save-point-tools, .save-point-confirm {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
}
.save-point-tools button, .save-point-confirm button {
padding: 3px 9px;
font-size: 0.72rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
}
.save-point-tools button:hover:not(:disabled),
.save-point-confirm button:hover:not(:disabled) {
color: var(--accent-bright);
border-color: var(--border-bright);
}
.save-point-tools button.danger:hover:not(:disabled),
.save-point-confirm button.danger:hover:not(:disabled) {
color: var(--danger);
border-color: var(--danger);
}
.save-point-tools button:disabled,
.save-point-confirm button:disabled { opacity: 0.4; cursor: default; }
.save-point-confirm span {
font-size: 0.74rem;
color: var(--text-dim);
line-height: 1.5;
flex-basis: 100%;
}
.action-edit { margin-bottom: 14px; }
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
several paragraphs, and the old fixed 110px turned that into a keyhole.