commit f28e7a02191214c4287a49576397b5b702cdc604
parent eea3f3327802f839df1c30f382026fb74ef69a63
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 8 Oct 2026 22:33:42 -0400
docs: operator notes (umtool/docs/notes.md, AGENTS.md, REPORT.md)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 133 insertions(+), 2 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -140,6 +140,21 @@ better), whose clock is not the same.
This tooling is on `main` as of 2026-08-20.
+# Operator notes on articles and videos
+
+```sh
+umtool notes --all --open # from the checkout: node umtool/bin/umtool.mjs notes --all --open
+```
+
+The operator leaves notes in umtool on articles (`/sites/<site>/<report>`) and on
+report-video projects (rows, takes, moments in a cut). They live in a `notes.json`
+beside the report's `report.json` (never published) or beside the project's
+`video.manifest.json`. `umtool notes <site>/<report>` prints each open note with its
+anchor resolved and the **source** file to edit — a draft, not the generated
+`report.json` or manifest. Act, regenerate, then `umtool notes reply <id> "<what
+changed>" --resolve`. Never hand-edit `notes.json`. See
+[umtool/docs/notes.md](umtool/docs/notes.md).
+
# The runtime container
`docker compose up -d` stands up a working archive: the editor plus Caddy, with
diff --git a/REPORT.md b/REPORT.md
@@ -2,7 +2,7 @@
<!-- GENERATED by common/bin/file-schemas-docs.ts from the schemas and their *_FIELD_DOCS records — do not edit by hand. -->
-One cited report, format `"archilyzer-report"`, version 1, persisted to `transcripts/sites/<siteId>/reports/<reportId>/report.json` beside its `stills/` and `sources/<sourceId>/`; a relative path in it is relative to that directory. A site's `reports` list in `site.json` is the published, ordered list — see [SITE.md](SITE.md); a report directory it does not name is a draft. The schema is `common/lib/report/schema.ts`; its citations and sources are the citation model's — see [CITATIONS.md](CITATIONS.md).
+One cited report, format `"archilyzer-report"`, version 1, persisted to `transcripts/sites/<siteId>/reports/<reportId>/report.json` beside its `stills/` and `sources/<sourceId>/`; a relative path in it is relative to that directory. A site's `reports` list in `site.json` is the published, ordered list — see [SITE.md](SITE.md); a report directory it does not name is a draft. The schema is `common/lib/report/schema.ts`; its citations and sources are the citation model's — see [CITATIONS.md](CITATIONS.md). A `notes.json` beside it holds the operator's notes on the article (umtool, `umtool notes`; [umtool/docs/notes.md](umtool/docs/notes.md)) and is never published.
A **fact-check** (`"kind": "factcheck"`) is sections (chapters) of claims, each with a verdict and its findings. A **sweep** (`"kind": "sweep"`) is sections with no verdicts, or bodies that cite inline. Markdown fields (`summary`, a section's `body`, a claim's `findings`) cite with `[label](cite:<id>)`.
diff --git a/common/lib/report/docs.ts b/common/lib/report/docs.ts
@@ -32,7 +32,9 @@ export function renderReportMarkdown(): string {
"that directory. A site's `reports` list in `site.json` is the published, " +
"ordered list — see [SITE.md](SITE.md); a report directory it does not name is a " +
"draft. The schema is `common/lib/report/schema.ts`; its citations and sources " +
- "are the citation model's — see [CITATIONS.md](CITATIONS.md).",
+ "are the citation model's — see [CITATIONS.md](CITATIONS.md). A `notes.json` " +
+ "beside it holds the operator's notes on the article (umtool, `umtool notes`; " +
+ "[umtool/docs/notes.md](umtool/docs/notes.md)) and is never published.",
);
out.push("");
out.push(
diff --git a/umtool/docs/README.md b/umtool/docs/README.md
@@ -93,6 +93,8 @@ defects that have already shipped in real videos: a manifest with no `siteOrigin
| [mix-from-a-project.md](mix-from-a-project.md) | Deep-linking a clip into `/mix` |
| [index.md](index.md) | The LMDB index, and why the filesystem stays the model |
| [cli.md](cli.md) | `umtool ls / show / check / build / window / …` |
+| [notes.md](notes.md) | Operator notes on articles and videos; `umtool notes`, the agent loop |
+| [sites.md](sites.md) | `/sites`: every site's articles, their media, workspaces and notes |
| [authoring.md](authoring.md) | Writing a manifest from a sweep report |
| [e2e.md](e2e.md) | The fixture, the stubs, the global queue |
| [quirks.md](quirks.md) | Everything that cost time to find out |
diff --git a/umtool/docs/notes.md b/umtool/docs/notes.md
@@ -0,0 +1,112 @@
+# Notes: the operator writes them in umtool, an agent acts on them
+
+A note is a sentence or two the operator leaves on an **article** (a report on a
+site, `/sites/<site>/<report>`) or on a **report-video project** (a timeline row, a
+take, a moment in a rendered cut). It is saved where the agent that made the thing
+can read it back, act on the right SOURCE file, and reply or resolve.
+
+```sh
+umtool notes --all --open # every notes file with an open note
+umtool notes candalyzer/polemic-israel # one article's open notes, as markdown
+umtool notes candace/polemic-israel # one video project's (plus every take's verdict)
+umtool notes reply n_mgk3x0a1b2 "Changed 'said' to 'claimed' in drafts/israel.json" --resolve
+umtool notes resolve | wontfix | reopen n_mgk3x0a1b2
+```
+
+Run it from the repo checkout (or with `TRANSCRIPTS_DIR`/`SITES_DIR` and
+`REPORTS_DIR` set): like `CHANNELS_DIR`, the sites directory is found by walking up
+from the working directory to the checkout.
+
+## The agent loop
+
+1. `umtool notes --all --open` — what is waiting.
+2. `umtool notes <target>` — each open note with its anchor resolved against what
+ is on disk now, and the **Source** line naming the file to edit.
+3. Edit the SOURCE, not the output. An article's `report.json` is regenerated from
+ a draft (`<workspace>/polemics/drafts/<slug>.json`) by a generator
+ (`make-site.py`, `polemics.py`); a generated video manifest (`generatedBy`) is
+ rewritten by its `make-videos.py`. An edit to the output is lost on the next run.
+4. Regenerate.
+5. `umtool notes reply <id> "<what changed>" --resolve` — or reply without
+ `--resolve` to ask a question. The operator sees agent replies badged "agent".
+
+**Never hand-edit `notes.json`.** The CLI and the app write through one store that
+validates, takes a lock, and refuses a stale write; a hand edit races the page and
+can lose a reply. If the recorded source is wrong, correct it:
+`umtool notes source <target> --draft <path> [--generator <path>] [--how <why>]`.
+
+The same digest is served as text at `GET /api/notes/context?article=<site>/<report>`
+(or `?project=<id>`); the page's "Copy agent brief" copies it.
+
+## Where notes live
+
+| Subject | File |
+|---|---|
+| An article | `transcripts/sites/<site>/reports/<report>/notes.json`, beside `report.json` |
+| A report-video project | `<project>/notes.json`, beside `video.manifest.json` |
+
+The article file is the one corpus file umtool writes, and only through
+`isCorpusNotesFile` (`lib/paths.mjs`): exactly `sites/<site>/reports/<id>/notes.json`,
+in an existing report directory that is not a symlink out of its site. The
+generators overwrite only `report.json`, `video.mp4` and `poster.jpg` in a report
+directory, so notes survive a regenerate; compose and the report history never read
+it (`common/publish/composeReports.test.ts` holds that), so a note is never published.
+
+## The file
+
+```jsonc
+{ "format": "umtool-notes", "version": 1,
+ "subject": { "kind": "article", "site": "candalyzer", "report": "polemic-israel" },
+ // | { "kind": "video-project", "project": "candace/polemic-israel" }
+ "source": { "draft": "~/reports/candace/polemics/drafts/israel.json",
+ "generator": "~/reports/candace/site/polemics.py",
+ "how": "draft matched by id polemic-israel; generator names candalyzer" },
+ "notes": [ { "id": "n_…", "status": "open", // open | resolved | wontfix
+ "author": "operator", "text": "…", // operator | agent
+ "at": "…", "updatedAt": "…",
+ "anchor": { … },
+ "replies": [ { "author": "agent", "text": "…", "at": "…" } ],
+ "resolvedAt": "…", "resolvedBy": "agent" } ] }
+```
+
+`source` is filled on the first write. For an article it comes from
+`lib/articles/sources.mjs`: a draft whose `id` or file name is the report id (allowing
+a `polemic-` prefix either way; the workspace whose generator names the site wins a
+tie), and the generator under `<workspace>/polemics` or `<workspace>/site` that names
+the site or the report. For a video project it is the manifest and, when it has
+`generatedBy`, the generator.
+
+The last note deleted deletes the file. A `notes.json` that does not parse is never
+overwritten — fix it by hand first.
+
+## Anchors
+
+| `kind` | Fields | Resolved for the agent as |
+|---|---|---|
+| `text` | `section` (a section id, or `title`/`subtitle`/`summary`/`method`), `quote`, `prefix`, `suffix` | the section title and the sentence holding the quote |
+| `cite` | `cite` (a citation id) | the citation's quote, speaker, date, `<channel>/<id>@start-end` |
+| `section` | `section` | the section title |
+| `whole` | — | the whole article or project |
+| `moment` | `file` (relative mp4), `t`, `take?`, `entry?`, `resolved?` | the time, and the entry, quote and source second it resolved to when written |
+| `entry` | `entry` (a timeline entry id) | the entry's title, quote and source span |
+| `take` | `take` (a take id) | the take's label and summary, and its verdict |
+| `edit` | `entry?`, `field`, `from`, `to` | an edit made in umtool to a GENERATED manifest — port it into the generator's inputs |
+
+A text anchor is a quote with 32 characters of context either side (the W3C
+TextQuoteSelector), never an offset, so it survives a regenerated `report.json`:
+`lib/annotations/anchor.mjs` finds the quote exactly, then with whitespace, quotes
+and case normalised, then — when the quote itself was rewritten — between its
+surviving context. A note it cannot place is **orphaned**: still shown, pinned to its
+section, with its original quote.
+
+## Code
+
+| Module | What |
+|---|---|
+| `lib/annotations/shape.mjs` | the contract: constants, validation, the ops (pure, client-safe) |
+| `lib/annotations/anchor.mjs` | re-anchoring (pure, client-safe) |
+| `lib/annotations/store.mjs` | read, lockfile (`notes.json.lock`, stale after 30 s), token, tmp+rename |
+| `lib/annotations/targets.mjs` | article / project → file, subject, source; `listNotesFiles` |
+| `lib/annotations/digest.mjs`, `cli.mjs` | the markdown digest and `umtool notes` |
+| `app/api/notes/route.ts` | GET / POST (operator-stamped; 409 on a stale token) |
+| `lib/annotations/useNotes.ts` | the page's hook |