commit 056cfc2fff24cbf93a81d2962bf1aaf333771b4e
parent 5a717dd1901e8277854225d48f61c207305890e7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 8 Oct 2026 22:31:47 -0400
umtool S7: `umtool notes` CLI, agent digest, /api/notes/context
The agent's side of operator notes: list, digest (anchors resolved against
disk, the source file named first, take verdicts for a video project), reply
/ resolve / wontfix / reopen / source, every write stamped agent.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 489 insertions(+), 0 deletions(-)
diff --git a/umtool/app/api/notes/context/route.ts b/umtool/app/api/notes/context/route.ts
@@ -0,0 +1,32 @@
+import { errorResponse, targetFrom } from "@/lib/annotations/server";
+import { digest } from "@/lib/annotations/digest.mjs";
+import { readNotes } from "@/lib/annotations/store.mjs";
+
+export const dynamic = "force-dynamic";
+
+// The agent brief: the same markdown `umtool notes <target>` prints, as
+// text/plain, so "Copy agent brief" and `curl` hand over the words an agent
+// would read on the command line.
+//
+// /api/notes/context?article=<site>/<report>[&status=open|resolved|all]
+// /api/notes/context?project=<id>[&status=…]
+export async function GET(request: Request) {
+ try {
+ const url = new URL(request.url);
+ const target = await targetFrom({ article: url.searchParams.get("article"), project: url.searchParams.get("project") });
+ const s = url.searchParams.get("status");
+ const status = s === "resolved" || s === "all" ? s : "open";
+ const read = await readNotes(target.file);
+ const cli = `umtool notes ${target.id}`;
+ const body =
+ !read.doc && !read.error
+ ? `No notes on ${target.id}.\n`
+ : `${await digest(
+ { kind: target.kind as "article" | "video-project", id: target.id, file: target.file, doc: read.doc, ...(read.error ? { error: read.error } : {}) },
+ { status, projectDir: "dir" in target ? target.dir : undefined },
+ )}\n\nRead these again with \`${cli}\` (from the repo checkout).\n`;
+ return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
+ } catch (err) {
+ return errorResponse(err);
+ }
+}
diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs
@@ -36,6 +36,9 @@
// umtool diff <project> <snapshot> what changed since that snapshot
// umtool export <project> --format toc-bbcode|toc-markdown|description|chapters [--variant V]
// umtool check-sources [<project>…] prints the re-check chain
+// umtool notes [<site>/<report> | <project> | --all] [--open|--resolved|--all-status] [--json]
+// umtool notes reply <id> "<text>" [--resolve] | resolve | wontfix | reopen <id>
+// the operator's notes, and the agent's answers
import process from "node:process";
import {
PROJECT_KINDS,
@@ -65,6 +68,7 @@ import { buildSteps, checkSourcesSteps, PRESETS } from "../lib/report/driver.mjs
import { openIndex, signRecord } from "../lib/projects/index-db.mjs";
import { pipelineProcessesFor } from "../lib/report/busy.mjs";
import { probeTools } from "../lib/tools.mjs";
+import { notesCommand } from "../lib/annotations/cli.mjs";
import { scaffoldReportVideo } from "../lib/projects/scaffold.mjs";
import { CACHE_DIR, INDEX_DIR, MEDIA_ROOT, MEDIA_TIERED, OLD_CACHE_DIR } from "../lib/paths.mjs";
import {
@@ -698,6 +702,8 @@ function usage() {
" umtool diff <project> <snapshot> what changed since that snapshot",
" umtool export <project> --format toc-bbcode|toc-markdown|description|chapters [--variant V]",
" umtool check-sources [<project>…] prints the re-check chain (never-checked/old when no args)",
+ " umtool notes [<site>/<report>|<project>|--all] the operator's notes, as markdown (open ones)",
+ " umtool notes reply <id> \"<text>\" [--resolve] answer one; resolve | wontfix | reopen <id>",
"",
`reading ${REPORTS_ROOT} (set REPORTS_DIR to move it)`,
"",
@@ -725,6 +731,7 @@ const COMMANDS = {
diff: cmdDiff,
export: cmdExport,
"check-sources": cmdCheckSources,
+ notes: async () => process.exit(await notesCommand(argv.slice(argv.indexOf("notes") + 1))),
help: usage,
};
diff --git a/umtool/lib/annotations/cli.mjs b/umtool/lib/annotations/cli.mjs
@@ -0,0 +1,140 @@
+// `umtool notes` -- the agent's side of the notes the operator writes in the
+// app. It reads and writes through the same store (./store.mjs) and targets
+// (./targets.mjs) the app does, and stamps every write `author: agent`. An
+// agent never hand-edits notes.json: this validates, locks, and keeps the
+// operator's page from losing a reply (docs/notes.md).
+//
+// umtool notes [--all] every notes file, with open counts
+// umtool notes <site>/<report> | <project> the digest (open notes)
+// [--open | --resolved | --all-status] [--json]
+// umtool notes reply <id> "<text>" [--resolve] [--in <target>]
+// umtool notes resolve | wontfix | reopen <id> [--in <target>]
+// umtool notes source <target> [--draft P] [--generator P] [--how T]
+import { REPORTS_ROOT, SITES_DIR } from "../paths.mjs";
+import { digest } from "./digest.mjs";
+import { readNotes } from "./store.mjs";
+import { articleTarget, listNotesFiles, projectTarget, resolveTarget, writeNote } from "./targets.mjs";
+
+const USAGE = [
+ "usage: umtool notes [--all] every notes file and its open count",
+ " umtool notes <site>/<report> | <project> the notes, as markdown (open ones)",
+ " [--open | --resolved | --all-status] [--json]",
+ " umtool notes reply <id> \"<text>\" [--resolve] answer a note (and close it)",
+ " umtool notes resolve | wontfix | reopen <id>",
+ " umtool notes source <target> [--draft P] [--generator P] [--how T]",
+ "",
+ "Notes are the operator's, written in umtool (/sites, a video project). Act on one",
+ "by editing its SOURCE file and regenerating; never edit notes.json by hand.",
+].join("\n");
+
+class CliError extends Error {}
+
+/**
+ * @param {string[]} args everything after `notes`
+ * @param {{ sitesDir?: string, reportsRoot?: string, log?: (s: string) => void }} [opts]
+ * @returns {Promise<number>} exit code
+ */
+export async function notesCommand(args, { sitesDir = SITES_DIR, reportsRoot = REPORTS_ROOT, log = console.log } = {}) {
+ const flags = new Set(args.filter((a) => a.startsWith("--")));
+ const val = (n) => {
+ const i = args.indexOf(n);
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
+ };
+ const VALUED = new Set(["--in", "--draft", "--generator", "--how"]);
+ const pos = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && VALUED.has(args[i - 1])));
+ const json = flags.has("--json");
+ const opts = { sitesDir, reportsRoot };
+ const print = (v) => log(json ? JSON.stringify(v, null, 2) : v);
+
+ try {
+ const verb = pos[0];
+ if (flags.has("--help") || verb === "help") {
+ log(USAGE);
+ return 0;
+ }
+ if (verb === "reply" || verb === "resolve" || verb === "wontfix" || verb === "reopen") {
+ const id = pos[1];
+ if (!id) throw new CliError(`which note? \`umtool notes ${verb} <id>\``);
+ const where = await findNote(id, val("--in"), opts);
+ let op;
+ if (verb === "reply") {
+ const text = pos[2];
+ if (!text) throw new CliError('what reply? `umtool notes reply <id> "<text>" [--resolve]`');
+ op = { op: "reply", id, text, resolve: flags.has("--resolve") };
+ } else {
+ op = { op: "status", id, status: verb === "reopen" ? "open" : verb === "wontfix" ? "wontfix" : "resolved" };
+ }
+ const r = await writeNote(where.target, op, { by: "agent" });
+ if (json) print({ ok: true, target: where.target.id, file: where.target.file, note: r.note });
+ else log(`${id} in ${where.target.id}: ${r.note?.status}${verb === "reply" ? `, ${r.note?.replies.length} repl${r.note?.replies.length === 1 ? "y" : "ies"}` : ""}`);
+ return 0;
+ }
+ if (verb === "source") {
+ const spec = pos[1];
+ if (!spec) throw new CliError("which target? `umtool notes source <site>/<report> --draft P`");
+ const target = await resolveTarget(spec, opts);
+ const cur = await readNotes(target.file);
+ if (!cur.doc) throw new CliError(`${target.id} has no notes; the source is recorded with the first note`);
+ const source = { ...(cur.doc.source ?? {}) };
+ for (const k of ["draft", "generator", "how"]) if (val(`--${k}`) !== undefined) source[k] = val(`--${k}`);
+ const r = await writeNote(target, { op: "source", source }, { by: "agent" });
+ print(json ? { ok: true, source: r.doc?.source ?? null } : `${target.id}: source ${JSON.stringify(r.doc?.source ?? {})}`);
+ return 0;
+ }
+
+ const status = flags.has("--all-status") ? "all" : flags.has("--resolved") ? "resolved" : "open";
+ if (!verb || flags.has("--all")) {
+ const files = await listNotesFiles(opts);
+ if (json) {
+ print(files.map((f) => ({ kind: f.kind, id: f.id, file: f.file, open: f.doc ? f.doc.notes.filter((n) => n.status === "open").length : null, total: f.doc?.notes.length ?? null, error: f.error })));
+ return 0;
+ }
+ if (!files.length) {
+ log(`no notes under ${sitesDir} or ${reportsRoot}`);
+ return 0;
+ }
+ const w = Math.max(...files.map((f) => f.id.length));
+ let open = 0;
+ for (const f of files) {
+ const o = f.doc ? f.doc.notes.filter((n) => n.status === "open").length : 0;
+ open += o;
+ if (status === "open" && !o && !f.error) continue;
+ log(`${f.id.padEnd(w)} ${f.kind === "article" ? "article" : "video "} ${f.error ? `UNREADABLE: ${f.error}` : `${o} open / ${f.doc.notes.length}`}`);
+ }
+ log(`\n${open} open note(s) in ${files.length} file(s). \`umtool notes <id>\` for one.`);
+ return 0;
+ }
+
+ const target = await resolveTarget(verb, opts);
+ const read = await readNotes(target.file);
+ const entry = { kind: target.kind, id: target.id, file: target.file, doc: read.doc, ...(read.error ? { error: read.error } : {}) };
+ if (json) {
+ print({ ...entry, source: read.doc?.source ?? (await target.source()) ?? null });
+ return 0;
+ }
+ if (!read.doc && !read.error) {
+ log(`no notes on ${target.id} (${target.file})`);
+ return 0;
+ }
+ log(await digest(entry, { status, sitesDir, reportsRoot, projectDir: target.dir }));
+ return 0;
+ } catch (err) {
+ console.error(err instanceof Error ? err.message : String(err));
+ return err instanceof CliError || err?.name === "NoteError" || err?.name === "TargetError" ? 2 : 1;
+ }
+}
+
+/** The notes file holding note `id`: the one named by --in, else a search of every file. */
+async function findNote(id, inSpec, opts) {
+ if (inSpec) {
+ const target = await resolveTarget(inSpec, opts);
+ const r = await readNotes(target.file);
+ if (!r.doc?.notes.some((n) => n.id === id)) throw new CliError(`no note ${id} in ${target.id}`);
+ return { target };
+ }
+ const hits = (await listNotesFiles(opts)).filter((f) => f.doc?.notes.some((n) => n.id === id));
+ if (!hits.length) throw new CliError(`no note ${id} (see \`umtool notes --all\`)`);
+ if (hits.length > 1) throw new CliError(`${id} is in ${hits.length} files; say which with --in: ${hits.map((h) => h.id).join(", ")}`);
+ const hit = hits[0];
+ return { target: hit.kind === "article" ? await articleTarget(hit.id, opts) : await projectTarget(hit.id, opts) };
+}
diff --git a/umtool/lib/annotations/cli.test.mjs b/umtool/lib/annotations/cli.test.mjs
@@ -0,0 +1,103 @@
+// `umtool notes`: the agent's loop, end to end, against a temp SITES_DIR and
+// REPORTS_DIR -- read the digest, reply and resolve, reopen, list.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { notesCommand } from "./cli.mjs";
+import { articleTarget, projectTarget, writeNote } from "./targets.mjs";
+import { clearSourcesCache } from "../articles/sources.mjs";
+
+const REPORT = {
+ format: "archilyzer-report",
+ version: 1,
+ id: "polemic-x",
+ kind: "sweep",
+ title: "On X",
+ summary: "She said **X** twice. Then she [denied it](cite:c1).",
+ citations: { c1: { kind: "video", channel: "ch", id: "v1", start: 10, end: 20, quote: "I never said X", speaker: "Her" } },
+ sections: [{ id: "s1", title: "The first time", body: "In 2019 she said X on a podcast. Nobody noticed." }],
+};
+
+async function fixture() {
+ const root = await mkdtemp(path.join(tmpdir(), "umtool-notes-cli-"));
+ const sites = path.join(root, "sites");
+ const reports = path.join(root, "reports");
+ await mkdir(path.join(sites, "priv", "reports", "polemic-x"), { recursive: true });
+ await writeFile(path.join(sites, "priv", "site.json"), "{}");
+ await writeFile(path.join(sites, "priv", "reports", "polemic-x", "report.json"), JSON.stringify(REPORT));
+ await mkdir(path.join(reports, "ws", "polemics", "drafts"), { recursive: true });
+ await writeFile(path.join(reports, "ws", "polemics", "drafts", "x.json"), JSON.stringify({ id: "polemic-x" }));
+ await writeFile(path.join(reports, "ws", "polemics", "make-site.py"), 'SITE = "priv" # drafts\n');
+ const proj = path.join(reports, "ws", "polemic-x");
+ await mkdir(proj, { recursive: true });
+ await writeFile(
+ path.join(proj, "video.manifest.json"),
+ JSON.stringify({ schemaVersion: 1, slug: "polemic-x", generatedBy: "polemics/make-site.py", timeline: [{ type: "clip", id: "e1", channel: "ch", video: "v1", start: 10, end: 20, quote: "I never said X", onscreen: { title: "Denial" } }] }),
+ );
+ clearSourcesCache();
+ return { root, sites, reports, opts: { sitesDir: sites, reportsRoot: reports } };
+}
+
+async function run(args, opts) {
+ const out = [];
+ const code = await notesCommand(args, { ...opts, log: (s) => out.push(String(s)) });
+ return { code, text: out.join("\n") };
+}
+
+test("the agent loop: digest, reply --resolve, reopen; every write is the agent's", async () => {
+ const { root, sites, opts } = await fixture();
+ const t = await articleTarget("priv/polemic-x", opts);
+ const a = await writeNote(t, { op: "add", text: "Too strong; say 'claimed'.", anchor: { kind: "text", section: "s1", quote: "she said X", prefix: "In 2019 ", suffix: " on a podcast" } }, { by: "operator" });
+ await writeNote(t, { op: "add", text: "Is this the right clip?", anchor: { kind: "cite", cite: "c1" } }, { by: "operator" });
+
+ const d = await run(["priv/polemic-x"], opts);
+ assert.equal(d.code, 0);
+ assert.match(d.text, /# Notes on priv\/polemic-x — On X/);
+ assert.match(d.text, /edit `.*ws\/polemics\/drafts\/x\.json`; `report\.json` is regenerated by `.*make-site\.py`/);
+ assert.match(d.text, /“she said X” in “The first time”/);
+ assert.match(d.text, /> In 2019 she said X on a podcast\./);
+ assert.match(d.text, /citation `c1` — ch\/v1@10-20 \(Her\)/);
+ assert.match(d.text, /2 open, 0 closed/);
+
+ const r = await run(["reply", a.note.id, "Changed to 'claimed' in drafts/x.json", "--resolve"], opts);
+ assert.equal(r.code, 0, r.text);
+ const doc = JSON.parse(await readFile(path.join(sites, "priv", "reports", "polemic-x", "notes.json"), "utf8"));
+ const n = doc.notes.find((x) => x.id === a.note.id);
+ assert.equal(n.status, "resolved");
+ assert.equal(n.resolvedBy, "agent");
+ assert.deepEqual(n.replies.map((x) => x.author), ["agent"]);
+
+ assert.match((await run(["priv/polemic-x"], opts)).text, /1 open, 1 closed/);
+ assert.match((await run(["priv/polemic-x", "--resolved"], opts)).text, /Changed to 'claimed'/);
+ assert.equal((await run(["reopen", a.note.id], opts)).code, 0);
+ const list = await run(["--all"], opts);
+ assert.match(list.text, /priv\/polemic-x\s+article\s+2 open \/ 2/);
+
+ // refusals exit 2 and write nothing
+ assert.equal((await run(["reply", "n_nosuchnote"], opts)).code, 2);
+ assert.equal((await run(["reply", a.note.id], opts)).code, 2);
+ assert.equal((await run(["priv/../etc"], opts)).code, 2);
+ await rm(root, { recursive: true });
+});
+
+test("a video project's digest names the generator and lists the takes' verdicts", async () => {
+ const { root, reports, opts } = await fixture();
+ const proj = path.join(reports, "ws", "polemic-x");
+ await mkdir(path.join(proj, "takes", "deck"), { recursive: true });
+ await writeFile(path.join(proj, "takes", "deck", "take.json"), JSON.stringify({ id: "deck", group: "open", order: 1, label: "Deck first", kind: "similar", preview: "preview.mp4" }));
+ await writeFile(path.join(proj, "takes", "verdicts.json"), JSON.stringify({ deck: { verdict: "like", note: "keep the beat", at: "x" } }));
+ const t = await projectTarget("ws/polemic-x", opts);
+ await writeNote(t, { op: "add", text: "Cut this.", anchor: { kind: "entry", entry: "e1" } }, { by: "operator" });
+ await writeNote(t, { op: "add", text: "quote changed", anchor: { kind: "edit", entry: "e1", field: "quote", from: "a", to: "b" } }, { by: "operator" });
+ const d = await run(["ws/polemic-x"], opts);
+ assert.equal(d.code, 0, d.text);
+ assert.match(d.text, /video\.manifest\.json` is regenerated by `.*ws\/polemics\/make-site\.py`/);
+ assert.match(d.text, /timeline entry `e1` \(clip\) “Denial”/);
+ assert.match(d.text, /`e1\.quote`: "a" → "b"/);
+ assert.match(d.text, /- `deck` “Deck first” \(open, similar\): like — keep the beat/);
+ await rm(root, { recursive: true });
+});
diff --git a/umtool/lib/annotations/digest.mjs b/umtool/lib/annotations/digest.mjs
@@ -0,0 +1,207 @@
+// Notes as an AGENT reads them: markdown, every anchor resolved to something a
+// reader with no page open can act on, and the file to edit named first.
+//
+// `umtool notes <target>` prints this; GET /api/notes/context serves the same
+// text, so "Copy agent brief" on a page and an agent's CLI hand over the same
+// words. An anchor is resolved against what is on disk NOW:
+//
+// text the section's title and the sentence holding the quote, found
+// again with the same re-anchoring the page uses (ORPHANED when the
+// quote is gone -- the note still prints, with its quote)
+// cite the citation's quote, speaker, date and `<channel>/<id>@start-end`
+// moment the time, and the entry/source it resolved to when it was written
+// entry the timeline entry's title and quote
+// take the take's label and summary, and the operator's verdict on it
+// edit what changed, from → to, to port into the generator's inputs
+//
+// A video project's digest also lists every take with its verdict and note
+// (takes/verdicts.json): the agent that rendered the takes reads them here.
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { REPORTS_ROOT, SITES_DIR } from "../paths.mjs";
+import { listTakes, readVerdicts } from "../report/takes.mjs";
+import { locateQuote, sentenceAround } from "./anchor.mjs";
+
+const readJson = (file) => readFile(/* turbopackIgnore: true */ file, "utf8").then(JSON.parse, () => null);
+
+/** Markdown to the plain text a reader sees: links to their labels, emphasis and code marks dropped. */
+export function plainText(md) {
+ return String(md ?? "")
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
+ .replace(/^#{1,6}\s+/gm, "")
+ .replace(/^\s*>\s?/gm, "")
+ .replace(/^\s*[-*+]\s+/gm, "")
+ .replace(/(\*\*|__|\*|_|`)/g, "");
+}
+
+/**
+ * The plain text of one block of a report, as the page renders it: a section's
+ * title, body and claims; or the title/subtitle/summary/method.
+ */
+export function blockText(report, block) {
+ if (!report) return { title: block, text: "" };
+ if (["title", "subtitle", "summary", "method"].includes(block)) {
+ return { title: block, text: plainText(report[block] ?? "") };
+ }
+ const s = (report.sections ?? []).find((x) => x.id === block);
+ if (!s) return null;
+ const parts = [s.title, plainText(s.body ?? "")];
+ for (const c of s.claims ?? []) parts.push(c.title ?? "", plainText(c.text), plainText(c.findings ?? ""));
+ return { title: s.title, text: parts.filter(Boolean).join("\n") };
+}
+
+const clip = (s, n = 300) => {
+ const t = String(s ?? "").replace(/\s+/g, " ").trim();
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
+};
+const quoteLine = (s) => `> ${clip(s, 400)}`;
+const hms = (t) => {
+ const s = Math.max(0, Math.round(Number(t) || 0));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const ss = String(s % 60).padStart(2, "0");
+ return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
+};
+
+/** One anchor, resolved, as markdown lines. */
+function anchorLines(a, ctx) {
+ const { report, manifest, takes, verdicts } = ctx;
+ switch (a.kind) {
+ case "whole":
+ return [ctx.kind === "article" ? "**On:** the whole article" : "**On:** the whole project"];
+ case "section": {
+ const b = blockText(report, a.section);
+ return [`**On:** section “${b?.title ?? a.section}”${b ? "" : " (section no longer exists)"}`];
+ }
+ case "text": {
+ const b = blockText(report, a.section);
+ if (!b) return [`**On:** text in section \`${a.section}\` — ORPHANED (section no longer exists)`, quoteLine(a.quote)];
+ const hit = locateQuote(b.text, a);
+ if (!hit.found) return [`**On:** text in “${b.title}” — ORPHANED (quote no longer in the section)`, quoteLine(a.quote)];
+ const exact = b.text.slice(hit.start, hit.end);
+ return [
+ `**On:** “${clip(exact, 200)}” in “${b.title}”${hit.how === "exact" ? "" : ` (found ${hit.how})`}`,
+ quoteLine(sentenceAround(b.text, hit.start, hit.end)),
+ ];
+ }
+ case "cite": {
+ const c = report?.citations?.[a.cite];
+ if (!c) return [`**On:** citation \`${a.cite}\` (no longer in the report)`];
+ const who = [c.speaker, c.date].filter(Boolean).join(", ");
+ const where =
+ c.kind === "video" || c.kind === "audio"
+ ? `${c.channel}/${c.id}@${c.start}-${c.end}`
+ : c.kind === "post"
+ ? `post ${c.channel}/${c.id}`
+ : c.kind === "page"
+ ? c.url
+ : `source ${c.source}`;
+ return [`**On:** citation \`${a.cite}\`${c.label ? ` “${c.label}”` : ""} — ${where}${who ? ` (${who})` : ""}`, quoteLine(c.quote)];
+ }
+ case "moment": {
+ const r = a.resolved ?? {};
+ const take = a.take ? ` of take \`${a.take}\`` : "";
+ const lines = [`**On:** ${hms(a.t)} in \`${a.file}\`${take}${r.approx ? " (approximate)" : ""}`];
+ const entry = a.entry ?? r.entry;
+ if (entry || r.title) lines.push(`entry \`${entry ?? "?"}\`${r.title ? ` “${clip(r.title, 160)}”` : ""}`);
+ if (r.quote) lines.push(quoteLine(r.quote));
+ if (r.channel && r.video) lines.push(`source ${r.channel}/${r.video}${r.sourceT !== undefined ? ` @ ${hms(r.sourceT)}` : ""}${r.url ? ` — ${r.url}` : ""}`);
+ return lines;
+ }
+ case "entry": {
+ const e = (manifest?.timeline ?? []).find((x) => x.id === a.entry);
+ if (!e) return [`**On:** timeline entry \`${a.entry}\` (no longer in the manifest)`];
+ const title = e.title ?? e.onscreen?.title;
+ const lines = [`**On:** timeline entry \`${a.entry}\` (${e.type ?? "entry"})${title ? ` “${clip(title, 160)}”` : ""}`];
+ if (e.quote) lines.push(quoteLine(e.quote));
+ if (e.type === "clip" && e.channel && e.video) lines.push(`source ${e.channel}/${e.video}@${e.start}-${e.end}`);
+ return lines;
+ }
+ case "take": {
+ const t = takes?.find((x) => x.id === a.take);
+ const v = verdicts?.[a.take];
+ const lines = [`**On:** take \`${a.take}\`${t ? ` “${t.label}” (${t.group})` : " (no longer in takes/)"}${v?.verdict ? ` — verdict: ${v.verdict}` : ""}`];
+ if (t?.summary) lines.push(`summary: ${clip(t.summary, 300)}`);
+ return lines;
+ }
+ case "edit":
+ return [
+ `**On:** an edit made in umtool to a GENERATED manifest — port it into the generator's inputs`,
+ `\`${a.entry ? `${a.entry}.` : ""}${a.field}\`: ${clip(JSON.stringify(a.from), 300)} → ${clip(JSON.stringify(a.to), 300)}`,
+ ];
+ default:
+ return [`**On:** ${JSON.stringify(a)}`];
+ }
+}
+
+/** "Edit X; Y is regenerated by Z" -- the line an agent most needs. */
+export function sourceLine(kind, source) {
+ if (!source) return kind === "article" ? "**Source:** unknown — find the draft before editing report.json, which a generator may overwrite." : "**Source:** the manifest.";
+ if (kind === "article") {
+ if (source.draft && source.generator) return `**Source:** edit \`${source.draft}\`; \`report.json\` is regenerated by \`${source.generator}\`.`;
+ if (source.draft) return `**Source:** edit \`${source.draft}\`.`;
+ if (source.generator) return `**Source:** \`report.json\` is written by \`${source.generator}\`; edit its inputs.`;
+ } else {
+ if (source.generator) return `**Source:** \`${source.manifest}\` is regenerated by \`${source.generator}\`; edit its inputs (BEATS, drafts), not the manifest.`;
+ if (source.manifest) return `**Source:** edit \`${source.manifest}\`.`;
+ }
+ return `**Source:** ${source.how ?? "unknown"}`;
+}
+
+const STATUS = { open: (n) => n.status === "open", resolved: (n) => n.status !== "open", all: () => true };
+
+/**
+ * The digest of one notes file.
+ *
+ * @param {{ kind: "article" | "video-project", id: string, file: string, doc: any, error?: string }} entry
+ * @param {{ status?: "open" | "resolved" | "all", sitesDir?: string, reportsRoot?: string, projectDir?: string }} [opts]
+ */
+export async function digest(entry, { status = "open", sitesDir = SITES_DIR, reportsRoot = REPORTS_ROOT, projectDir } = {}) {
+ const lines = [];
+ const doc = entry.doc;
+ const ctx = { kind: entry.kind, report: null, manifest: null, takes: null, verdicts: null };
+ let heading;
+ if (entry.kind === "article") {
+ const [site, report] = entry.id.split("/");
+ ctx.report = await readJson(path.join(/* turbopackIgnore: true */ sitesDir, site, "reports", report, "report.json"));
+ heading = `# Notes on ${entry.id}${ctx.report?.title ? ` — ${ctx.report.title}` : ""}`;
+ } else {
+ const dir = projectDir ?? path.dirname(/* turbopackIgnore: true */ entry.file);
+ ctx.manifest = await readJson(path.join(/* turbopackIgnore: true */ dir, "video.manifest.json"));
+ const t = await listTakes(dir);
+ ctx.takes = t.takes;
+ ctx.verdicts = await readVerdicts(dir);
+ heading = `# Notes on ${entry.id}${ctx.manifest?.title ? ` — ${ctx.manifest.title}` : ""}`;
+ }
+ lines.push(heading, "");
+ lines.push(`file: \`${entry.file}\``);
+ if (entry.error) {
+ lines.push("", `**notes.json does not parse:** ${entry.error}. Nothing here may write it until it is fixed by hand.`);
+ return lines.join("\n");
+ }
+ lines.push(sourceLine(entry.kind, doc?.source));
+ if (doc?.source?.how) lines.push(`(${doc.source.how})`);
+ const all = doc?.notes ?? [];
+ const shown = all.filter(STATUS[status] ?? STATUS.open);
+ const open = all.filter((n) => n.status === "open").length;
+ lines.push("", `${open} open, ${all.length - open} closed${status === "all" ? "" : `; showing ${status}`}.`);
+ for (const n of shown) {
+ lines.push("", `## ${n.id} — ${n.status}${n.status !== "open" && n.resolvedBy ? ` by ${n.resolvedBy}` : ""} (${n.author}, ${n.at.slice(0, 16).replace("T", " ")})`);
+ lines.push(...anchorLines(n.anchor, ctx));
+ lines.push("", n.text);
+ for (const r of n.replies) lines.push("", `- **${r.author}** (${r.at.slice(0, 16).replace("T", " ")}): ${r.text.replace(/\n/g, "\n ")}`);
+ }
+ if (entry.kind === "video-project" && ctx.takes?.length) {
+ lines.push("", "## Takes (takes/verdicts.json)");
+ for (const t of ctx.takes) {
+ const v = ctx.verdicts?.[t.id];
+ lines.push(`- \`${t.id}\` “${t.label}” (${t.group}, ${t.kind}): ${v?.verdict ?? "no verdict"}${v?.note ? ` — ${clip(v.note, 400)}` : ""}`);
+ }
+ }
+ if (shown.length) {
+ lines.push("", "Act on a note by editing the SOURCE above and regenerating, then:");
+ lines.push(`\`umtool notes reply <id> "what you changed" --resolve\` (or \`umtool notes reply <id> "question"\` to ask).`);
+ }
+ return lines.join("\n");
+}