LICENSE
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
NOTICE
first-reader
Copyright Shubham Saboo and contributors
https://github.com/Shubhamsaboo/awesome-llm-apps/tree/main/agent_skills/first-reader
(upstream commit f12ecedf337e4732bd5ec628a1ee5b666f0d1262), licensed under the Apache License 2.0 (see LICENSE).
Changes in this copy (explainer project, 2026-09):
- scripts/feed.py: CJK-aware word counting (nwords) and 。!? sentence ends,
so Japanese drafts are chunked and paced like English ones.
- Tests live in tests/first-reader/ of the explainer repository
(test_first_reader.py adjusted for the new path; test_cjk.py added).
README.md
# 👁️ First Reader
Readers before you publish.
Most AI slop skills look at the text itself: banned words, sentence length, em dashes. They cannot tell you whether a real person would keep reading. This skill simulates two readers going through your draft one passage at a time and reports what happened to them: where they got interested, where they lost interest, where they stopped, and what they still remembered the next day. A third reader only skims the piece and says whether they would have opened it at all. When the run is done you can ask any of the readers follow-up questions. The skill never rewrites your text.
https://github.com/user-attachments/assets/cf222598-39f1-4f37-9f64-505f87925fa7
## Install
```bash
npx skills add https://github.com/Shubhamsaboo/awesome-llm-apps/tree/main/agent_skills/first-reader
```
The [skills CLI](https://skills.sh) installs the folder for compatible
coding agents. You can also copy this directory into an agent's skills
directory. Everything inside is Markdown and stdlib Python. It runs
offline, has no dependencies, and sends nothing off your machine.
## Use
Point your agent at a draft file, or paste the draft, and say who it is for:
> review this, it's a launch post for backend engineers on our blog
A few minutes later the agent reports back in plain language: whether the
skimmer opened it, where each reader got interested or stopped, what they
remembered, and one question for you. It also builds a page that shows
your draft with the readers' comments next to each passage, the skimmer's
verdict, and a strip that shows how attention changed from passage to
passage.
After the run you can keep asking:
- **"ask S what would have kept her past passage 4"**: S answers from her
own notes, in her own words. She will tell you what was missing, but she
will not write it for you.
- **"again"**, after you have revised the draft in your editor: the same
readers read it fresh, and the page shows the previous run's attention
strip above the new one so you can see what changed.
- **"quick read"**: only the skimmer runs. You get two sentences on whether
they would open it and why.
The readers are cast from the audience you name in the first sentence.
Describing them as people works better than job titles, for example "a
staff engineer who has built three eval harnesses and wants a reason to
stop". You can save your readers in `.first-reader/audience.json` next to
your drafts so every run in that folder uses the same people.
## Why simulate readers
AI slop skills measure the text. They cannot measure what a reader
experiences, and research on detecting hollow writing shows that the
people who spot it reliably are judging what a piece commits to and what
it leaves in memory, not counting words. So this skill measures the
experience instead.
Three rules keep the simulation honest. The draft is served one passage at
a time, and the feed will not advance faster than a person could read, so
a reader who quits at passage four has never seen passage five. The memory
test is answered from the reader's own notes, never from a second look at
the text. And the readers only report what happened to them and what would
have had to be different for them to keep going; every decision about the
draft stays with you.
## Verify
```bash
python3 agent_skills/evals/first-reader/test_first_reader.py
```
The test builds temporary drafts and checks each part of the skill: the
skim view, the served feed (no text written to disk, reading speed
enforced, one token per reader), the memory quiz, the trust signals, the
reader consult, and the page. The behavior spec is in
[`agent_skills/evals/first-reader/`](../evals/first-reader/). Run each
prompt in `evals.json` in a fresh session with the skill installed and
check the listed expectations. `trigger-cases.json` lists when the skill
should and should not fire. The ledger in the same folder records how the
skill was tested, including five consecutive reads of one real essay.
## Files
```text
first-reader/
|-- SKILL.md
|-- README.md
|-- references/
| |-- personas.md
| |-- report.md
| |-- room.md
| `-- interview.md
`-- scripts/
|-- skim.py
|-- feed.py
|-- recall.py
|-- signals.py
|-- ask.py
|-- room.py
`-- room_template.html
```
Part of [awesome-llm-apps](https://github.com/Shubhamsaboo/awesome-llm-apps).
Apache-2.0. Last verified: September 2026.
references/interview.md
# The author interview
When the readers find nothing at stake (nothing survived recall, near-total
portability, no admissions, no events), line edits are the wrong tool: the
draft is missing material, and the only person who has it is the author.
The interview collects that material. It is the one moment this skill
generates words, and the discipline below exists so those words stay the
author's.
## When to offer it
Offer once, in the verdict, in one sentence: "The readers found nothing
only you could have written. Want me to interview you for ten minutes and
then re-run?" If the author declines, deliver the findings as usual and
stop. Never start interviewing uninvited, and never frame the offer as
the draft being bad; frame it as the draft being empty of them.
## How to ask
One question per message, then wait. Never a questionnaire. Follow the
energy: when an answer has a specific in it (a number, a name, a failure,
a fight), the next question digs there, not at the next item on your list.
Ask for events, never for opinions. The difference:
- Not "what do you think about eval metrics?" but "tell me about a time a
metric lied to you. What did it cost?"
- Not "why is this important?" but "what happened that made you sit down
and write this?"
- Not "what would you tell beginners?" but "what did you do wrong the
first time?"
The six questions that most reliably surface stakes, adapted to the piece:
1. What actually happened? (the incident behind the thesis)
2. What did it cost, in hours, money, or embarrassment?
3. Who disagrees with you about this, and what is their best argument?
4. What did you believe before that you don't believe now?
5. What example from your own work would you show if the reader were
sitting next to you?
6. What are you still unsure about?
Question 3 and 6 matter most: disagreement and uncertainty are the two
signals a hollow draft never contains, because they cannot be generated,
only owned.
Stop when you have two or three concrete events with details, or when two
questions in a row come back thin. A thin answer gets ONE follow-up
("when that happened, what did you actually do?"); if it stays thin, move
on. Do not squeeze; absent material is a finding too, and it is fine to
end with "the piece may want to wait until this has happened to you."
## How to write from the answers
- Capture the author's phrasing verbatim during the interview; their
wording of an event is almost always better than your paraphrase of it.
- Weave their answers into the draft where the hollow passages were, in
their words wherever the words work. Connective tissue may be yours;
events, numbers, names, positions, and doubts must be theirs.
- Never upgrade an answer. "It took a while" does not become "it took
three weeks". "Some people disagreed" does not become a named skeptic.
If a detail matters and they were vague, ask again instead of writing.
- Show the rewritten passages as tracked changes (see room.md), tell the
author these are their answers arranged, and ask them to read once for
anything that is not true in their voice.
- Then re-run with fresh readers. The interview succeeded if the recall
test now returns their material, not your arrangement of it.
references/personas.md
# Casting the reader
A review needs a reader, and "the audience" is not one. Cast two personas
per review: one sympathetic, one skeptical. Both are honest; they differ in
what they want and what stopping costs them (nothing, ever).
Every cast states four things before the read begins:
1. **Priors**: what they already know and believe about this topic, and what
they have already read this week that this piece competes with.
2. **Situation**: where they meet the text (feed on a phone, inbox, a link a
colleague sent, a docs search result) and what they were doing before.
3. **Patience budget**: how much they give the opening before bailing, from
the empirical table below.
4. **Stake**: why they might care, in one sentence. If you cannot write this
sentence, that is the first finding of the review.
## Patience budgets (empirical, per encounter context)
| Context | Budget for the opening | Source basis |
|---|---|---|
| Social feed scroll | 10 to 15 seconds, roughly the first two lines | Chartbeat: ~55% of pageviews under 15s engaged time |
| Blog/newsletter via link | first screen, ~2 paragraphs | NN/g: at most ~20 to 28% of words read; commitment earned by the opening |
| Inbox (memo, cover letter) | under 30 seconds | recruiter/hiring-manager survey data; Ladders eye-tracking 7.4s initial resume scan |
| Slush pile / gatekeeper | first paragraph, one strike allowed | slush-reader practice: most rejections happen on page one |
| Investor / exec skim | ~20 seconds per section, fixates on team/numbers/ask | DocSend: 3m44s average per deck |
| Docs / reference lookup | as long as scanning WORKS; zero tolerance for prose blocking the answer | information foraging: reader follows scent to the answer |
| Committed subscriber / fan | full read, but skimming the middle is still on the table | median scroll depth ~50% even for engaged visits |
## The two default casts
**The sympathetic reader** wants the piece to be good. They followed a
recommendation, they like the topic, they read generously and finish unless
actively pushed out. Their transcript shows what the piece is at its best
(Elbow's believing game). If even this reader skims a section, that section
is dead.
**The skeptical gatekeeper** is the genre's professional stopper: slush
reader, staff engineer skimming a design doc, editor, investor, tired
customer. They are looking for a reason to stop, one is enough, and an early
credibility hit taxes everything after it. Their quit point is the review's
sharpest number (the doubting game).
Adjust both to the piece's real audience. A skill README's skeptic is a
developer deciding in 30 seconds whether to install; a personal essay's
skeptic is a stranger who has read forty essays this week that open the
same way.
## Casting rules
- Personas must disagree with each other somewhere or the cast failed;
rerun the skeptic with a tighter budget and a sharper stake.
- Never cast the author, and never cast "someone who loves this style".
- The persona's knowledge sets what needs explaining: an expert reader
experiences a definition of basics as being talked down to; a newcomer
experiences undefined jargon as a locked door. Both are needle events.
- If the user cannot say who the reader is, cast the skeptic from the venue
(where will this be published?) and say in the report that the cast is a
guess.
references/report.md
# The reading report
The deliverable is a reading, not an audit. Order and register are fixed;
they come from Liz Lerman's Critical Response Process and Peter Elbow's
reader-based feedback, and they exist so the writer ends the report eager
to revise rather than braced against a list of violations.
## Register: testimony, never verdict
Every finding is phrased as what happened in a reader, anchored to quoted
text, with the effect it had. The reader cannot be wrong about what they
experienced; the writer stays free to decide it doesn't matter.
- Yes: "At 'the future of work is changing', my skeptic thought 'forty
other posts this week' and left. That sentence cost you the read."
- No: "Weak opening." / "This violates the hook rule." / "Score: 6/10."
No scores, no grades, no percentages of quality. The only numbers allowed
are observations: the quit chunk, what the recall quiz returned, what
signals.py counted.
Diagnose the place and the effect; hold fixes loosely. Workshop constant:
readers are right about where, wrong about how. Prescriptions appear only
in the final section, labeled as opinions, and the writer may decline them.
## Order
**1. Sayback and meaning.** One sentence: what this piece is, as received.
Then what landed: the moments that had meaning, quoted. This comes first
because it tells the writer what not to break, and because a review that
opens with defects gets read defensively. This section is honest, not
polite; if little landed, it is short.
**2. The reading.** The transcript evidence, one short block per persona:
where expectation was set, where it was met or broken, where the needle
moved and why, where skimming started, the exact sentence of the quit or
the fact of the finish. Quote the draft only where the reader's log did.
**3. What survived.** The recall results: the one-sentence retelling vs
what the piece meant to say, the phrases that stuck, the peak, whether the
ending survived at all, the center of gravity ("it says it's about X; it
came alive at Y"), and the one-action answer.
**4. The person behind it.** Implied author in one or two sentences ("the
person these sentences imply is..."), any seam where that person changes,
and the trust ledger: costly signals paid vs free signals, with the
signals.py numbers as evidence, read through the genre.
**5. Questions, then opinions by permission.** Two or three genuinely
neutral questions (no embedded advice: "what did you want the reader
feeling at the pivot?" not "why is the pivot so abrupt?"). Then: "I have
opinions about the opening, the middle section, and the ending. Want
them?" If the user says yes, give them as opinions, still anchored to the
transcript.
## Altitude rule
If the skim gate fails or both personas quit in the first third, stop
there. Report sections 1, 2 and 4 only, say plainly that the piece has a
structural problem that makes line-level notes premature, and name the
developmental question the draft has to answer first. Line notes on a
piece whose structure is dead are wasted work and false comfort.
The gate and the reads can disagree: a skim gate that fails while both
timed readers finish and the recall test passes means the piece loses
readers at acquisition but rewards the ones who commit. That structure is
wounded, not dead. Report the full evidence, lead with the gate failure
as the primary structural finding, and say which audience each result
speaks for: the scanner is most of the piece's reach, the finishers are
the already-persuaded.
## The report is allowed to be happy
A reader sometimes just gets pulled through. If both transcripts run
positive, the recall quiz returns the intended gist, and the trust ledger
is paid up, the report says so and stops. "I read it twice and have only
pointing to offer" is a complete, valid review. Manufacturing findings to
seem thorough is this skill's defined failure mode, and the eval's control
case exists to catch it.
## What this skill never does
It never rewrites. Where is the reviewer's job; how is the writer's.
Handing back rewritten sentences would collapse the one thing this skill
adds over every humanizer: the writer putting the fix in their own
language. If the user asks for the rewrite, point them at their own voice
or editing skill and offer the transcript as its input.
references/room.md
# The first-reader page
The review's primary deliverable is a page, not a wall of text: the draft
with the reading still on it. `scripts/room.py` builds it from the run's
raw sessions; you feed it curation through one JSON file so the page
carries a plain-language verdict, the readers' best lines, and the lens
data from the recall and signals steps.
## Build
```
python3 scripts/room.py <run-dir> [--out room.html] [--annotations room-annotations.json]
```
`<run-dir>` is the directory whose subdirectories are the feed.py
sessions. With no annotations the page still works: excerpts are trimmed
mechanically from the logs, and the memory and trust lenses stay hidden.
Always write the annotations file; the mechanical fallback is for
resilience, not for shipping.
Publish the page as an Artifact where you can, or write it beside the
run and open it; give the user the link or path as the review's ending.
Keep the same file path on rebuilds so a published URL survives.
## room-annotations.json
Write it after the recall test and your own read, before building:
- `title`: the page title. Always "First read: <the draft's own title>",
so the tab says exactly what the page is; room.py defaults to that
from the first heading if you omit it.
- `skim`: the skim gate's decision, `{"commits": false, "said": "<the
skimmer's own words, two sentences>"}`. It renders at the top, because
for most readers that decision is the whole encounter.
- `verdict_lead`, `verdict_rest`: the verdict in plain spoken language.
Lead with the good news if there is any. No jargon, no codenames, no
scores. Write it the way you'd tell a friend across a table.
- `readers`: keyed by session directory name:
`{"initial": "P", "label": "platform eng", "title": "a platform engineer who needs this for a Thursday review"}`.
The title is a sentence fragment a stranger understands.
- `excerpts`: keyed by session dir, then passage number as a string. One
or two of the reader's own sentences per passage, trimmed by you. Quote
the reader; never paraphrase into reviewer-speak.
- `flags`: passage number to a plain phrase a person would say: "best
moment", "felt misled here", "attention dropped". Never invented
codenames.
- `memory_phrases`: the exact strings the recall quiz preserved. These
light up under the "what stuck" lens; everything else ghosts.
- `portable_sentences` and `entities`: from the signals step; they power
the trust lens.
- `opinions`: the sealed list for opinions-by-permission, as short plain
sentences. Empty list or omitted means no envelope renders.
- `previous`: on a re-run, `{"summary": "...", "run": "<path to the
prior run dir>"}`. The summary is one sentence comparing reader
behavior ("Last time the skeptic quit at passage 2; this time they
finished") and renders at the top; the run path makes the page draw
last run's needle strip above this run's, so the author sees the
orange stretch turn blue (or not).
## Consulting the readers
After the run the readers are still there: each session directory holds
a persona and the log that is that reader's memory, and `skim.txt`
holds the skimmer's decision. When the author asks a reader something:
```
python3 scripts/ask.py <run-dir> skeptic "what would have convinced you at passage 8?"
python3 scripts/ask.py <run-dir> skim "what would have made you open it?"
python3 scripts/ask.py <run-dir> all "did you notice the retention example?"
```
Hand each printed bundle to a fresh subagent and relay the answer in the
reader's voice. The bundle never contains the draft; a reader who
cannot answer from their notes says so. Readers never propose rewrites.
## The saved audience
`.first-reader/audience.json` beside the draft keeps the project's readers
so they stay the same people across drafts and runs:
```json
{
"venue": "company engineering blog",
"readers": {
"keen": {"initial": "E", "label": "adk engineer",
"title": "an AI engineer whose quality check is reading transcripts by hand"},
"skeptic": {"initial": "S", "label": "eval skeptic",
"title": "a staff engineer who has built eval harnesses and wants a reason to stop"}
}
}
```
Reuse it for casting and for the `readers` block of the annotations;
change it only when the author names a different audience.
## The run manifest
Before the timed reads, write `manifest.json` in the run directory:
```json
{
"draft": "<path>",
"intended_gist": "<what the reader should think, feel, or do>",
"casts": {"<session-dir>": "<one-line persona>"},
"previous_run": "<path to the prior run dir, or null>"
}
```
On a re-run after revision: reuse the casts from the prior manifest so
the comparison is honest, point `previous_run` at it, and lead both the
chat verdict and the room's `previous` line with what changed in reader
behavior. Fresh readers every time; only the casts repeat.
scripts/ask.py
#!/usr/bin/env python3
"""Consult a reader after the run: they answer in character, from their own log.
A real beta reader can be asked follow-ups: "what would have convinced
you?", "where exactly did you check out?", "did you notice the retention
example?" This script packages one reader's persona and moment-by-moment
log with the author's question so a fresh agent can answer AS that reader,
grounded in what actually happened to them during the read, and nothing
else. The draft is never included: like a person the next day, the reader
has their memory of the experience, not the text.
Usage:
python3 ask.py <run-dir> <reader> "<question>" one reader (session dir name)
python3 ask.py <run-dir> skim "<question>" the skim-gate reader
python3 ask.py <run-dir> all "<question>" every reader, one bundle each
Hand each printed bundle to a fresh subagent and relay its answer in the
reader's voice. Readers stay honest: an answer the log cannot support is
"I can't say; I didn't note it at the time."
"""
import json
import sys
from pathlib import Path
def bundle(run, name, question):
d = Path(run) / name
if name == "skim":
note = Path(run) / "skim.txt"
if not note.exists():
return None
return (f"You are the reader who skimmed this piece and decided whether to open it. You saw only the "
f"scanner's view (headings, first lines, numbers, links), never the full text, and you still have not "
f"read it. This is what you said at the time:\n\n{note.read_text().strip()}\n\n"
f"The author asks you: {question}\n\nAnswer in your own voice, in under 120 words, from that decision "
f"and nothing else. If you would need to have read the piece to answer, say so.")
if not (d / "state.json").exists():
return None
state = json.loads((d / "state.json").read_text())
entries = [json.loads(l) for l in (d / "log.jsonl").read_text().splitlines() if l.strip()]
log = "\n".join(f"[passage {e['chunk']}] {'QUIT ' if e.get('quit') else ''}{e['entry']}" for e in entries)
how = ("stopped at passage %s of %s" % (state["quit_at"], len(state["chunks"]))) if state.get("quit_at") \
else "finished all %s passages" % len(state["chunks"])
return (f"You are {state['persona']}. Earlier today you read a piece one passage at a time and {how}. "
f"You no longer have the text; you have your memory of reading it, which is exactly the notes you kept "
f"as you went:\n\n{log}\n\nThe author asks you: {question}\n\n"
f"Answer as yourself, in under 150 words, grounded only in what you noted at the time. Quote your own "
f"notes where they answer it. Do not invent details of the text you did not note. If the log does not "
f"support an answer, say 'I can't say; I didn't note it at the time.' Never suggest rewrites; say what "
f"happened to you and what would have had to be true for it to go differently.")
def main():
if len(sys.argv) != 4:
sys.exit(__doc__)
run, who, question = sys.argv[1], sys.argv[2], sys.argv[3]
names = [who]
if who == "all":
names = sorted(p.name for p in Path(run).iterdir() if (p / "state.json").exists())
if (Path(run) / "skim.txt").exists():
names.append("skim")
out = []
for n in names:
b = bundle(run, n, question)
if b is None:
sys.exit(f"no reader '{n}' under {run}")
out.append(f"===== reader: {n} =====\n{b}")
print("\n\n".join(out))
if __name__ == "__main__":
main()
scripts/feed.py
#!/usr/bin/env python3
"""Reveal a draft to a reader one passage at a time, with no lookahead.
Modified by the explainer project (2026-09): word counts treat CJK text as
about one word per two characters, and sentences also end at 。!?, so
Japanese drafts are served in paragraph-sized beats and paced like English.
Upstream: https://github.com/Shubhamsaboo/awesome-llm-apps/tree/main/agent_skills/first-reader
Humans read forward in time: at any sentence they know what came before,
roughly how much text remains, and nothing else. An LLM sees the whole
document at once, which makes "read this like a human" performed rather
than real. This driver restores the arrow of time, and in served mode it
enforces it: the text lives only in the memory of a local server the
orchestrator starts; each reader gets a one-line address with a token
and never a path; the server releases the next passage only after the
reader logs what just happened in them, never re-serves anything but the
current passage, and refuses to move faster than a person could read.
Nothing about the piece is written where a reader could find it until
every reader is finished.
SERVED MODE (default whenever readers are separate agents)
Orchestrator, in the background:
python3 feed.py serve <draft> --run <run-dir> --readers keen,skeptic
It prints one READER_FEED address per reader plus an admin address.
Hand each reader ONLY its own address, e.g.
export READER_FEED=http://127.0.0.1:PORT/<token>
Reader (READER_FEED set in its shell):
python3 feed.py start
python3 feed.py next --log "needle=<-2..+2> expected... got... <felt notes>"
python3 feed.py quit --log "needle=-2 <why the persona stopped>"
Orchestrator, to watch and to finish:
python3 feed.py progress --admin <admin-address>
python3 feed.py close --admin <admin-address>
Closing writes <run-dir>/<reader>/state.json + log.jsonl in the layout
recall.py and room.py expect. Logs stream to log.jsonl live (they hold
only the reader's own words). The server also closes itself once every
reader has finished or quit.
FILE MODE (solo fallback: one mind plays reader and reviewer)
python3 feed.py start <draft> --session <dir> [--persona <name>]
python3 feed.py next <dir> --log "<entry>"
python3 feed.py quit <dir> --log "<entry>"
python3 feed.py transcript <dir>
python3 feed.py status <dir>
Log entry format (one line, free text, but include):
needle=<-2..+2> the felt reaction right now, minus is boredom/distrust
expect: what you now expect next got: what this chunk actually was
plus anything felt: skimmed, lost, won back, doubted a claim, laughed.
"""
import argparse
import json
import os
import re
import secrets
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib import request as urlrequest
from urllib.error import HTTPError
TARGET_WORDS = 85 # a paragraph-scale beat
MAX_WORDS = 200 # split anything longer: nobody swallows 200 words as one beat
MIN_LOG_CHARS = 25
_CJK = re.compile(r"[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uff66-\uff9f]")
def nwords(text):
"""Space-separated words, plus about one word per two CJK characters."""
cjk = len(_CJK.findall(text))
return len(_CJK.sub(" ", text).split()) + (cjk + 1) // 2
def chunk(text):
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
out, buf, buf_words = [], [], 0
for p in paras:
w = nwords(p)
if w > MAX_WORDS:
if buf:
out.append("\n\n".join(buf)); buf, buf_words = [], 0
sentences = re.findall(r".+?(?:[.!?](?=\s|$)|[。!?]|$)", p, re.S)
piece, count = [], 0
for s in sentences:
sw = s.split()
# a sentence-less blob (transcript, list dump) still gets beats
while len(sw) > MAX_WORDS:
out.append(" ".join(piece + sw[:TARGET_WORDS]))
piece, count, sw = [], 0, sw[TARGET_WORDS:]
piece.append(" ".join(sw)); count += nwords(" ".join(sw))
if count >= TARGET_WORDS:
out.append(" ".join(piece)); piece, count = [], 0
if piece:
out.append(" ".join(piece))
continue
# keep a heading attached to the paragraph that follows it
if re.match(r"#{1,6}\s", p) and buf_words == 0:
buf.append(p); continue
buf.append(p); buf_words += w
if buf_words >= TARGET_WORDS:
out.append("\n\n".join(buf)); buf, buf_words = [], 0
if buf:
out.append("\n\n".join(buf))
return out
BRIEFING = ("You are this persona encountering this text cold. React honestly; "
"your persona's patience budget is real, and quitting is a finding, "
"not a failure.")
def chunk_header(i, chunks):
remaining = sum(nwords(c) for c in chunks[i + 1:])
return f"--- CHUNK {i + 1}/{len(chunks)} ({remaining} words remain after this) ---"
def chunk_footer():
return ("--- record your moment log with: feed.py next --log \"...\" "
"(or feed.py quit --log \"...\" if your persona stops here) ---")
def dwell_floor(text, factor):
# a person needs roughly words/4 seconds at 238 wpm; the floor is a
# fraction of that so honest readers never notice it and racers do
return max(2.0, nwords(text) * factor) if factor > 0 else 0.0
# ---------------------------------------------------------------- served mode
class Feed:
def __init__(self, draft, run_dir, names, persona_titles, dwell):
text = Path(draft).read_text(encoding="utf-8")
self.chunks = chunk(text)
self.words = nwords(text)
self.run_dir = Path(run_dir)
self.dwell = dwell
self.admin = secrets.token_urlsafe(18)
self.readers = {}
self.lock = threading.Lock()
self.closed = False
for name in names:
token = secrets.token_urlsafe(18)
d = self.run_dir / name
d.mkdir(parents=True, exist_ok=True)
(d / "log.jsonl").write_text("")
self.readers[token] = {"name": name, "persona": persona_titles.get(name, name),
"cursor": 0, "started": None, "served_at": None,
"done": False, "quit_at": None, "log": []}
def _payload(self, r):
i = r["cursor"]
r["served_at"] = time.time()
return {"header": chunk_header(i, self.chunks), "text": self.chunks[i],
"footer": chunk_footer(), "index": i + 1, "total": len(self.chunks)}
def start(self, r):
if r["done"]:
return {"error": "Session already finished."}
first = r["started"] is None
if first:
r["started"] = time.time()
p = self._payload(r)
p["briefing"] = (f"SESSION {r['name']} | persona: {r['persona']} | "
f"{len(self.chunks)} chunks, {self.words} words total\n{BRIEFING}") if first else \
"(re-showing the current passage; nothing advanced)"
return p
def _record(self, r, entry, quit_, check_dwell=True, chunk=None):
entry = (entry or "").strip()
if len(entry) < MIN_LOG_CHARS:
return {"refused": f"log entry under {MIN_LOG_CHARS} chars. What happened in you "
"during that chunk? needle=?, expected what, got what?"}
# the dwell floor guards lookahead; quitting reveals nothing, so a
# reader may leave the instant a passage loses them
if check_dwell and r["served_at"] is not None and self.dwell > 0:
floor = dwell_floor(self.chunks[r["cursor"]], self.dwell)
waited = time.time() - r["served_at"]
if waited < floor:
return {"refused": f"too fast: a person needs about {floor:.0f}s with this passage "
f"and {waited:.0f}s have passed. Read it, then log what happened."}
rec = {"chunk": chunk or r["cursor"] + 1, "quit": quit_, "entry": entry, "t": time.time()}
r["log"].append(rec)
with (self.run_dir / r["name"] / "log.jsonl").open("a") as f:
f.write(json.dumps(rec) + "\n")
return None
def next(self, r, entry):
if r["done"]:
return {"error": "Session already finished."}
if r["started"] is None:
return {"error": "Call feed.py start first."}
bad = self._record(r, entry, False)
if bad:
return bad
r["cursor"] += 1
if r["cursor"] >= len(self.chunks):
r["done"] = True
self._maybe_finish()
return {"end": "END OF PIECE. The reader finished. Add one final log line for the "
"ending itself via: feed.py quit --log \"final: ...\""}
return self._payload(r)
def quit(self, r, entry):
if r["started"] is None:
return {"error": "Call feed.py start first."}
finished = r["done"] or r["cursor"] >= len(self.chunks) - 1
# a final reaction after END belongs to the last chunk, not cursor+1
last_chunk = len(self.chunks) if r["done"] else None
bad = self._record(r, entry, not finished, check_dwell=False, chunk=last_chunk)
if bad:
return bad
r["done"] = True
r["quit_at"] = None if finished else r["cursor"] + 1
r["served_at"] = time.time()
self._maybe_finish()
if r["quit_at"]:
return {"end": f"Reader stopped at chunk {r['quit_at']}/{len(self.chunks)}. "
"That stop point is the primary finding."}
return {"end": "Final reaction recorded. Session complete."}
def progress(self):
return {r["name"]: {"chunk": min(r["cursor"] + 1, len(self.chunks)), "of": len(self.chunks),
"started": r["started"] is not None, "done": r["done"],
"quit_at": r["quit_at"]} for r in self.readers.values()}
def _maybe_finish(self):
if all(r["done"] for r in self.readers.values()):
self.flush()
def flush(self):
# only now does the text touch disk: every reader is finished
for r in self.readers.values():
state = {"draft": "(served)", "persona": r["persona"], "chunks": self.chunks,
"cursor": r["cursor"], "done": r["done"], "quit_at": r["quit_at"],
"started": r["started"] or time.time()}
(self.run_dir / r["name"] / "state.json").write_text(json.dumps(state, indent=2))
self.closed = True
class Handler(BaseHTTPRequestHandler):
feed = None
server_ref = None
def log_message(self, *a): # quiet
pass
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _route(self, method):
parts = [p for p in self.path.split("?")[0].split("/") if p]
if len(parts) != 2:
return self._send(404, {"error": "not found"})
token, action = parts
body = {}
if method == "POST":
n = int(self.headers.get("Content-Length") or 0)
if n:
try:
body = json.loads(self.rfile.read(n) or b"{}")
except json.JSONDecodeError:
body = {}
f = self.feed
with f.lock:
if token == f.admin:
if action == "progress":
return self._send(200, f.progress())
if action == "close":
f.flush()
self._send(200, {"ok": True, "closed": True})
threading.Thread(target=self.server_ref.shutdown, daemon=True).start()
return
return self._send(404, {"error": "not found"})
r = f.readers.get(token)
if r is None:
return self._send(404, {"error": "not found"})
if action == "start" and method == "GET":
return self._send(200, f.start(r))
if action == "next" and method == "POST":
return self._send(200, f.next(r, body.get("log")))
if action == "quit" and method == "POST":
return self._send(200, f.quit(r, body.get("log")))
return self._send(404, {"error": "not found"})
def do_GET(self):
self._route("GET")
def do_POST(self):
self._route("POST")
def cmd_serve(args):
names = [n.strip() for n in args.readers.split(",") if n.strip()]
titles = {}
for spec in (args.persona or []):
if "=" in spec:
k, v = spec.split("=", 1); titles[k.strip()] = v.strip()
feed = Feed(args.draft, args.run, names, titles, args.dwell)
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
Handler.feed = feed
Handler.server_ref = httpd
port = httpd.server_address[1]
base = f"http://127.0.0.1:{port}"
print(f"FEED serving {len(feed.chunks)} passages, {feed.words} words, readers: {', '.join(names)}")
for token, r in feed.readers.items():
print(f"reader {r['name']}: READER_FEED={base}/{token}")
print(f"admin: {base}/{feed.admin}")
print("Give each reader ONLY its own READER_FEED line. Nothing else about the piece.", flush=True)
if args.ready_file:
Path(args.ready_file).write_text(json.dumps(
{"base": base, "admin": f"{base}/{feed.admin}",
"readers": {r["name"]: f"{base}/{t}" for t, r in feed.readers.items()}}))
def watchdog():
while True:
time.sleep(5)
if feed.closed:
# every reader is done, but the last one may still be sending
# its final reaction: stay up until things go quiet
last = max([r["served_at"] or 0 for r in feed.readers.values()] + [0])
if time.time() - last > 90:
httpd.shutdown(); return
continue
anyone_started = any(r["started"] is not None for r in feed.readers.values())
if args.idle and anyone_started:
last = max(r["served_at"] or 0 for r in feed.readers.values())
if last and time.time() - last > args.idle:
with feed.lock:
feed.flush()
httpd.shutdown(); return
threading.Thread(target=watchdog, daemon=True).start()
try:
httpd.serve_forever()
finally:
if not feed.closed:
with feed.lock:
feed.flush()
def _call(base, action, payload=None):
url = base.rstrip("/") + "/" + action
data = json.dumps(payload).encode() if payload is not None else None
req = urlrequest.Request(url, data=data, method="POST" if data is not None else "GET",
headers={"Content-Type": "application/json"})
try:
with urlrequest.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except HTTPError as e:
sys.exit(f"feed error: {e.code} (wrong READER_FEED address or the feed has closed)")
except OSError as e:
sys.exit(f"feed unreachable at {base}: {e}. Is the feed still running?")
def _print_served(res):
if "refused" in res:
sys.exit("REFUSED: " + res["refused"])
if "error" in res:
sys.exit(res["error"])
if "briefing" in res:
print(res["briefing"])
if "end" in res:
print(res["end"]); return
print(res["header"]); print(res["text"]); print(res["footer"])
def served_base(args):
return getattr(args, "feed", None) or os.environ.get("READER_FEED")
# ------------------------------------------------------------------ file mode
def load(session):
d = Path(session)
state = json.loads((d / "state.json").read_text())
return d, state
def save(d, state):
(d / "state.json").write_text(json.dumps(state, indent=2))
def show(state, i):
chunks = state["chunks"]
print(chunk_header(i, chunks))
print(chunks[i])
print(chunk_footer())
def cmd_start(args):
base = served_base(args)
if base:
return _print_served(_call(base, "start"))
if not args.draft or not args.session:
sys.exit("file mode needs: feed.py start <draft> --session <dir> (or set READER_FEED)")
d = Path(args.session); d.mkdir(parents=True, exist_ok=True)
text = Path(args.draft).read_text(encoding="utf-8")
chunks = chunk(text)
state = {"draft": str(args.draft), "persona": args.persona or "unnamed",
"chunks": chunks, "cursor": 0, "done": False, "started": time.time()}
save(d, state)
(d / "log.jsonl").write_text("")
print(f"SESSION {d} | persona: {state['persona']} | {len(chunks)} chunks, "
f"{nwords(text)} words total")
print(BRIEFING)
show(state, 0)
def record(d, state, entry, quit_=False):
if len(entry.strip()) < MIN_LOG_CHARS:
sys.exit(f"REFUSED: log entry under {MIN_LOG_CHARS} chars. What happened "
"in you during that chunk? needle=?, expected what, got what?")
with (d / "log.jsonl").open("a") as f:
f.write(json.dumps({"chunk": state["cursor"] + 1, "quit": quit_,
"entry": entry.strip(), "t": time.time()}) + "\n")
def _session_or_die(args):
if not args.session:
sys.exit("file mode needs the session dir: feed.py next <dir> --log ... (or set READER_FEED)")
def cmd_next(args):
base = served_base(args)
if base:
return _print_served(_call(base, "next", {"log": args.log}))
_session_or_die(args)
d, state = load(args.session)
if state["done"]:
sys.exit("Session already finished.")
record(d, state, args.log)
state["cursor"] += 1
if state["cursor"] >= len(state["chunks"]):
state["done"] = True; save(d, state)
print("END OF PIECE. The reader finished. Add one final log line for the "
"ending itself via: feed.py quit <session> --log \"final: ...\"")
return
save(d, state)
show(state, state["cursor"])
def cmd_quit(args):
base = served_base(args)
if base:
return _print_served(_call(base, "quit", {"log": args.log}))
_session_or_die(args)
d, state = load(args.session)
finished = state["done"] or state["cursor"] >= len(state["chunks"]) - 1
record(d, state, args.log, quit_=not finished)
state["done"] = True
state["quit_at"] = None if finished else state["cursor"] + 1
save(d, state)
if state["quit_at"]:
print(f"Reader stopped at chunk {state['quit_at']}/{len(state['chunks'])}. "
"That stop point is the primary finding.")
else:
print("Final reaction recorded. Session complete.")
def cmd_transcript(args):
d, state = load(args.session)
print(f"READING TRANSCRIPT | persona: {state['persona']} | "
f"{'finished' if not state.get('quit_at') else 'QUIT at chunk %s' % state['quit_at']}"
f" of {len(state['chunks'])} chunks")
entries = [json.loads(l) for l in (d / "log.jsonl").read_text().splitlines() if l.strip()]
for i, e in enumerate(entries):
final = (i == len(entries) - 1 and state["done"] and not state.get("quit_at"))
mark = "QUIT " if e["quit"] else ("FINAL " if final else "")
print(f"[chunk {e['chunk']}] {mark}{e['entry']}")
def cmd_status(args):
d, state = load(args.session)
print(f"persona={state['persona']} "
f"cursor={min(state['cursor'] + 1, len(state['chunks']))}/"
f"{len(state['chunks'])} done={state['done']} quit_at={state.get('quit_at')}")
def cmd_progress(args):
print(json.dumps(_call(args.admin, "progress"), indent=2))
def cmd_close(args):
print(json.dumps(_call(args.admin, "close")))
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("serve"); s.add_argument("draft"); s.add_argument("--run", required=True)
s.add_argument("--readers", required=True, help="comma-separated reader names")
s.add_argument("--persona", action="append", help="name=one-line persona (repeatable)")
s.add_argument("--port", type=int, default=0)
s.add_argument("--dwell", type=float, default=0.08,
help="seconds per word a reader must spend on a passage before advancing; 0 disables")
s.add_argument("--idle", type=float, default=1800, help="close after this many idle seconds")
s.add_argument("--ready-file", help="write addresses as JSON here for the orchestrator")
s = sub.add_parser("start"); s.add_argument("draft", nargs="?"); s.add_argument("--session"); s.add_argument("--persona"); s.add_argument("--feed")
for name in ("next", "quit"):
s = sub.add_parser(name); s.add_argument("session", nargs="?"); s.add_argument("--log", required=True); s.add_argument("--feed")
for name in ("transcript", "status"):
s = sub.add_parser(name); s.add_argument("session")
for name in ("progress", "close"):
s = sub.add_parser(name); s.add_argument("--admin", required=True)
args = p.parse_args()
{"serve": cmd_serve, "start": cmd_start, "next": cmd_next, "quit": cmd_quit,
"transcript": cmd_transcript, "status": cmd_status,
"progress": cmd_progress, "close": cmd_close}[args.cmd](args)
if __name__ == "__main__":
main()
scripts/recall.py
#!/usr/bin/env python3
"""Build the recall quiz: what survived the reading, tested honestly.
Humans keep gist, not sentences; verbatim memory collapses within seconds
of continued reading. An agent with the draft in context has perfect
verbatim memory, so asking it "what do you remember" is theater. The
honest experiment: hand a FRESH agent only the reading transcript (the
moment log a reader produced during the timed read) and quiz it. What made
it into the log is what registered during reading; what the fresh agent
can reconstruct from the log is the gist that would survive a commute.
Usage: python3 recall.py <session-dir>
Prints the transcript plus the quiz. Contains no draft text beyond what
the reader chose to quote in their own log, which is exactly the point.
"""
import json
import sys
from pathlib import Path
def main():
if len(sys.argv) != 2:
sys.exit(__doc__)
d = Path(sys.argv[1])
state = json.loads((d / "state.json").read_text())
print("You are answering for a reader who read a piece once, earlier today, "
"and no longer has it. Below is the moment-by-moment log they kept "
"while reading. You have nothing else. Answer only from this log; "
"if the log does not support an answer, say 'nothing survived'.")
print(f"\npersona: {state['persona']} | "
f"{'finished the piece' if not state.get('quit_at') else 'quit at chunk %s of %s' % (state['quit_at'], len(state['chunks']))}")
print("-" * 60)
for line in (d / "log.jsonl").read_text().splitlines():
e = json.loads(line)
print(f"[chunk {e['chunk']}] {'QUIT ' if e['quit'] else ''}{e['entry']}")
print("-" * 60)
print("""QUIZ (answer from the log alone):
1. SAYBACK: retell the piece in one sentence, as you'd describe it to a
colleague. If you can't, say so; that is a finding, not a failure.
2. POINTING: quote any exact words or phrases that stuck. Only what the
log actually preserved counts.
3. PEAK: the single strongest moment of the read, and why.
4. ENDING: how it ended and how that felt. 'I don't remember the ending'
is a valid and important answer.
5. CENTER OF GRAVITY: where did the piece feel most alive? Is that the
same thing the piece claimed to be about?
6. ONE ACTION: is there anything you would now do, check, or tell someone
because you read this?""")
if __name__ == "__main__":
main()
scripts/room_template.html
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>@@TITLE@@</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap">
<style data-own>
:root {
--canvas:#fafafa; --card:#ffffff; --well:#f2f2f2; --line:#ebebeb;
--ink:#171717; --prose:#262626; --body:#4d4d4d; --mute:#626262; --faint:#6e6e6e;
--ink-soft:#626262;
--link:#0070f3; --mem-bg:#d3e5ff;
--n2:#0070f3; --n1:#79a8f5; --n0:#d4d4d4; --n0-ink:#171717; --nm1:#f5a623; --nm2:#ee0000;
--on-n2:#ffffff; --on-n1:#171717; --on-nm1:#171717; --on-nm2:#ffffff;
--nm1-ink:#8a5200; --nm2-ink:#c40000;
--header-bg:rgba(250,250,250,.86); --mesh-o:.5;
--shadow-float:0 2px 2px rgba(0,0,0,.04), 0 8px 16px -4px rgba(0,0,0,.10);
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--canvas:#0a0a0a; --card:#111111; --well:#161616; --line:#262626;
--ink:#ededed; --prose:#d6d6d6; --body:#a1a1a1; --mute:#9a9a9a; --faint:#8a8a8a;
--link:#52a8ff; --mem-bg:#102a4c;
--n2:#3291ff; --n1:#2f6fce; --n0:#3a3a3a; --n0-ink:#ededed; --nm1:#d9921f; --nm2:#ff4d4d;
--on-n2:#0a0a0a; --on-n1:#ffffff; --on-nm1:#171717; --on-nm2:#171717;
--nm1-ink:#f0a83a; --nm2-ink:#ff6b6b;
--header-bg:rgba(10,10,10,.82); --mesh-o:.3;
--shadow-float:0 2px 2px rgba(0,0,0,.3), 0 8px 16px -4px rgba(0,0,0,.5);
} }
:root[data-theme="dark"] {
--canvas:#0a0a0a; --card:#111111; --well:#161616; --line:#262626;
--ink:#ededed; --prose:#d6d6d6; --body:#a1a1a1; --mute:#9a9a9a; --faint:#8a8a8a;
--link:#52a8ff; --mem-bg:#102a4c;
--n2:#3291ff; --n1:#2f6fce; --n0:#3a3a3a; --n0-ink:#ededed; --nm1:#d9921f; --nm2:#ff4d4d;
--on-n2:#0a0a0a; --on-n1:#ffffff; --on-nm1:#171717; --on-nm2:#171717;
--nm1-ink:#f0a83a; --nm2-ink:#ff6b6b;
--header-bg:rgba(10,10,10,.82); --mesh-o:.3;
--shadow-float:0 2px 2px rgba(0,0,0,.3), 0 8px 16px -4px rgba(0,0,0,.5);
}
* { box-sizing:border-box }
html { scroll-behavior:smooth; background:var(--canvas); }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior:auto } }
body { background:var(--canvas); color:var(--ink); margin:0;
font-family:Geist, Inter, Arial, sans-serif; line-height:1.6;
-webkit-font-smoothing:antialiased; }
/* ---------- top bar ---------- */
header { position:sticky; top:0; z-index:9; background:var(--header-bg);
backdrop-filter:blur(10px); border-bottom:1px solid var(--line);
padding:10px 28px 12px; overflow:hidden; }
.brief { border-bottom:1px solid var(--line); padding:16px 28px 20px; }
header::before { content:""; position:absolute; top:-140px; right:-60px;
width:520px; height:340px; pointer-events:none; opacity:var(--mesh-o);
background:
radial-gradient(200px 160px at 20% 60%, rgba(80,227,194,.35), transparent 70%),
radial-gradient(240px 180px at 55% 30%, rgba(0,112,243,.30), transparent 70%),
radial-gradient(220px 170px at 80% 65%, rgba(121,40,202,.28), transparent 70%),
radial-gradient(180px 150px at 95% 30%, rgba(255,0,128,.22), transparent 70%),
radial-gradient(160px 140px at 40% 85%, rgba(249,203,40,.20), transparent 70%); }
.hrow { max-width:1080px; margin:0 auto; position:relative; }
.topline { display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
h1.room { font-size:20px; font-weight:600; letter-spacing:-0.4px; margin:0; color:var(--ink); }
.chips { display:flex; gap:6px; flex-wrap:wrap; }
.statchip { font-family:"Geist Mono", "IBM Plex Mono", monospace; font-size:11px;
color:var(--body); background:var(--card); border:1px solid var(--line);
border-radius:6px; padding:3px 8px; white-space:nowrap; }
.statchip b { color:var(--ink); font-weight:500; }
.verdict { font-size:14px; color:var(--body); margin:10px 0 0; max-width:74ch; line-height:1.55; }
.verdict b { color:var(--ink); font-weight:600; }
.prev { font-size:13px; color:var(--body); margin:8px 0 0; max-width:74ch; line-height:1.55;
border-left:2px solid var(--n2); padding-left:10px; }
.prev b { color:var(--ink); font-weight:500; }
/* ---------- skim verdict, how to consult ---------- */
.verdict:first-child { margin-top:0 }
.flabel { font-family:"Geist Mono", monospace; font-size:11px; font-weight:500;
letter-spacing:.04em; text-transform:uppercase; color:var(--mute); margin-bottom:2px; }
.skim { margin:14px 0 0; max-width:74ch; border:1px solid var(--line); border-radius:8px;
background:var(--card); padding:10px 14px; }
.skim p { margin:4px 0 0; font-size:14px; color:var(--body); line-height:1.55; }
.askhint { margin:12px 0 0; max-width:74ch; font-size:13px; color:var(--body); line-height:1.55; }
/* ---------- buttons ---------- */
.btn { border:1px solid var(--line); background:var(--card); color:var(--ink);
padding:4px 10px; border-radius:6px; cursor:pointer; font:inherit;
font-size:13px; font-weight:500; line-height:20px;
transition:border-color .12s, background .12s; }
.btn:hover { border-color:var(--faint); }
.btn[aria-pressed="true"] { background:var(--ink); color:var(--canvas); border-color:var(--ink); }
.btn:focus-visible { outline:2px solid var(--link); outline-offset:2px; }
/* ---------- scrubber ---------- */
.scrub { display:flex; gap:4px; margin:10px 0 2px; max-width:1080px; }
.cell { flex:1; height:30px; position:relative; border:1px solid var(--line);
background:var(--card); border-radius:6px; cursor:pointer; padding:0;
display:flex; flex-direction:column; justify-content:center; gap:3px;
align-items:stretch; transition:border-color .12s; }
.cell:hover { border-color:var(--faint); }
.cell:focus-visible { outline:2px solid var(--link); }
.tr { height:4px; border-radius:2px; margin:0 18%; }
.v2 { background:var(--n2) } .v1 { background:var(--n1) } .v0 { background:var(--n0) }
.v-1 { background:var(--nm1) } .v-2 { background:var(--nm2) } .vx { background:var(--line) }
.fmark { position:absolute; top:-4px; left:50%; width:6px; height:6px; border-radius:50%;
background:var(--link); transform:translateX(-50%); }
.scrublabel { font-family:"Geist Mono", monospace; font-size:10px; font-weight:500;
letter-spacing:.06em; text-transform:uppercase; color:var(--faint); margin:10px 0 -6px; }
.scrub.prev { opacity:.55 }
.scrub.prev .cell { height:22px; cursor:default }
.views { margin-top:12px; }
.skey { display:flex; gap:14px; align-items:center; margin-top:8px;
font-family:"Geist Mono", monospace; font-size:10px; letter-spacing:.04em;
text-transform:uppercase; color:var(--faint); }
.skey i { display:inline-block; width:14px; height:4px; border-radius:2px; margin-right:5px; vertical-align:middle; }
.made { font-family:"Geist Mono", monospace; font-size:10px; letter-spacing:.06em;
text-transform:uppercase; color:var(--faint); margin-top:28px; }
.views summary { list-style:none; cursor:pointer; color:var(--mute);
font-family:"Geist Mono", monospace; font-size:11px; font-weight:500;
letter-spacing:.04em; text-transform:uppercase; display:inline-block; }
.views summary::-webkit-details-marker { display:none }
.views summary:hover { color:var(--body); }
.controls { display:flex; flex-wrap:wrap; gap:14px; align-items:center; margin-top:10px; }
.grp { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
.grp label { color:var(--mute); font-family:"Geist Mono", monospace;
letter-spacing:.04em; text-transform:uppercase; font-size:10px; margin-right:4px; }
/* ---------- manuscript ---------- */
main { max-width:1080px; margin:0 auto; padding:40px 28px 140px; }
.legend { border:1px solid var(--line); border-radius:8px; background:var(--card);
padding:12px 16px; margin:0 0 24px; font-size:13px; color:var(--body);
display:flex; flex-direction:column; gap:8px; max-width:74ch; }
.legend .lrow { display:flex; flex-wrap:wrap; gap:6px 14px; align-items:center; line-height:1.5; }
.legend .flabel { margin:0; flex:none; }
.litem { display:inline-flex; align-items:center; gap:6px; }
.litem .who, .litem .chip, .litem .skimtag { flex:none }
.chunk { display:grid; grid-template-columns:minmax(0,64ch) 1fr; gap:44px;
padding:28px 0 16px; border-bottom:1px solid var(--line); }
.fold { font-family:"Geist Mono", monospace; color:var(--nm2-ink);
border-top:1px dashed var(--nm2); padding:12px 0 4px; font-size:13px; }
.cnum { font-family:"Geist Mono", monospace; font-size:10px; font-weight:500;
letter-spacing:.06em; text-transform:uppercase; color:var(--faint); margin-bottom:12px;
display:flex; align-items:center; gap:12px; }
.flag { font-family:"Geist Mono", monospace; color:var(--link); font-size:11px;
font-weight:500; letter-spacing:.04em; text-transform:uppercase; margin-bottom:4px; }
.prose { color:var(--prose); }
.prose p, .prose li { font-size:15px; line-height:1.65; }
.prose h2,.prose h3,.prose h4 { font-weight:600; line-height:1.25;
text-wrap:balance; color:var(--ink); }
.prose h2 { font-size:28px; line-height:1.2; letter-spacing:-1.1px; }
.prose h3 { font-size:20px; letter-spacing:-0.4px; }
.prose h4 { font-size:16px; letter-spacing:-0.2px; }
.prose a { color:var(--link); text-decoration:none; }
.prose a:hover { text-decoration:underline; }
.code { background:var(--card); border:1px solid var(--line); border-radius:8px;
padding:14px 16px; overflow-x:auto; font-family:"Geist Mono", monospace;
font-size:12.5px; line-height:1.55; color:var(--body); }
p code, li code { font-family:"Geist Mono", monospace; font-size:.85em;
background:var(--well); border:1px solid var(--line); padding:1px 5px; border-radius:4px; }
mark.mem { background:transparent; color:inherit; }
/* ---------- edits applied after the read ---------- */
/* ---------- margin notes ---------- */
.notes { display:flex; flex-direction:column; gap:8px; padding-top:36px; }
.note { font-family:"Geist Mono", monospace; font-size:11.5px; line-height:1.55;
color:var(--body); padding:9px 11px; background:var(--card); border-radius:8px;
border:1px solid var(--line); }
.note.final { border-color:var(--faint); }
.who { display:inline-block; width:15px; height:15px; border-radius:50%; text-align:center;
line-height:15px; font-size:9px; font-weight:500; margin-right:6px;
font-family:Geist, sans-serif; }
@@WHO_CSS@@
.chip { display:inline-block; font-size:10px; padding:0 5px; border-radius:4px;
color:#fff; margin-right:6px; font-weight:500; font-family:Geist, sans-serif; }
.chip.n2 { background:var(--n2); color:var(--on-n2) } .chip.n1 { background:var(--n1); color:var(--on-n1) }
.chip.n0 { background:var(--n0); color:var(--n0-ink) }
.chip.nm1 { background:var(--nm1); color:var(--on-nm1) } .chip.nm2 { background:var(--nm2); color:var(--on-nm2) }
.skimtag { font-size:9px; letter-spacing:.08em; text-transform:uppercase;
color:var(--nm1-ink); margin-right:6px; }
.skimtag.stop { color:var(--nm2-ink) }
/* ---------- envelope / footer ---------- */
.envelope { margin-top:40px; border:1px solid var(--line); border-radius:8px;
padding:16px 20px; background:var(--card); max-width:64ch; }
.envelope summary { cursor:pointer; font-size:15px; font-weight:500; color:var(--ink); }
.envelope li { font-size:14px; color:var(--body); }
.howto { max-width:64ch; margin:32px 0 0; font-size:13px; color:var(--body); }
.howto summary { list-style:none; cursor:pointer; font-family:"Geist Mono", monospace;
font-size:11px; font-weight:500; letter-spacing:.04em; text-transform:uppercase;
color:var(--mute); }
.howto summary::-webkit-details-marker { display:none }
.howto summary:hover { color:var(--body) }
.howto p { margin:.6em 0; line-height:1.6; }
/* ---------- persona / lens states ---------- */
@@SEL_CSS@@
body.lens-scan .prose p, body.lens-scan .prose li { color:var(--faint); opacity:.4 }
body.lens-scan .prose .fs, body.lens-scan .prose h2, body.lens-scan .prose h3,
body.lens-scan .prose h4, body.lens-scan .prose strong,
body.lens-scan .prose .num { color:var(--ink); opacity:1 }
body.lens-scan .code { opacity:.3 } body.lens-scan .code .num { opacity:1; color:var(--ink) }
body.lens-scan .notes { opacity:.25 }
body.lens-mem .prose :is(p,li,h2,h3,h4,.code) { color:var(--faint); opacity:.3 }
body.lens-mem .prose mark.mem { background:var(--mem-bg); color:var(--ink); opacity:1;
padding:0 3px; border-radius:3px; }
body.lens-mem .prose :is(p,li,h2,h3,h4):has(mark.mem) { opacity:.8 }
body.lens-mem .notes { opacity:.25 }
body.lens-trust .prose .num, body.lens-trust .prose .ent {
border-bottom:2px solid var(--link); font-weight:600; }
body.lens-trust .prose .portable { opacity:.4; text-decoration:line-through;
text-decoration-color:var(--nm1); text-decoration-thickness:1px; }
body.replay .chunk.future { opacity:.1 }
body.replay .chunk.now { opacity:1 }
@media (max-width:880px) { .chunk { grid-template-columns:minmax(0,1fr); gap:12px } .notes { padding-top:6px }
header { position:static } }
</style>
<header>
<div class="hrow">
<div class="topline">
<h1 class="room">@@TITLE@@</h1>
<div class="chips">@@STATS@@</div>
</div>
@@PREVSCRUB@@
<div class="scrub" role="group" title="each block is one stretch of the piece; blue means readers leaned in, amber and red mean they drifted; a dot marks a moment worth clicking" aria-label="how each reader felt, stretch by stretch">@@SCRUB@@</div>
<div class="skey"><span><i class="v2"></i>leaning in</span><span><i class="v-1"></i>drifting</span><span><i class="v-2"></i>gone</span><span>· click a block to jump there</span></div>
</div>
</header>
<section class="brief" aria-label="Verdict and findings">
<div class="hrow">
<p class="verdict">@@VERDICT_LEAD@@ @@VERDICT_REST@@</p>
@@PREVIOUS@@
@@SKIM@@
@@ASKHINT@@
<details class="views"><summary>@@VIEWS_LABEL@@ ▾</summary>
<div class="controls">
<div class="grp"><label>readers</label>@@SEL_BTNS@@</div>
<div class="grp"><label>lens</label>@@LENS_BTNS@@</div>
<div class="grp"><button class="btn" id="playBtn" aria-pressed="false" onclick="replay()">replay the read</button></div>
</div>
</details>
</div>
</section>
<main>
@@LEGEND@@
@@CHUNKS@@
@@OPINIONS@@
<details class="howto"><summary>how this page was made ▾</summary>
<p>AI readers with different lives got this piece the way a person would: one passage at a time, no peeking ahead, free to quit whenever. A passage is a paragraph-sized cut, roughly 85 words; this piece became @@N@@ of them, and a script refused to show each reader the next passage until they wrote down what just happened in them.</p>
<p>The readers: @@READER_INTRO@@ Their real notes sit beside each passage with the needle they logged: +2 leaning in, -2 done. A dashed line means a reader stopped and never saw the rest. Afterward, a separate agent was quizzed with the piece taken away, holding only the reading notes; the "what stuck" lens shows what survived. None of this is a score. It's what happened to the readers, written down as it happened.</p>
</details>
<div class="made">first-reader @@VERSION@@ · readers before you publish</div>
</main>
<script data-own>
if(!document.documentElement.lang)document.documentElement.lang='en';
function sel(k){
document.body.className=document.body.className.replace(/\bsel-\S+/g,'').trim();
if(k!=='all')document.body.classList.add('sel-'+k);
document.querySelectorAll('[id^="sel"]').forEach(b=>b.setAttribute('aria-pressed',
String(b.id==='sel'+k||(k==='all'&&b.id==='selall'))));
}
function lens(m){
document.body.classList.remove('lens-scan','lens-mem','lens-trust');
if(m!=='read')document.body.classList.add('lens-'+m);
const map={read:'lensRead',scan:'lensScan',mem:'lensMem',trust:'lensTrust'};
Object.entries(map).forEach(([kk,id])=>{const b=document.getElementById(id);
if(b)b.setAttribute('aria-pressed',String(kk===m));});
}
function jump(i){document.getElementById('c'+i).scrollIntoView({block:'start'});}
let timer=null;
function replay(){
const btn=document.getElementById('playBtn');
if(timer){clearInterval(timer);timer=null;document.body.classList.remove('replay');
document.querySelectorAll('.chunk').forEach(c=>c.classList.remove('now','future'));
btn.setAttribute('aria-pressed','false');btn.textContent='replay the read';return;}
document.body.classList.add('replay');btn.setAttribute('aria-pressed','true');btn.textContent='stop replay';
const cs=[...document.querySelectorAll('.chunk')];cs.forEach(c=>c.classList.add('future'));
let i=0;
const step=()=>{if(i>0)cs[i-1].classList.remove('now');
if(i>=cs.length){clearInterval(timer);timer=null;document.body.classList.remove('replay');
cs.forEach(c=>c.classList.remove('future','now'));
btn.setAttribute('aria-pressed','false');btn.textContent='replay the read';return;}
cs[i].classList.remove('future');cs[i].classList.add('now');
cs[i].scrollIntoView({block:'center'});i++;};
step();timer=setInterval(step,2600);}
</script>
scripts/room.py
#!/usr/bin/env python3
"""Build the first-reader page: the draft with the reading still on it.
Takes a run directory holding one or more feed.py sessions and emits a
single self-contained HTML page: the draft passage by passage, each
reader's margin notes with their needle, quit points rendered as a fold
line, a needle strip up top, and lenses. It is a report: the author
acts on it by talking to their agent, not by clicking.
Usage:
python3 room.py <run-dir> [--out room.html] [--annotations room-annotations.json]
<run-dir> layout: every subdirectory containing state.json + log.jsonl is
a reader session. All sessions must come from the same draft.
The page builds without annotations, using deterministic fallbacks:
excerpts are trimmed from the raw logs, and the memory/trust lenses hide
until the annotations file provides their phrase lists. The annotations
format is documented in references/room.md; the reviewing agent writes it
after the recall and report steps so the page carries curated excerpts, a
plain-language verdict, flags, and the lens data.
"""
import argparse
import html
import json
import re
from pathlib import Path
VERSION = "1.0.0"
M1, M2 = "\ue000", "\ue001"
P1, P2 = "\ue002", "\ue003"
E1, E2 = "\ue004", "\ue005"
N1, N2 = "\ue006", "\ue007"
def esc(t):
return html.escape(t, quote=False)
class Marker:
def __init__(self, mem, portable, ents):
self.mem = sorted(mem, key=len, reverse=True)
self.portable = portable
self.ents = sorted(ents, key=len, reverse=True)
def wrap(self, seg):
for ph in self.mem:
seg = seg.replace(esc(ph), M1 + esc(ph) + M2)
for ph in self.portable:
seg = seg.replace(esc(ph), P1 + esc(ph) + P2)
for ph in self.ents:
seg = re.sub(r"(?<![\w>-])(" + re.escape(ph) + r")(?![\w])", E1 + r"\1" + E2, seg)
seg = re.sub(r"(?<![\w-])([\w$][\w.,:%$-]*\d[\w.,:%$-]*)", N1 + r"\1" + N2, seg)
return seg
def mark(self, h):
parts = re.split(r"(<[^>]+>)", h)
out = "".join(p if p.startswith("<") else self.wrap(p) for p in parts)
return (out.replace(M1, '<mark class="mem">').replace(M2, "</mark>")
.replace(P1, '<span class="portable">').replace(P2, "</span>")
.replace(E1, '<span class="ent">').replace(E2, "</span>")
.replace(N1, '<span class="num">').replace(N2, "</span>"))
def inline_md(t):
t = esc(t)
t = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1", t)
t = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", t)
t = re.sub(r"`([^`]+)`", r"<code>\1</code>", t)
return t
def first_sentence_wrap(phtml):
m = re.search(r"[.!?](\s|$)", phtml)
if not m:
return f'<span class="fs">{phtml}</span>'
i = m.end()
return f'<span class="fs">{phtml[:i]}</span>{phtml[i:]}'
def md_block(text, marker):
out, lines, i = [], text.split("\n"), 0
while i < len(lines):
ln = lines[i]
if ln.startswith("```"):
buf = []
i += 1
while i < len(lines) and not lines[i].startswith("```"):
buf.append(lines[i]); i += 1
i += 1
# tabindex so a keyboard user can scroll a wide block
out.append('<pre class="code" tabindex="0">' + marker.mark(esc("\n".join(buf))) + "</pre>")
continue
if re.match(r"#{1,6}\s", ln):
# one level down: the page's own title is the only h1
lvl = min(len(ln) - len(ln.lstrip("#")) + 1, 4)
out.append(f"<h{lvl}>{marker.mark(inline_md(ln.lstrip('# ')))}</h{lvl}>")
i += 1; continue
if re.match(r"(\*|-|\d+\.)\s", ln):
items = []
while i < len(lines) and re.match(r"(\*|-|\d+\.)\s", lines[i]):
item = re.sub(r"^(\*|-|\d+\.)\s+", "", lines[i])
items.append("<li>" + first_sentence_wrap(marker.mark(inline_md(item))) + "</li>")
i += 1
out.append("<ul>" + "".join(items) + "</ul>")
continue
if ln.strip():
para = [ln]
i += 1
while i < len(lines) and lines[i].strip() and not re.match(r"(#{1,6}|\*|-|\d+\.|```)\s?", lines[i]):
para.append(lines[i]); i += 1
out.append("<p>" + first_sentence_wrap(marker.mark(inline_md(" ".join(para)))) + "</p>")
continue
i += 1
return "\n".join(out)
def load_sessions(run_dir):
sessions = []
for d in sorted(run_dir.iterdir()):
if d.is_dir() and (d / "state.json").exists() and (d / "log.jsonl").exists():
state = json.loads((d / "state.json").read_text())
entries = [json.loads(l) for l in (d / "log.jsonl").read_text().splitlines() if l.strip()]
sessions.append({"dir": d.name, "state": state, "entries": entries})
if not sessions:
raise SystemExit(f"No sessions (state.json + log.jsonl) found under {run_dir}")
n = len(sessions[0]["state"]["chunks"])
for s in sessions:
if len(s["state"]["chunks"]) != n:
raise SystemExit("Sessions disagree on passage count; they must come from one draft.")
return sessions
def parse_needle(entry):
m = re.search(r"needle\s*[=:]?\s*([+-]?\d)", entry)
if not m:
return 0
return max(-2, min(2, int(m.group(1))))
def auto_excerpt(entry):
t = re.sub(r"^\s*needle\s*[=:]?\s*[+-]?\d[.,;:]?\s*", "", entry).strip()
parts = re.split(r"(?<=[.!?])\s+", t)
out = ""
for p in parts:
if out and len(out) + len(p) > 200:
break
out = (out + " " + p).strip()
if len(out) > 140:
break
return out[:240]
def needle_map(session):
per = {}
for e in session["entries"]:
per[e["chunk"]] = parse_needle(e["entry"])
return per
def prev_strip(prev_run):
"""A dimmed strip of last run's needles so revision reads as progress."""
try:
psess = load_sessions(Path(prev_run))
except SystemExit:
return ""
pn = len(psess[0]["state"]["chunks"])
cells = []
for idx in range(1, pn + 1):
rows = []
for ps in psess:
v = needle_map(ps).get(idx)
rows.append(f'<i class="tr v{"x" if v is None else v}"></i>')
cells.append(f'<span class="cell" role="img" aria-label="last time, passage {idx}">{"".join(rows)}</span>')
return (f'<div class="scrublabel">last time ({pn} passages)</div>'
f'<div class="scrub prev" aria-label="how each reader felt last time">{"".join(cells)}</div>'
'<div class="scrublabel">this time</div>')
def needle_chip(v):
cls = {2: "n2", 1: "n1", 0: "n0", -1: "nm1", -2: "nm2"}[v]
return f'<span class="chip {cls}">{"+" if v > 0 else ""}{v}</span>'
def render_skim(skim):
"""The skim gate's verdict: what a scanner thought this was, and whether they opened it."""
if not skim:
return ""
committed = skim.get("commits")
tag = "opened it" if committed else "moved on"
return (f'<div class="skim"><div class="flabel">the skimmer {tag}</div>'
f'<p>{esc(skim.get("said", ""))}</p></div>')
def render_ask_hint(sessions):
names = " or ".join(f'"ask {s["initial"]} ..."' for s in sessions[:2])
return (f'<div class="askhint">These are people you can consult. In chat: {names}, '
f'"ask the skimmer what would have made them open it", or "ask everyone ...". '
f'Revise in your own editor, then "again" for a fresh read.</div>')
def render_stats(sessions, n):
"""The chips in the top bar: readers, finished, stopped early, passages, fixes."""
finished = sum(1 for s in sessions if not s["quit_at"])
quits = len(sessions) - finished
stats = [f'<span class="statchip"><b>{len(sessions)}</b> readers</span>',
f'<span class="statchip"><b>{finished}</b> finished</span>']
if quits:
stats.append(f'<span class="statchip" style="color:var(--nm2-ink)"><b>{quits}</b> stopped early</span>')
stats.append(f'<span class="statchip"><b>{n}</b> passages</span>')
stats_html = "".join(stats)
return stats_html
def render_legend(sessions, chunk_html):
"""The card that introduces the readers and the needle chips to a first-time author."""
count_word = {1: "One reader", 2: "Two readers", 3: "Three readers", 4: "Four readers"}.get(
len(sessions), f"{len(sessions)} readers")
legend_items = "".join(
f'<span class="litem"><span class="who w{s["key"]}">{esc(s["initial"])}</span>{esc(s["title"])}</span>'
for s in sessions)
any_skim = any('class="skimtag"' in c for c in chunk_html)
skim_item = ('<span class="litem"><span class="skimtag">skim</span>started skimming here</span>'
if any_skim else "")
legend_html = ('<div class="legend"><div class="lrow"><span class="flabel">about the margin notes</span> '
f'{count_word} met this piece one passage at a time, no peeking ahead, and wrote down each '
'reaction as it happened. Those raw notes sit beside each passage.</div>'
f'<div class="lrow">{legend_items}</div>'
'<div class="lrow"><span class="flabel">each note starts with how it felt</span>'
'<span class="litem"><span class="chip n2">+2</span>leaning in</span>'
'<span class="litem"><span class="chip n0">0</span>neutral</span>'
'<span class="litem"><span class="chip nm2">-2</span>wanted out</span>'
f'{skim_item}</div></div>')
return legend_html
def lens_controls(ann):
"""Lens buttons for the data the annotations actually carry, and the label that names them."""
have_mem = bool(ann.get("memory_phrases"))
have_trust = bool(ann.get("portable_sentences") or ann.get("entities"))
views_label = "see it: as a skimmer" + (" · what stuck" if have_mem else "") + \
(" · trust signals" if have_trust else "") + " · replay the read"
lens_btns = ['<button class="btn" id="lensRead" aria-pressed="true" onclick="lens(\'read\')">reading</button>',
'<button class="btn" id="lensScan" aria-pressed="false" onclick="lens(\'scan\')">what a skimmer sees</button>']
if have_mem:
lens_btns.append('<button class="btn" id="lensMem" aria-pressed="false" onclick="lens(\'mem\')">what stuck</button>')
if have_trust:
lens_btns.append('<button class="btn" id="lensTrust" aria-pressed="false" onclick="lens(\'trust\')">trust</button>')
return lens_btns, views_label
def build(run_dir, out_path, ann):
sessions = load_sessions(run_dir)
chunks = sessions[0]["state"]["chunks"]
n = len(chunks)
readers_meta = ann.get("readers", {})
used_initials = set()
for ix, s in enumerate(sessions):
meta = readers_meta.get(s["dir"], {})
persona = s["state"].get("persona") or s["dir"]
initial = meta.get("initial") or persona[:1].upper()
while initial in used_initials:
initial += "2"
used_initials.add(initial)
s["initial"] = initial
s["label"] = meta.get("label") or persona
s["title"] = meta.get("title") or persona
s["key"] = f"r{ix}"
s["quit_at"] = s["state"].get("quit_at")
per = {}
for e in s["entries"]:
c = e["chunk"]
exc = ann.get("excerpts", {}).get(s["dir"], {}).get(str(c)) or auto_excerpt(e["entry"])
skim = bool(re.search(r"\bskim", e["entry"], re.I)) and not \
re.search(r"(stop(ped)?|quit|no longer|slowed|without)[^.]{0,20}skimm", e["entry"], re.I)
per[c] = {"needle": parse_needle(e["entry"]),
"skim": skim,
"quit": e.get("quit", False), "text": exc}
s["per"] = per
marker = Marker(ann.get("memory_phrases", []), ann.get("portable_sentences", []),
ann.get("entities", []))
flags = {int(k): v for k, v in ann.get("flags", {}).items()}
chunk_html = []
for idx, ch in enumerate(chunks, 1):
body = md_block(ch, marker)
skim_attrs = "".join(f' data-skim-{s["key"]}="1"' for s in sessions if s["per"].get(idx, {}).get("skim"))
unseen_attrs = "".join(f' data-unseen-{s["key"]}="1"' for s in sessions
if s["quit_at"] and idx > s["quit_at"])
flag = f'<div class="flag">{esc(flags[idx])}</div>' if idx in flags else ""
notes = []
for s in sessions:
e = s["per"].get(idx)
if not e:
continue
final = (not s["quit_at"]) and idx == max(s["per"])
quit_here = s["quit_at"] == idx
tag = ('<span class="skimtag">skimmed</span>' if e["skim"] else "")
head = ('<span class="skimtag stop">stopped reading here</span>' if quit_here else tag)
notes.append(
f'<div class="note note-{s["key"]}{" final" if (final or quit_here) else ""}">'
f'<span class="who w{s["key"]}" title="{esc(s["title"])}">{esc(s["initial"])}</span>'
f'{needle_chip(e["needle"])}{head}<span class="ntext">{esc(e["text"])}</span></div>')
folds = "".join(
f'<div class="fold">{esc(s["initial"])} stopped here. They never saw anything below this line.</div>'
for s in sessions if s["quit_at"] == idx)
chunk_html.append(f"""
<section class="chunk" id="c{idx}" data-i="{idx}"{skim_attrs}{unseen_attrs}>
<div class="prose">{flag}<div class="cnum">passage {idx:02d} of {n}</div>{body}</div>
<div class="notes" role="group" aria-label="notes on passage {idx}">{''.join(notes)}</div>
</section>{folds}""")
cells = []
for idx in range(1, n + 1):
fl = f'<i class="fmark" title="{esc(flags[idx])}"></i>' if idx in flags else ""
rows, said = [], []
for s in sessions:
e = s["per"].get(idx)
v = "x" if e is None else e["needle"]
rows.append(f'<i class="tr t{s["key"]} v{v}"></i>')
said.append(f'{s["initial"]} ' + ("did not see it" if e is None else f'{"+" if e["needle"] > 0 else ""}{e["needle"]}'))
label = f'passage {idx}: ' + ", ".join(said) + (f' ({flags[idx]})' if idx in flags else "")
cells.append(f'<button class="cell" onclick="jump({idx})" aria-label="{esc(label)}">{"".join(rows)}{fl}</button>')
sel_btns = ['<button class="btn" id="selall" aria-pressed="true" onclick="sel(\'all\')">both</button>'
if len(sessions) > 1 else ""]
for s in sessions:
sel_btns.append(f'<button class="btn" id="sel{s["key"]}" aria-pressed="false" '
f'onclick="sel(\'{s["key"]}\')">{esc(s["label"])}</button>')
sel_css = "\n".join(
f'body.sel-{s["key"]} .note:not(.note-{s["key"]}) {{ display:none }}\n'
f'body.sel-{s["key"]} .chunk[data-skim-{s["key"]}="1"] .prose {{ opacity:.38 }}\n'
f'body.sel-{s["key"]} .chunk[data-unseen-{s["key"]}="1"] .prose {{ opacity:.12 }}\n'
f'body.sel-{s["key"]} .tr:not(.t{s["key"]}) {{ opacity:.15 }}'
for s in sessions)
palette = [("var(--n1)", "var(--on-n1)"), ("var(--ink-soft)", "#fff"),
("var(--nm1)", "var(--on-nm1)"), ("var(--n2)", "var(--on-n2)")]
who_css = "\n".join(f'.w{s["key"]} {{ background:{palette[ix % len(palette)][0]}; color:{palette[ix % len(palette)][1]} }}'
for ix, s in enumerate(sessions))
lens_btns, views_label = lens_controls(ann)
skim_html = render_skim(ann.get("skim"))
ask_html = render_ask_hint(sessions)
stats_html = render_stats(sessions, n)
verdict_lead = ann.get("verdict_lead", "The readers are done. Their notes are in the margin.")
verdict_rest = ann.get("verdict_rest", "")
previous = ann.get("previous", {}).get("summary", "")
prev_html = f'<p class="prev"><b>Since last time:</b> {esc(previous)}</p>' if previous else ""
prev_run = ann.get("previous", {}).get("run")
prevscrub_html = prev_strip(prev_run) if prev_run and Path(prev_run).is_dir() else ""
opinions = ann.get("opinions", [])
opinions_html = ""
if opinions:
lis = "".join(f"<li>{esc(o)}</li>" for o in opinions)
opinions_html = (f'<details class="envelope"><summary>I\'d push back on {len(opinions)} '
f'spot{"s" if len(opinions) > 1 else ""}. Ask me and I\'ll make the case.</summary><ul>{lis}</ul>'
'<p style="font-size:.85rem;color:var(--mute)">These stay opinions; the rewrite stays yours.</p></details>')
reader_intro = " ".join(
f'<span class="who w{s["key"]}">{esc(s["initial"])}</span>{esc(s["title"])}.' for s in sessions)
legend_html = render_legend(sessions, chunk_html)
tpl = (Path(__file__).parent / "room_template.html").read_text(encoding="utf-8")
heading = next((ln.lstrip("# ").strip() for ln in chunks[0].splitlines()
if re.match(r"#{1,6}\s", ln)), None)
default_title = f"First read: {heading}" if heading else "First read of your draft"
page = (tpl.replace("@@TITLE@@", esc(ann.get("title", default_title)))
.replace("@@VERSION@@", VERSION)
.replace("@@STATS@@", stats_html)
.replace("@@VERDICT_LEAD@@", esc(verdict_lead))
.replace("@@VERDICT_REST@@", esc(verdict_rest))
.replace("@@PREVIOUS@@", prev_html)
.replace("@@PREVSCRUB@@", prevscrub_html)
.replace("@@N@@", str(n))
.replace("@@READER_INTRO@@", reader_intro)
.replace("@@LEGEND@@", legend_html)
.replace("@@SEL_BTNS@@", "".join(sel_btns))
.replace("@@LENS_BTNS@@", "".join(lens_btns))
.replace("@@VIEWS_LABEL@@", views_label)
.replace("@@SEL_CSS@@", sel_css)
.replace("@@WHO_CSS@@", who_css)
.replace("@@SCRUB@@", "".join(cells))
.replace("@@CHUNKS@@", "".join(chunk_html))
.replace("@@SKIM@@", skim_html)
.replace("@@ASKHINT@@", ask_html)
.replace("@@OPINIONS@@", opinions_html))
out_path.write_text(page, encoding="utf-8")
print(f"wrote {out_path} ({len(page)} bytes, {n} passages, {len(sessions)} readers)")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("run_dir", type=Path)
ap.add_argument("--out", type=Path, default=None)
ap.add_argument("--annotations", type=Path, default=None)
a = ap.parse_args()
ann = {}
ann_path = a.annotations or (a.run_dir / "room-annotations.json")
if ann_path.exists():
ann = json.loads(ann_path.read_text())
build(a.run_dir, a.out or (a.run_dir / "room.html"), ann)
if __name__ == "__main__":
main()
scripts/signals.py
#!/usr/bin/env python3
"""Measure the trust signals a draft pays, and what they cost the writer.
Readers judge credibility by costly signals: details that could be checked,
numbers that could be wrong, commitments that expose the writer. Free
signals (generic praise, safe hedges, uniform confidence) are worthless
precisely because they cost nothing. The accurate human detectors of AI
text (Russell et al. 2025) key on this layer, not on banned words.
These are measurements, not verdicts. A poem scores zero specifics and is
not worse for it; a launch post scoring zero specifics is a finding. The
skill decides what a number means for this genre; this script only counts.
Usage: python3 signals.py <draft> [--json]
"""
import json
import re
import statistics
import sys
from pathlib import Path
HEDGES = r"\b(might|may|could|perhaps|possibly|arguably|potentially|seems?|somewhat|likely|probably|tend(?:s)? to|in some cases|to some extent|it could be argued)\b"
CERTAINTY = r"\b(definitely|certainly|always|never|must|undeniably|clearly|obviously|of course|without a doubt)\b"
FIRST_PERSON = r"\b(I|I'm|I've|I'd|my|me|we|our)\b"
SECOND_PERSON = r"\b(you|your|you're|you've)\b"
ADMISSION = r"\b(I was wrong|we were wrong|I don't know|I'm not sure|didn't work|failed|my mistake|I missed|we missed|turned out I|I had to throw|embarrassing|uncomfortable|I still can't|was a fantasy|took us .{0,20} to notice|we broke|our fault)\b"
def clean(text):
text = re.sub(r"```.*?```", " ", text, flags=re.S)
return re.sub(r"^#{1,6}\s.*$", " ", text, flags=re.M)
def sentences(text):
parts = re.split(r"(?<=[.!?])\s+", clean(text).replace("\n", " "))
return [s.strip() for s in parts if len(s.split()) >= 3]
def proper_nouns(text):
# capitalized tokens not at sentence start: rough named-entity proxy
hits = re.findall(r"(?<![.!?]\s)(?<!^)(?<!\n)\b([A-Z][a-zA-Z]+(?:\s[A-Z][a-zA-Z]+)*)\b", text)
stop = {"I", "The", "A", "An", "But", "And", "It", "This", "That", "If", "So"}
return [h for h in hits if h not in stop]
def main():
argv = sys.argv[1:]
as_json = "--json" in argv
argv = [a for a in argv if a != "--json"]
if len(argv) != 1:
sys.exit(__doc__)
text = Path(argv[0]).read_text(encoding="utf-8")
sents = sentences(text)
if not sents:
sys.exit("No prose sentences found.")
lens = [len(s.split()) for s in sents]
words = sum(lens)
per100 = lambda n: round(n * 100 / max(words, 1), 2)
# any token carrying a digit counts as a checkable specific: 4,100, $3M,
# 90-second, v2.3, 48MB, 2:47am all expose the writer to being wrong
numbers = re.findall(r"\b[\w$][\w.,:%$-]*\d[\w.,:%$-]*", clean(text))
quotes = re.findall(r"[\"“]([^\"”]{10,200})[\"”]", text)
hedged = [s for s in sents if re.search(HEDGES, s, re.I)]
certain = [s for s in sents if re.search(CERTAINTY, s, re.I)]
admissions = [s for s in sents if re.search(ADMISSION, s, re.I)]
nouns = proper_nouns(clean(text))
tokens = re.findall(r"[a-zA-Z']+", text.lower())
ttr = round(len(set(tokens)) / max(len(tokens), 1), 3)
# portable sentences: no digit, no named entity, no first person: could
# move unchanged into any other document on the topic
portable = [s for s in sents
if not re.search(r"\d", s)
and not re.search(FIRST_PERSON, s)
and not any(n in s for n in set(nouns))]
# epistemic temperature: share of sentences carrying ANY marker of how
# sure the writer is; near-zero variance in commitment is the machine tell
marked = len(set(hedged) | set(certain))
result = {
"words": words, "sentences": len(sents),
"sentence_len": {"mean": round(statistics.mean(lens), 1),
"sd": round(statistics.pstdev(lens), 1),
"burstiness": round(statistics.pstdev(lens) / max(statistics.mean(lens), 1), 2),
"max": max(lens), "min": min(lens)},
"costly": {
"numbers": {"count": len(numbers), "per100w": per100(len(numbers)), "sample": numbers[:10]},
"named_entities": {"count": len(nouns), "per100w": per100(len(nouns)), "sample": list(dict.fromkeys(nouns))[:10]},
"direct_quotes": len(quotes),
"admissions_against_interest": {"count": len(admissions), "sample": admissions[:3]},
"first_person_sentences": sum(1 for s in sents if re.search(FIRST_PERSON, s)),
},
"free": {
"hedged_sentences": {"count": len(hedged), "share": round(len(hedged) / len(sents), 2), "sample": hedged[:3]},
"certainty_sentences": {"count": len(certain), "share": round(len(certain) / len(sents), 2)},
"epistemically_marked_share": round(marked / len(sents), 2),
},
"portable_sentences": {"count": len(portable), "share": round(len(portable) / len(sents), 2),
"sample": portable[:5]},
"type_token_ratio": ttr,
}
if as_json:
print(json.dumps(result, indent=2))
return
print("TRUST SIGNAL MEASUREMENTS (counts, not verdicts; genre decides meaning)")
print(json.dumps(result, indent=2))
print("\nReading notes: costly signals expose the writer and buy trust; "
"portable sentences fit any document and buy nothing. A piece where "
"most sentences are portable and unmarked is committing to nothing.")
if __name__ == "__main__":
main()
scripts/skim.py
#!/usr/bin/env python3
"""Produce the scanner's view of a draft: what a skimming reader actually
fixates on, and nothing else.
Eyetracking work (Nielsen Norman Group) shows scanners fixate on headings,
the first words of lines and paragraphs, bold spans, numbers, and links,
and skip nearly everything else. This prints exactly that view so a fresh
reader agent can answer the only question a scanner answers: what is this,
and do I commit? Feeding the full text would break the experiment, so this
script deliberately truncates.
Usage: python3 skim.py <draft.md|draft.txt>
"""
import re
import sys
from pathlib import Path
WPM = 238 # average adult silent reading speed (Brysbaert 2019 meta-analysis)
MOBILE_CHARS_PER_LINE = 38
MOBILE_LINES_PER_SCREEN = 14
FIRST_WORDS = 9
def paragraphs(text):
return [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
def first_sentence(p):
m = re.match(r".+?[.!?](?=\s|$)", p, re.S)
return (m.group(0) if m else p).replace("\n", " ")
def first_words(p, n=FIRST_WORDS):
words = p.replace("\n", " ").split()
if len(words) <= n:
return " ".join(words)
return " ".join(words[:n]) + " ..."
def main():
if len(sys.argv) != 2:
sys.exit(__doc__)
text = Path(sys.argv[1]).read_text(encoding="utf-8")
paras = paragraphs(text)
words = len(text.split())
bold = re.findall(r"\*\*(.+?)\*\*|__(.+?)__", text)
bold = [a or b for a, b in bold]
links = re.findall(r"\[([^\]]+)\]\(", text)
numbers = re.findall(
r"(?<![\w./-])(?:\$[\d,.]+[kKmMbB]?|\d+(?:[.,]\d+)*%?|\d{4})(?![\w/-])", text
)
screens = max(1, round(len(text) / (MOBILE_CHARS_PER_LINE * MOBILE_LINES_PER_SCREEN)))
print("SCANNER VIEW (what a skimming reader fixates on; the full text is withheld)")
print(f"stats: {words} words | ~{max(1, round(words / WPM))} min full read | ~{screens} phone screens")
print(f"fixation tokens: numbers={numbers[:20] if numbers else 'NONE'}")
if bold:
print(f"bold spans: {[b[:60] for b in bold[:12]]}")
if links:
print(f"link texts: {links[:12]}")
print("-" * 60)
body_count = 0
for p in paras:
if re.match(r"#{1,6}\s", p):
print(p.splitlines()[0])
continue
if p.startswith(("- ", "* ", "1.", "> ")):
for line in p.splitlines()[:6]:
print(" " + first_words(line.lstrip("->*1234567890. ")))
continue
body_count += 1
if body_count <= 2:
# A scanner does genuinely read the opening lines in full.
print(first_sentence(p))
else:
print(first_words(p))
print("-" * 60)
print(
"Question for the reader: from this view alone, say (1) what you think "
"this piece is and what it will argue, (2) whether your cast persona "
"commits to a full read or moves on, and (3) the single element above "
"that decided it."
)
if __name__ == "__main__":
main()
SKILL.md
---
name: first-reader
description: Beta readers for any draft, run by simulating how a real reader experiences it, moment by moment. A skim gate, a no-lookahead timed read producing an attention transcript with quit points, a recall test of what a reader remembers the next day, and a trust ledger of the implied author. Use when the user asks for a beta read, beta readers, test readers, human review, reader review, to read something like a human, to be a first reader, whether a piece holds attention or will actually get read, for a quick read of whether a busy skimmer would even open a post, what readers will remember or take away tomorrow, where readers stop reading or bounce, or why a draft still feels off or hollow after anti-slop or humanizer edits. Final gate before publishing; reports where the reading broke; never rewrites.
compatibility: Python 3 stdlib only, offline, no dependencies. feed.py runs a loopback http server on 127.0.0.1 during a read so reader subagents receive the draft one passage at a time; nothing leaves the machine and there is no external network access.
---
# first-reader
Readers before you publish.
Every anti-slop skill audits properties of the text: banned words, sentence
shapes, rhythm. This skill occupies the layer none of them touch: the
experience of a reader. The people who detect hollow text near-perfectly do
not count words; they notice what the text commits to, what it risks, and
what it leaves in memory. A human reader is a forager building a gist model
under time pressure, running a trust evaluation of the writer in parallel,
free to quit at any sentence. This skill reproduces that reader and reports
what happened to them.
It produces a reading, not an audit. Run it as the final gate before
publishing.
## The user contract (read this first, it overrides everything below)
To the user, this skill is a beta-reading session: a few readers with
lives met their draft cold and can be consulted afterwards. Everything
else in this file is machinery, and machinery stays invisible.
- **"review this"** (or "be my first reader") starts a run. Reply with
one line ("Reading it as your audience would. Back in about 5
minutes."), then work silently. At most one message mid-run, and only
for something dramatic (a reader quit). No cast announcements, no step
narration, no instrument names.
- **The result is what a friend says after reading, plus one page.**
Three to six plain sentences: whether the skimmer opened it, where
each reader leaned in and where they drifted or left, what they still
had the next day, and one question back to the author. Quote the
readers' own notes, by their initial, and the draft's own words. Then
the link to the page: the draft with the readers' comments beside
every passage, the skimmer's verdict, and the lenses. No numbered
fixes, no suggestions, no revised copy. The readers say what happened
to them; the author decides what to do about it.
- **The readers can be consulted.** "ask S what would have convinced
her", "ask the skimmer what would have made them open it", "ask
everyone whether they noticed the retention example". Run
`scripts/ask.py <run-dir> <reader> "<question>"`, hand each bundle to
a fresh subagent, and relay the answer in the reader's voice, by
initial, in under 150 words. Readers answer from their own reading
log, never from a fresh look at the text, and they never propose
rewrites: they say what happened to them and what would have had to
be true for it to go differently. If the log cannot support an
answer, the reader says so.
- **"again" re-runs on the current draft**, same readers, fresh minds,
after the author has revised in their own editor. Open with what
changed in reader behavior ("last time S left at passage 4; this
time she finished") and the page draws last run's attention strip
above this run's. Wherever the draft lives now is fine.
- **"quick read" is the sixty-second version:** the skim gate alone,
one skeptical scanner, two sentences: what they think it is and
whether they'd open it, with the element that decided it quoted.
- **A real reader's comments are evidence.** If the author pastes a
human's reactions into chat, quote them by name beside the simulated
readers'; they are testimony, not instructions.
- **A hollow verdict ends in an interview offer.** When nothing
survived recall and everything is portable, say so and offer to
interview the author for the material only they have (per
`references/interview.md`). Never write their experience for them.
- **Paste is fine.** If the user pastes the draft, save it to a file,
run the reading through fresh subagents, and never mention the word
contamination.
- Ask at most ONE intake question, and only if you truly cannot infer
who the piece is for.
## Why the discipline matters
You see whole documents at once, forget nothing, and never get bored. A
human reader has none of those powers, and pretending to read while
holding the full text is performance, not measurement. So:
**Do not open the draft.** From the moment this skill is invoked until the
timed reads are complete, never read the draft file or let the user paste
it. Only the scripts touch it. If the draft is already in your context
(the user pasted it earlier), you are contaminated as a reader: run every
reading step through fresh subagents and say so in the report. Your own
full-text read happens once, at step 6, after all reader experiments are
done.
All scripts are stdlib Python, offline, no dependencies:
`python3 scripts/<name>.py`. Keep every run's files in a `.first-reader/`
directory next to the draft, never in a session scratch directory:
scratch gets wiped between sessions, and "again" in a tomorrow session
needs the prior run to compare against. If the draft lives in a git
repo, mention once that `.first-reader/` is worth gitignoring.
## Step 0: Scope and genre
First decide whether a timed read even models this text's real encounter:
- **Prose meant to be read in order** (essay, article, post, memo, README
narrative, landing copy, cover letter, talk script): full workflow.
- **Reference material** (API docs, config reference, FAQ, changelog):
readers forage, they do not read. Skip the timed read. Run the skim gate
as the main event with lookup tasks: cast a reader with a question and
test whether the scanner view routes them to the answer. Skimmability is
the success condition here, not a defect.
- **Under ~150 words** (tweet, bio, announcement): no chunked feed. One
fresh subagent gets the text cold with a persona and a two-second frame:
first felt reaction, would they stop scrolling, what they'd retell.
Then the trust ledger. Skip recall.
- **Fiction and poetry**: the transcript and recall work; the trust ledger
and specifics-density readings do not apply as written. Needle and
memory only, and say so.
- **Not this skill**: code review, factual verification, copyediting,
rewriting. Decline and point at the right tool.
Work silently per the user contract: one line at the start, at most one
mid-run message for a dramatic event, then the result. In anything the
user does see, plain words only.
## Step 1: Cast the reader
Read `references/personas.md`. If the audience or venue is unknown, ask
the user one question: who is this for and where will it be published?
Then cast two personas, sympathetic and skeptical, each with priors,
situation, patience budget from the table, and a one-sentence stake. If
you cannot write the stake sentence, report that first.
Also ask, if not obvious: what should the reader think, feel, or do after
reading? That is the intended gist the recall test will be judged against.
Fold the audience into the one starting line only when it needs
confirming ("Reading it as engineers on HN would"); otherwise just
start. Before casting, look for `.first-reader/audience.json` beside the
draft (format in `references/room.md`): it holds the readers this
author already chose, with their initials and one-line lives, so the
same two people read every draft in the project and "S" means the same
person next month. Reuse it unless the user names a different audience;
write it after the first cast. Write `manifest.json` in the run
directory per `references/room.md` for your own use. On a repeat review, reuse the
prior manifest's casts and point `previous_run` at the old run so the
results can be compared honestly.
## Step 2: The skim gate
Have a fresh subagent playing the skeptical persona run
`scripts/skim.py <draft>` itself and answer from that view alone; if you
must run the script, redirect its output straight to a file you never
open, because the scanner view quotes fragments of the draft and reading
it contaminates you before the timed reads. It answers: what is this
piece, do you commit to a full read, and what single element decided it.
A scanner who cannot say what the piece is, or declines to commit, is the
first finding. For most real readers this gate is the whole encounter.
## Step 3: The timed reads
No lookahead is enforced by mechanism, not by asking nicely. Start the
feed in the background BEFORE dispatching any reader:
```
python3 scripts/feed.py serve <draft> --run <run-dir> --readers keen,skeptic \
--persona "keen=<one line>" --persona "skeptic=<one line>" \
--ready-file <run-dir>/feed.json
```
The text now lives only in that process's memory. `feed.json` holds
addresses, nothing else; read it and give each reader ONLY its own
line. A reader's dispatch prompt contains the persona, `export
READER_FEED=<reader-address>`, and three commands; it never
contains the draft path, the run directory, or the other reader's
address. The reader runs `feed.py start`, then reads one passage at a
time, logging honestly at each step with
`feed.py next --log "needle=<-2..+2> expected... got... <felt notes>"`.
The needle is the felt reaction: +2 leaning in, 0 neutral, -1 drifting
or doubting, -2 done. Log skimming the moment it starts. When the
persona's patience runs out, quit with
`feed.py quit --log "needle=-2 <why the persona stopped>"`, because
quitting is the single most informative thing a reader does. A reader
who finishes still records one final line the same way; the transcript
labels it FINAL rather than QUIT.
The feed releases the next passage only after a real log lands, never
re-serves anything but the current passage, refuses to advance faster
than a person could read the passage, and writes nothing of the piece
to disk until every reader is finished (`feed.py progress --admin
<admin-address>` shows where they are; `feed.py close --admin ...`
flushes early if a reader dies). The reader subagent returns nothing
of substance; the artifact is the session. Collect it with
`feed.py transcript <run-dir>/<reader>` after close.
If subagents are unavailable, use file mode (`feed.py start <draft>
--session <dir>`) yourself under the same rules, before ever opening
the draft, and disclose that one mind played both reader and reviewer
(see Adapting to your environment).
## Step 4: The recall test
Run `scripts/recall.py <session-dir>` for the sympathetic persona's
session (and the skeptic's if it finished). Hand the output to a fresh
subagent with no other context. It answers the quiz from the transcript
alone: sayback, pointing, peak, ending, center of gravity, one action.
Judge the answers against the intended gist from step 1. A piece whose
one-sentence retelling does not match its thesis, or whose ending nobody
remembers, has a finding no line edit can fix. "Nothing survived" is a
finding about the piece, not about the reader.
## Step 5: The trust ledger
Run `scripts/signals.py <draft> --json`, but only after the timed reads
are complete: its output quotes draft sentences, so reading it earlier
contaminates you. Read the numbers through the genre: costly signals (checkable numbers, named entities, quotes,
admissions against interest, first-person experience) buy trust; free
signals (hedges everywhere, certainty everywhere, portable sentences that
fit any document) buy nothing. Near-zero variance in epistemic commitment
reads as machine confidence. The counters are floors, not truth: the
admissions detector in particular under-counts (it pattern-matches stock
phrasings and misses lines like "our test data was a fantasy"), so
confirm costly signals in your own step 6 read rather than trusting a
zero.
## Step 6: Your own read, then the report
Only now read the draft in full, once, for two judgments the scripts
cannot make: the implied author (describe the person these sentences
imply, and quote any seam where that person changes mid-document) and
whether the transcripts' complaints are the piece's fault or the cast's.
Write the report exactly as `references/report.md` specifies: sayback and
meaning first, then the reading transcripts, what survived, the person
behind it, then neutral questions and opinions by permission. Testimony
register throughout, no scores, altitude rule enforced, and the report is
allowed to be happy.
## Step 7: The page, and the readers stay available
Save the skim gate's answer as `<run-dir>/skim.txt` and put its verdict
in the annotations (`skim`). Build the page per `references/room.md`
(`scripts/room.py`): the draft with the readers' comments beside each
passage, the skimmer's verdict, the lenses, the strip; no fixes. Publish
it where you can, or write it beside the run and open it. Deliver per
the user contract: the friend's report, then the link. Keep the
Lerman-order report on disk beside the run for anyone who asks.
The readers persist in the run directory. Every later "ask" in the
session goes through `scripts/ask.py` and a fresh subagent per reader;
never answer for a reader yourself, and never let a reader see the
draft again: their memory is their log.
## Adapting to your environment
This skill runs in any SKILL.md-compatible coding agent, but three
capabilities vary. Check what you have BEFORE the run starts, adapt
silently, and tell the user only what changes for them, in one line.
**Subagents.** If you can spawn fresh agents (Claude Code's agent tool,
or any delegation mechanism), each reader is one. If you cannot, you
play the readers yourself, one persona at a time, and the order of
operations becomes everything: do NOT read or open the draft file for
any reason before the reads; start immediately with the feed script and
meet the piece one passage at a time in persona (file mode; served
mode is pointless when reader and reviewer share a mind); do the skeptic's read
in a later, separate pass from the sympathetic one; run recall by
answering the quiz from the transcript alone before ever seeing the
full text. This is a weaker experiment than fresh minds, so say so once
in the verdict ("I played both readers myself here, so treat the
findings as slightly softer") and never pretend otherwise. If the draft
is already in your context (you read it earlier in the session, it was
pasted, or your harness auto-loaded it), solo mode cannot blind you; do
the reads anyway as honest role-passes, and weight the mechanical
signals (quit logic, recall-from-log, signals.py) more heavily than the
needle.
**A place for the page.** With artifact publishing (Claude Code on
claude.ai), the page is a private link. Without it, it is a local HTML
file next to the run, opened for the user. The page is read, not
clicked; questions to the readers come to you in chat.
**Turn budget.** In agents where every script call is a visible tool
step, the full run is many steps. Say so up front in the starting line
("this takes a few minutes and a lot of small steps") and never ask
permission step by step; batch where your harness allows.
The first time the skill runs in a session, the starting line also
teaches the interface in passing, because nothing else will: "Reading
it as your audience would, about 5 minutes. When I'm back: where each
reader leaned in or left, what stuck with them, and a page with their
comments beside your draft. You can ask them follow-ups, and 'again'
re-reads after you revise."
## Guardrails
- Never rewrite the draft, in whole or in part, and never hand over a
list of suggested fixes. The readers report what happened to them;
the author owns every decision about the text. If asked for a rewrite,
offer the readers' comments to the user's own editing skill instead.
- Never manufacture findings. Two clean transcripts and a passed recall
test end the review honestly.
- Never blame the reader. If the cast was wrong for the piece, recast and
rerun; if the piece is for experts and the skeptic was a novice, that
was a casting error, not a draft error.
- Word-level slop hunting is out of scope; other skills own it. If the
transcripts keep tripping on the same phrase, report the tripping, not
the phrase's presence on any list.
- The transcripts are evidence. Quote them; do not summarize them into
the abstractions they exist to replace.