Docs review loop (comments + verification)
notabene: a navigable renderer over a repo's docs + a human↔agent review loop. Stateless — the data lives in the consumer repo's git, not in the tool. What follows is the generic, agent-agnostic protocol; three rules override it inside this plugin:
- Not set up here? No
notabene.config.mjsor no.notabene/store → hand off to thenotabene-setupskill (or/notabene:setup) to install/configure and launch, then resume. Don't fail; delegate — ignore the genericnpx … initfallback below. - Every CLI call goes through the plugin forwarder —
node "${CLAUDE_PLUGIN_ROOT}/bin/nb.mjs" <cmd> --root <repo-root>— nevernpx notabene(unscoped: not our package) and never a hardcoded version. This replaces everynpx -y @z29k/notabene@latest …shown below (comments ls,build,lint,journal add). The loop still never depends on the CLI: file tools are enough. - Authoring palette — for what you can put in a page (Mermaid diagrams, GFM tables,
code blocks, inter-doc links) and the MDX-safety rules, use the
notabene-authoringskill rather than the web page linked in Step 4.
Discovery — EVERYTHING comes from the config (nothing hardcoded)
Read notabene.config.mjs at the repo root to learn:
store— comments + journal folder (e.g.docs/.notabene). One file per comment:<store>/<page>/<id>.json(so branches don't conflict on merge). Journal:<store>/journal.json. Schema version:<store>/meta.json({ "schemaVersion": <n> }, currently 3). Older stores keep one array per page (<store>/<page>.json) — both are read;notabene migrateupgrades a store to the current schema (v3, one file per comment).roots[]— the doc spaces:{ key, label, path, exclude }. A comment'spagefield is prefixed by a root'spath(e.g. rootdocs/plans→page: "docs/plans/services/x").i18n(optional,{ locales, defaultLocale, strategy }) — the doc is multi-language and a comment'spagekey is locale-encoded, mapping straight to that language's file:strategy: "directory"→page: "docs/fr/guide/x"= filedocs/fr/guide/x.md;strategy: "suffix"→page: "docs/guide/x.fr"= filedocs/guide/x.fr.md(the default locale is unsuffixed:page: "docs/guide/x"=docs/guide/x.md). Edit that file — a comment belongs to one language; don't touch the other language's file or auto-translate unless asked.verify[]— project-specific checks to run after editing.review—"auto"(default) or"approve". In approve mode you don't resolve comments yourself: you edit, mark themaddressed, and a human validates them (with a diff) at/review. See Step 5.
Assume no path, port or label. Do not require a live server or a port.
Strict rules (no exceptions)
- NEVER commit or run git operations without an explicit request ("continue"/ "go on" ≠ commit). Offer the commit at the end.
- NEVER bulk-delete the store (
rm -rf <store>): those are the user's real comments (precious, committed). To clean a test, delete a single comment byid(edit its page file), never the folder. - Ignore
hold: true("⏸ on hold") andstatus≠open(addressed/resolvedalready handled): only processopenand not on hold. - Account for EVERY comment in the roster (step 1). A pass is not "some of the
comments" — each id ends the pass either handled (
resolved/addressed) or explicitly declined, with the reason posted as athreadreply so the human sees it. Leaving one silently untouched is a failed pass, not a partial success: an untouched comment is indistinguishable from one the human wrote a minute ago, so nothing downstream can flag it — not/review, which only ever shows what you DID, and notcomments verify, for which anopencomment is perfectly legal. You are the only check. A long roster is a reason to work in batches, never a reason to stop early. - MDX-safety (format
"mdx"only): when editing a.mdxfile, don't introduce stray{or<outside code fences (MDX parses them as expression/JSX)..mdfiles (CommonMark/GFM) are lenient — no such constraint. Validated by the renderer build. - File-I/O first: read/write the
<store>/files directly with your file tools. Theastro devserver need NOT be running — the HTTP/api/commentsis only a convenience when the site is already open. Depend on neither a port nor a process.
Step 1 — Read the comments to process
List the actionable set with the CLI (any agent can shell out — no store-parsing to
reimplement, no python3):
npx -y @z29k/notabene@latest comments ls --open --json # open AND not-on-hold, machine-readable
npx -y @z29k/notabene@latest comments ls --open # …or human-readable
That list is the pass's roster. Take it ONCE, whole, and write the ids down — into
your task list, a scratch file, whatever survives the pass. Every later step is measured
against it, and step 6 reconciles with it. Neither the CLI nor the HTTP API paginates,
truncates or caps: one call returns every eligible comment, however many there are (the
only ellipsis anywhere is the 100-character quote preview in the human-readable
listing — use --json and you get the full text). So a short roster means a short store,
never a partial read. Count the ids and state the number before you start — a pass
that never named its own size cannot notice it dropped half of it, which is exactly how
a real store ended up with fifteen comments handled and six untouched, in a run that
reported success.
If the CLI isn't available (offline, no Node, a policy against npx), read the store with
your file tools directly: each <store>/**/*.json (except journal.json/meta.json) is
one comment, or — in older v1 stores — a legacy array of comments; keep those with
status == "open" and hold != true. The loop never depends on the CLI being present.
A comment reopened after a rejection (approve mode) carries the human's reason as
later thread replies — read them and adjust accordingly before editing. (A human
rejects from /review, or with comments reopen <id> --reply "<why>".)
A thread[].author is a plain string that may be git-style Name <email> (the browser
embeds the reviewer's email for a unique identity) — treat the whole string as the author;
split on the trailing <…> only if you need the bare display name.
Step 2 — Locate the source page
page (= data-page) → source file, via roots[]: a page starting with
<root.path>/… maps to a file under <root.path> at the same relative path.
<root.path>/<x>→<root.path>/<x>.mdor.mdx- Index page: if
<x>.{md,mdx}doesn't exist, it's<x>/index.{md,mdx}(the loader stripsindexfrom the id → somedata-pagevalues omit/index). Test both.
Step 3 — Resolve the anchor
anchor.quote is the rendered text (markdown stripped: no **, links as plain
text…). To find it in the source, search tolerantly, using anchor.prefix/suffix
(disambiguating context) and anchor.section (nearest heading). scope: "page" = a
page-wide comment, no anchor.
Block comments (scope: "block", store v3) target a diagram or image, not text —
the anchor is { kind, key, label, section, index } (no quote; index disambiguates
repeated blocks with the same key). kind: "image" → find the
 whose src matches key/label and act on it; kind: "mermaid" → find the
```mermaid fence for that diagram (its source hashes to key; label = the diagram
type + first line) and edit the **diagram source**. anchor.section narrows the search.
Step 4 — Edit the docs (faithfully)
Apply each piece of feedback faithfully at the right spot. A comment is a user decision. If the change touches public behavior documented elsewhere, update it (see project hooks below). For what you can put in a page — Mermaid diagrams (```mermaid), GFM tables, code blocks, inter-doc links — and the MDX-safety rules, see the authoring reference: https://z29k.github.io/notabene/guide/authoring/.
Step 5 — Mark the comment + write the journal
Set the status by review mode (from the config):
auto(default):status = "resolved".approve:status = "addressed"— you propose; the human validates at/review. Do not resolve it yourself.
In both cases set resolution = { note, journalEntryId } and append a
<store>/journal.json entry: { id, date (YYYY-MM-DD), title, summary, changes[] { page, commentIds[], what, why } }. Each resolution's journalEntryId = the journal entry's
id.
Prefer the CLI for this step — it picks the status from review for you, preserves
every other field, and writes atomically:
# 1. journal first: --json echoes { id } so you can chain it
echo '{ "id": "j-2026-07-28", "date": "2026-07-28", "title": "…", "summary": "…",
"changes": [{ "page": "docs/guide/x", "commentIds": ["c1"], "what": "…", "why": "…" }] }' \
| npx -y @z29k/notabene@latest journal add --json
# 2. then the comments it covers (status = resolved | addressed, per the config)
npx -y @z29k/notabene@latest comments done c1 c2 --note "…" --journal j-2026-07-28
Editing the JSON by hand is still valid (journal.json: 2-space indent + trailing
newline) — just never lose a field, and never write resolved in approve mode.
Cascade (load-bearing for the review UI): if fixing a comment touched several
pages (a cross-ref, behavior documented elsewhere), emit one changes[] entry per
page actually touched, each listing that commentId. The reviewer's diff is built by
inverting the journal — a page you don't record there won't be shown.
Step 6 — Verify
- ALWAYS: build the renderer — a broken doc file breaks the tool itself
(
npx -y @z29k/notabene@latest build, or the project's renderer build). - Lint the inter-doc links —
npx -y @z29k/notabene@latest lint. It validates every relative.mdlink against the routes the build just emitted (with did-you-mean suggestions;--jsonfor machine reading). A broken link is a failed verification — fix it before reporting. If it exits 2, the build of step 1 didn't run — never skip it. - Audit the store you just wrote —
npx -y @z29k/notabene@latest comments verify. It checks statuses, the comment↔journal links in both directions, the file layout and dangling pages. The one to care about: a comment whose journal entry doesn't list it back inchanges[]makes/reviewshow the human an empty diff. Exit 1 = fix it before reporting. - Reconcile the roster — re-run
comments ls --open --jsonand subtract: no id from step 1's roster may still be there. Any that is was silently dropped — go back and handle it, or decline it with a reply; it is a defect to fix, not a result to report. Do not simply check that the list is empty: a comment written during your pass is legitimately open and must be left alone, which is precisely why the comparison is against the roster and not against zero. config.verify[]— the project's own checks (build/lint/memory update).- Project memory — if the project keeps a memory doc (
CLAUDE.md/AGENTS.md), update it for any public-behavior change.
Steps 5–6 are the project extension point. The core loop is generic; a consumer declares its post-edit steps via
verify[]and its memory conventions. The core does not know any specific project.
Step 7 — Report (without committing)
Open with the arithmetic of the pass: N eligible → N handled + N declined, and make
the three numbers add up. Say it even when nothing was skipped — a report that cannot
be wrong about its own coverage is what turns "I think it's done" into something the
human can check at a glance. If a comment was declined, name it and say why.
Then summarize as a table: per comment → the change made (section) + the why. Point to
/journal (and, in approve mode, to /review — the human validates each edit
against its diff there, then approves → resolved or rejects → reopened). Then ask
whether to commit, and what (doc edits only / + resolved store + journal / + project
artifacts). Wait for an explicit go-ahead.