# 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//`) 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 ` — 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 (`/polemics/drafts/.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 "" --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 --draft [--generator ] [--how ]`. The same digest is served as text at `GET /api/notes/context?article=/` (or `?project=`); the page's "Copy agent brief" copies it. ## Where notes live | Subject | File | |---|---| | An article | `transcripts/sites//reports//notes.json`, beside `report.json` | | A report-video 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//reports//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 `/polemics` or `/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, `/@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 |