You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Every path you read or write must be under your worktree. A path outside it is
auto-rejected by the harness and that rejection ends your session instantly — no
commit, no PR, work lost. This already killed one run in this batch (#138).
In particular: do not use /tmp. Write scratch output under your cwd (e.g. ./.scratch/) or use pytest's tmp_path fixture, which resolves inside the sandbox.
Do not look for bop-scripty, CassetteLore, or global-memory — not on this machine.
Context
The read-view now knows how far through the script it is (commit 3656c15 added percent() and the HUD's completeness readout). That makes "mark this script read
in the library when I actually finish it" cheap, where before it needed a scroll
heuristic invented from scratch.
Both design docs deliberately parked this — see "Out of scope" in docs/superpowers/specs/2026-07-31-library-index-design.md and docs/superpowers/specs/2026-07-31-readview-session-affordances-design.md. The
reason was that inferring completion from scroll position has its own failure
modes. Those failure modes are now known and named below, which is what makes this
issue closeable rather than open-ended.
The swipe/tap path in the library stays exactly as it is. This adds an automatic
path alongside it, not a replacement.
Acceptance criteria
When a read-view reaches 100%, it marks itself read in the library's
storage: localStorage["coldread-library:read"] is an object, and the
script's key is set to true (merging into whatever is already there — never replacing the object).
The key used is the read-view's own served filename, obtained from decodeURIComponent(location.pathname.split("/").pop()). See the danger
zone below — this is the one detail that silently produces a no-op if done
the other plausible way.
A script shorter than the viewport does not mark itself read on
open. percent() returns 100 when maxScroll() <= 0; a naive === 100
check therefore marks such a script read the instant it loads.
Reaching 100% by any route works: autoscrolling to the end, the End key,
a drag, and a momentum fling.
Marking is idempotent and does not fight the user: once the read-view
has auto-marked in a given page load it must not re-mark, so a user who
returns to the library, unmarks the row by hand, and comes back to a page
still sitting at 100% does not get it silently re-marked.
The library page reflects it on next load without any change to library.py's prune logic — the existing prune keeps entries whose filename
is in the current index, and a correct key satisfies that untouched.
All storage access goes through the read-view's existing store-style try/catch contract. Losing the write is acceptable; throwing is not.
A test covers the short-script case (the maxScroll() <= 0 → 100% trap) at
whatever level is testable, or — if it genuinely is not unit-testable —
the PR states how it was verified in a browser instead. Do not silently skip
it.
Files / where this lives
vo_format/readview/reader.js — percent() and paintHud() are the existing
completeness code; seek() is the single funnel every scroll route passes
through. The store object at the top shows the required try/catch shape,
but note it prefixes keys with coldread:<title> — this write must not use
that prefix (see danger zones).
vo_format/readview/library.py — read for context only. _JS's PREFIX is "coldread-library" and the read map is stored under the read key, so the
full key is coldread-library:read. Rows carry data-key="<filename>".
tests/test_readview_render.py — where read-view render assertions live.
docs/superpowers/specs/2026-07-31-readview-session-affordances-design.md —
update its "Out of scope" section, since this issue reverses that decision.
How to verify
./.venv/bin/python -m pytest tests/ -q → 233 passing before this issue; must
rise.
End to end in a browser, which is the only check that proves the cross-page
coupling:
./.venv/bin/python vo_format/readview/library.py ./.scratch to build the index
over the same directory
Open the read-view, press End, then open index.html and confirm the row
shows as read.
In devtools, confirm localStorage["coldread-library:read"] contains the
read-view's exact filename as a key, matching the row's data-key byte for
byte.
Confirm the short-script case: a read-view whose content is shorter than the
window must not appear read after merely being opened.
Constraints
Preserve: the library's swipe-to-mark and tap-the-✓ paths, unchanged.
Preserve: library.py's prune-on-load behaviour. Do not weaken it to accommodate
a mismatched key — fix the key instead.
Do not touch: percent()'s existing return contract, including its maxScroll() <= 0 → 100 branch. The HUD depends on it; guard at the call site.
Do not: introduce a new storage key or a new prefix. Write the key the library
already reads.
Match: the existing try/catch-wrapped storage style in both files.
No new dependencies; the read-view must stay self-contained and offline.
⚠ Danger zones (review focus)
⚠ The key.data-title on the read-view happens to equal the filename minus - readview.html, because the push script stages PDFs as Channel — Title - variant.pdf. Deriving the key from data-title therefore appears to work on the Pi and breaks anywhere the HTML was renamed — and its
failure is silent in the worst way: the write succeeds, the library's prune drops
the unknown filename on next load, and nothing ever shows as read. Verify the
implementation reads location.pathname, and verify the resulting key matches a
row's data-key exactly, including the em-dash and spaces after decodeURIComponent.
⚠ The short-script trap.percent() returns 100 for any script that fits the
viewport. Confirm by test or by browser that opening such a script does not mark
it read. A cheap executor will write if (percent() === 100) in paintHud() and
it will look correct.
⚠ Clobbering the map.store.set("read", {key: true}) instead of a merge
wipes every other script's read state. Verify with two marked scripts that
marking a third preserves both.
⚠ Write frequency.seek() runs every animation frame. A write placed there
without a guard hits localStorage at 60Hz, which is a synchronous main-thread
write on the device this is for. Verify the write happens once per completion,
not per frame.
Out of scope
Un-marking automatically when the user scrolls back up. Completion is a
one-way signal here; reversing it is the manual path that already exists.
Any change to how the library renders or orders rows.
Syncing read state between devices.
Notes
Executor tier: cheap (DeepSeek), but this is a cross-page storage coupling
with four named silent-failure modes — consider exec:pro if the wave has
capacity, and review the diff against all four danger zones rather than the
acceptance list alone.
Reverses an explicit "out of scope" in two design docs; the PR must update the
newer one rather than leaving it contradicting the code.
Context
The read-view now knows how far through the script it is (commit
3656c15addedpercent()and the HUD's completeness readout). That makes "mark this script readin the library when I actually finish it" cheap, where before it needed a scroll
heuristic invented from scratch.
Both design docs deliberately parked this — see "Out of scope" in
docs/superpowers/specs/2026-07-31-library-index-design.mdanddocs/superpowers/specs/2026-07-31-readview-session-affordances-design.md. Thereason was that inferring completion from scroll position has its own failure
modes. Those failure modes are now known and named below, which is what makes this
issue closeable rather than open-ended.
The swipe/tap path in the library stays exactly as it is. This adds an automatic
path alongside it, not a replacement.
Acceptance criteria
storage:
localStorage["coldread-library:read"]is an object, and thescript's key is set to
true(merging into whatever is already there —never replacing the object).
decodeURIComponent(location.pathname.split("/").pop()). See the dangerzone below — this is the one detail that silently produces a no-op if done
the other plausible way.
open.
percent()returns 100 whenmaxScroll() <= 0; a naive=== 100check therefore marks such a script read the instant it loads.
Endkey,a drag, and a momentum fling.
has auto-marked in a given page load it must not re-mark, so a user who
returns to the library, unmarks the row by hand, and comes back to a page
still sitting at 100% does not get it silently re-marked.
library.py's prune logic — the existing prune keeps entries whose filenameis in the current index, and a correct key satisfies that untouched.
store-styletry/catchcontract. Losing the write is acceptable; throwing is not.maxScroll() <= 0→ 100% trap) atwhatever level is testable, or — if it genuinely is not unit-testable —
the PR states how it was verified in a browser instead. Do not silently skip
it.
Files / where this lives
vo_format/readview/reader.js—percent()andpaintHud()are the existingcompleteness code;
seek()is the single funnel every scroll route passesthrough. The
storeobject at the top shows the requiredtry/catchshape,but note it prefixes keys with
coldread:<title>— this write must not usethat prefix (see danger zones).
vo_format/readview/library.py— read for context only._JS'sPREFIXis"coldread-library"and the read map is stored under thereadkey, so thefull key is
coldread-library:read. Rows carrydata-key="<filename>".tests/test_readview_render.py— where read-view render assertions live.docs/superpowers/specs/2026-07-31-readview-session-affordances-design.md—update its "Out of scope" section, since this issue reverses that decision.
How to verify
./.venv/bin/python -m pytest tests/ -q→ 233 passing before this issue; mustrise.
coupling:
./.venv/bin/python -m vo_format.cli vo_format/samples/single_narrator_sample.md --no-preflight --archetype single_narrator --non-interactive -o ./.scratch/t.pdf./.venv/bin/python -m vo_format.readview.cli ./.scratch/t.pdf --library index.html./.venv/bin/python vo_format/readview/library.py ./.scratchto build the indexover the same directory
End, then openindex.htmland confirm the rowshows as read.
localStorage["coldread-library:read"]contains theread-view's exact filename as a key, matching the row's
data-keybyte forbyte.
window must not appear read after merely being opened.
Constraints
library.py's prune-on-load behaviour. Do not weaken it to accommodatea mismatched key — fix the key instead.
percent()'s existing return contract, including itsmaxScroll() <= 0 → 100branch. The HUD depends on it; guard at the call site.already reads.
try/catch-wrapped storage style in both files.⚠ Danger zones (review focus)
data-titleon the read-view happens to equal the filename minus- readview.html, because the push script stages PDFs asChannel — Title - variant.pdf. Deriving the key fromdata-titlethereforeappears to work on the Pi and breaks anywhere the HTML was renamed — and its
failure is silent in the worst way: the write succeeds, the library's prune drops
the unknown filename on next load, and nothing ever shows as read. Verify the
implementation reads
location.pathname, and verify the resulting key matches arow's
data-keyexactly, including the em-dash and spaces afterdecodeURIComponent.percent()returns 100 for any script that fits theviewport. Confirm by test or by browser that opening such a script does not mark
it read. A cheap executor will write
if (percent() === 100)inpaintHud()andit will look correct.
store.set("read", {key: true})instead of a mergewipes every other script's read state. Verify with two marked scripts that
marking a third preserves both.
seek()runs every animation frame. A write placed therewithout a guard hits
localStorageat 60Hz, which is a synchronous main-threadwrite on the device this is for. Verify the write happens once per completion,
not per frame.
Out of scope
one-way signal here; reversing it is the manual path that already exists.
Notes
with four named silent-failure modes — consider
exec:proif the wave hascapacity, and review the diff against all four danger zones rather than the
acceptance list alone.
newer one rather than leaving it contradicting the code.