#!/usr/bin/env node // umtool — the project tree, from a terminal. // // The audience is an AI assistant working in this repo, which is why every // command takes --json and why `check` exits non-zero. It reads the SAME // lib/projects/*.mjs the app does, so `umtool ls` and /browse cannot disagree // about what a project is, and `umtool check` and the decisions inbox cannot // disagree about what is wrong with one. // // Honouring REPORTS_DIR / SONG_REPORTS_DIR / SONG_DIR / CHANNELS_DIR means it // can be pointed at the e2e fixture, which is how it is tested. // // umtool ls [--kind K] [--template T] [--state S] [--open] [--blocking] // [--q TEXT] [--sort name|recent] [--json] // umtool show [--json] // umtool check [ | --all] [--json] exit 1 on anything blocking // umtool decisions [--json] // umtool folders [--json] // umtool kinds [--json] // umtool window [--start S] [--end E] [--lock] [--lock-end] // [--title T] [--date YYYY-MM-DD] [--cite S] [--cite-url U] [--quote Q] // [--correction TEXT] [--verdict confirmed|incorrect|''] // [--cut-start S] [--cut-end E] [--lock-cut] the cut inside the extent // umtool corrections what the report got wrong, as markdown for the next pass // umtool build [--preset preview|fast|final] [--only ID] [--dry] // umtool index [--rebuild] [--prune] [--since MS] [--json] // umtool new [--kind report-video] [--from ||/] // [--site-origin URL] [--seed chapters] [--brand archilyzer-media] // umtool doctor [--json] exit 1 if the report pipeline is missing a tool // or the media root is set and not there // umtool storage [move-out|move-back |--all] [--dry-run] [--json] // where each project's out/ lives; move it // umtool storage deliverables --to media|local [--dry-run] [--json] // move clips/ and every share-*/, then set the switch // umtool snapshot [--label L] copy the manifest into revisions/ // umtool diff what changed since that snapshot // umtool export --format toc-bbcode|toc-markdown|description|chapters [--variant V] // umtool check-sources […] prints the re-check chain // umtool notes [/ | | --all] [--open|--resolved|--all-status] [--json] // umtool notes reply "" [--resolve] | resolve | wontfix | reopen // the operator's notes, and the agent's answers import process from "node:process"; import { PROJECT_KINDS, REPORTS_ROOT, decisionsAreComplete, decisionsFor, folders, projectRefs, resolveProject, summarise, } from "../lib/projects/core.mjs"; import { clipVerdict, correctionsOf, readClipDetail, readManifest, reviewOf, sourcesOf, } from "../lib/projects/report.mjs"; import { createSnapshot, listSnapshots, readSnapshot } from "../lib/report/snapshots.mjs"; import { diffManifests, formatChange } from "../lib/report/manifest-diff.mjs"; import { EXPORT_FORMATS, exportProject } from "../lib/report/export.mjs"; import path from "node:path"; import { updateClip, updateStorage } from "../lib/report/manifest.mjs"; import { withEditNotes } from "../lib/report/edit-guard.mjs"; import { hms } from "umtool-report-to-video/attribution"; 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 { deliverablesState, measureTree, mediaRootProblem, moveDeliverables, moveDirToLocal, moveDirToMedia, outDirState, pathState, } from "../lib/report/storage.mjs"; const argv = process.argv.slice(2); const cmd = argv.find((a) => !a.startsWith("-")) ?? "help"; const rest = argv.filter((a) => a !== cmd); const has = (n) => rest.includes(n) || argv.includes(n); const val = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; }; const json = has("--json"); const positional = argv.filter((a, i) => { if (a.startsWith("-")) return false; if (a === cmd && argv.indexOf(a) === argv.indexOf(cmd)) return false; // A value that belongs to the flag before it is not a positional. return !(i > 0 && argv[i - 1].startsWith("--")); }); const out = (v) => console.log(json ? JSON.stringify(v, null, 2) : v); const die = (msg, code = 2) => { console.error(msg); process.exit(code); }; const RANK = { blocking: 0, open: 1, info: 2 }; const sortDecisions = (ds) => [...ds].sort((a, b) => RANK[a.severity] - RANK[b.severity] || a.project.localeCompare(b.project)); const ago = (ms) => { if (!ms) return "—"; const s = Math.max(0, (Date.now() - ms) / 1000); if (s < 90) return `${Math.round(s)}s`; if (s < 5400) return `${Math.round(s / 60)}m`; if (s < 129600) return `${Math.round(s / 3600)}h`; return `${Math.round(s / 86400)}d`; }; async function summaries() { const refs = await projectRefs(); return Promise.all(refs.map((p) => summarise(p))); } async function allDecisions() { const refs = await projectRefs(); const per = await Promise.all(refs.map((p) => decisionsFor(p))); return sortDecisions(per.flat()); } // --------------------------------------------------------------------------- async function cmdLs() { let items = await summaries(); const counts = new Map(); const refs = await projectRefs(); for (const p of refs) { const ds = await decisionsFor(p); counts.set(p.id, { blocking: ds.filter((d) => d.severity === "blocking").length, open: ds.filter((d) => d.severity === "open").length, complete: decisionsAreComplete(p.kind), }); } const kind = val("--kind"); const template = val("--template"); const state = val("--state"); const q = (val("--q") ?? "").trim().toLowerCase(); items = items.filter( (p) => (!kind || p.kind === kind) && (!template || p.template === template) && (!state || p.state === state) && (!q || p.haystack.includes(q)) && (!has("--blocking") || (counts.get(p.id)?.blocking ?? 0) > 0) && (!has("--open") || (counts.get(p.id)?.blocking ?? 0) + (counts.get(p.id)?.open ?? 0) > 0), ); items.sort( val("--sort") === "name" ? (a, b) => a.id.localeCompare(b.id) : (a, b) => b.newestMtimeMs - a.newestMtimeMs || a.id.localeCompare(b.id), ); if (json) return out(items.map((p) => ({ ...p, decisions: counts.get(p.id) }))); if (!items.length) return console.log("no projects match"); const w = Math.max(...items.map((p) => p.id.length)); for (const p of items) { const c = counts.get(p.id); const marks = [ c.blocking ? `${c.blocking} blocking` : "", c.open ? `${c.open} open` : "", ...p.flags, ].filter(Boolean); console.log( `${p.id.padEnd(w)} ${p.badge.padEnd(6)} ${p.state.padEnd(8)} ${ago(p.newestMtimeMs).padStart(4)} ` + `${p.facts.join(" · ")}${marks.length ? ` [${marks.join(" · ")}]` : ""}`, ); } console.log(`\n${items.length} project(s) under ${REPORTS_ROOT}`); } async function pick(arg) { if (!arg) die("which project? pass an id, a name, or a directory"); const r = await resolveProject(arg); if (r.ambiguous) { die( `"${arg}" is the name of ${r.ambiguous.length} projects — say which:\n` + r.ambiguous.map((p) => ` ${p.id}`).join("\n"), ); } if (!r.project) die(`no project matches "${arg}"`); return r.project; } async function cmdShow() { const p = await pick(positional[0]); const s = await summarise(p); const ds = await decisionsFor(p); const detail = p.kind === "report-video" ? await readClipDetail(p.dir) : null; if (json) { const src = detail ? await sourcesOf(p.dir, { manifest: detail.manifest }) : null; const snapshots = detail ? await listSnapshots(p.dir) : null; return out({ ...s, decisions: ds, entries: detail?.entries ?? null, sources: src?.rows ?? null, snapshots }); } console.log(`${s.title}`); console.log(`${p.id} [${p.kind} · ${p.template}] ${s.state}`); if (s.subtitle) console.log(s.subtitle); console.log(`${p.dir}`); if (s.facts.length) console.log(`\n${s.facts.join(" · ")}`); if (s.flags.length) console.log(`FLAGS: ${s.flags.join(" · ")}`); if (detail) { console.log(`\ncues from ${detail.channelsDir}${detail.shadowExists ? " (this project's shadow tree)" : ""}`); console.log("\nthe cut:"); for (const e of detail.entries) { // A CLIP is `type === "clip"`. Everything else -- a card, and the `scroll` // and `chart` entries one real manifest carries -- has no window and no // source, and the vocabulary is open, so it is printed generically rather // than assumed to be one of two things. if (e.kind !== "clip") { const label = e.heading ?? e.title ?? e.label ?? ""; const secs = e.seconds != null ? `${e.seconds}s` : ""; console.log(` ${e.id.padEnd(5)} ${String(e.kind).padEnd(9)} ${secs.padStart(4)} ${label}`); continue; } const marks = [ e.lock ? "locked" : "", !e.lock && e.lockStart ? "start pinned" : "", !e.lock && e.lockEnd ? "end pinned" : "", e.cached ? "cached" : "NOT FETCHED", e.segment ? "segment" : "", e.hasCues ? "" : "NO CUES", e.endsSentence === false && !e.lockEnd && !e.lock ? "ends mid-sentence" : "", e.noPunctuation ? "source unpunctuated" : "", e.proposed ? `widen -> ${e.proposed.start}–${e.proposed.end}` : "", e.lockCut ? "cut pinned" : "", ].filter(Boolean); // EXTENT then CUT. The extent is what was reviewed; the cut is what // plays, and a line that showed only one of them would be the same // confusion the two fields exist to end. const cut = e.cutStart != null && e.cutEnd != null ? ` cut ${Number(e.cutStart).toFixed(2)}–${Number(e.cutEnd).toFixed(2)} (${(e.cutEnd - e.cutStart).toFixed(1)}s)` : ""; console.log( ` ${e.id.padEnd(5)} ${String(e.video).padEnd(14)} ` + `${e.start.toFixed(2)}–${e.end.toFixed(2)} (${(e.end - e.start).toFixed(1)}s)${cut}` + `${marks.length ? ` [${marks.join(" · ")}]` : ""}`, ); } } if (detail) { const src = await sourcesOf(p.dir, { manifest: detail.manifest }); console.log(`\nsources (${src.rows.length}):`); const w = Math.max(10, ...src.rows.map((r) => r.key.length)); for (const r of src.rows) { const a = r.availability; const marks = [ r.cues === "missing" ? "NO CUES" : r.cues === "no-punctuation" ? "no-punctuation" : "", r.coverageGap ? `CUE GAP: cues end ${r.coverageGap.cuesEnd.toFixed(0)} s · ${r.coverageGap.clip} needs ${r.coverageGap.needs.toFixed(0)} s` : "", a ? (a.ok ? `ok ${ago(a.checkedAtMs)} ago` : `${a.state.toUpperCase()} ${ago(a.checkedAtMs)} ago`) : "never checked", r.cite.differs ? "citeUrl override" : "", ].filter(Boolean); console.log(` ${r.key.padEnd(w)} ${r.clips.join(",").padEnd(12)} ${marks.join(" · ")}`); } const snaps = await listSnapshots(p.dir); if (snaps.length) { console.log(`\nsnapshots (${snaps.length}):`); for (const sn of snaps) { console.log(` ${sn.rel.padEnd(44)} ${ago(sn.mtimeMs).padStart(4)} ago ${sn.legacy ? "legacy" : ""}${sn.label ? ` [${sn.label}]` : ""}`); } } } if (ds.length) { console.log(""); for (const d of sortDecisions(ds)) { console.log(` ${d.severity.toUpperCase().padEnd(8)} ${d.kind} ${d.target} — ${d.why}`); } } if (!decisionsAreComplete(p.kind)) { console.log(`\n(this kind's decisions are computed by the app — see /browse/decisions)`); } } async function cmdCheck() { const one = positional[0]; const refs = one ? [await pick(one)] : await projectRefs(); const rows = []; for (const p of refs) rows.push(...(await decisionsFor(p))); const sorted = sortDecisions(rows); const blocking = sorted.filter((d) => d.severity === "blocking"); if (json) { out({ ok: blocking.length === 0, blocking: blocking.length, decisions: sorted }); } else { for (const d of sorted) { if (d.severity === "info" && !has("--all-severities")) continue; console.log(`${d.severity.toUpperCase().padEnd(8)} ${d.project} ${d.kind} ${d.target}`); console.log(` ${d.why}`); } const partial = refs.filter((p) => !decisionsAreComplete(p.kind)).length; console.log( `\n${refs.length} project(s), ${blocking.length} blocking, ` + `${sorted.filter((d) => d.severity === "open").length} open`, ); if (partial) { console.log( `(${partial} of them are kinds whose decisions the app computes — this checked their routing and nothing else)`, ); } } // The whole point: a build script can gate on this. process.exit(blocking.length ? 1 : 0); } async function cmdDecisions() { const ds = await allDecisions(); if (json) return out(ds); let last = ""; for (const d of ds) { if (d.project !== last) { console.log(`\n${d.project}`); last = d.project; } console.log(` ${d.severity.toUpperCase().padEnd(8)} ${d.kind} ${d.target} — ${d.why}`); } console.log(`\n${ds.filter((d) => d.severity !== "info").length} waiting of ${ds.length}`); } async function cmdFolders() { const f = await folders(); if (json) return out([...f.values()]); for (const n of f.values()) { if (!n.path) continue; console.log(`${n.path} "${n.label}" ${n.projects.length} project(s)`); } } function cmdKinds() { const meta = PROJECT_KINDS.map((k) => ({ id: k.id, template: k.template, label: k.label, badge: k.badge, decisionKinds: k.decisionKinds, hasDecisions: !!k.decisions, })); if (json) return out(meta); for (const k of meta) { console.log(`${k.id.padEnd(14)} ${k.template.padEnd(16)} ${k.label}`); if (k.decisionKinds.length) console.log(` decisions: ${k.decisionKinds.join(", ")}`); } } /** * The roots, as the doctor reports them: where projects are read, where their * out/ goes, where the cache is -- and a cache left where it used to live * (under SONG_DATA, before release 17), which is derived and safe to delete * once `umtool index` has rebuilt the new one. */ async function rootsReport() { const isDir = async (p) => (await pathState(p)).kind === "dir"; const problem = await mediaRootProblem(); const oldPresent = OLD_CACHE_DIR !== CACHE_DIR && (await isDir(OLD_CACHE_DIR)); return { ok: !problem, reports: { path: REPORTS_ROOT, present: await isDir(REPORTS_ROOT) }, media: { path: MEDIA_ROOT, tiered: MEDIA_TIERED, present: await isDir(MEDIA_ROOT), problem }, cache: { path: CACHE_DIR, present: await isDir(CACHE_DIR), index: await isDir(INDEX_DIR) }, oldCache: oldPresent ? { path: OLD_CACHE_DIR, present: true, bytes: (await measureTree(OLD_CACHE_DIR)).bytes } : { path: OLD_CACHE_DIR, present: false }, }; } const mb = (n) => `${(n / 1024 ** 2).toFixed(1)} MB`; async function cmdDoctor() { // The one command that shells out on purpose. Seven version flags, ~100 ms. const r = await probeTools(); const roots = await rootsReport(); if (json) { // `ok` is what the exit status says (a script gates on either); the tools' // own verdict stays readable as toolsOk. out({ ...r, ok: r.ok && roots.ok, toolsOk: r.ok, roots }); } else { for (const t of r.tools) { const mark = t.present ? "ok " : t.required ? "MISSING" : "absent"; console.log( `${mark.padEnd(8)} ${t.id.padEnd(13)} ${(t.version ?? "").padEnd(14)} ${t.neededBy.join(", ")}` + (t.error ? `\n ${t.error}` : ""), ); } console.log( r.ok ? "\nthe report pipeline can build here" : "\nthe report pipeline is MISSING a tool it cannot run without", ); console.log("\nroots"); console.log(` reports ${roots.reports.path}${roots.reports.present ? "" : " (not there)"}`); console.log( roots.media.tiered ? ` media ${roots.media.path} — every project's out/ is linked here (UMTOOL_MEDIA_DIR)` + (roots.media.problem ? `\n MISSING ${roots.media.problem}` : "") : ` media = reports (UMTOOL_MEDIA_DIR unset): out/ stays in each project`, ); console.log( ` cache ${roots.cache.path}` + (roots.cache.index ? "" : " (no index yet — `umtool index` builds it; everything works without)"), ); if (roots.oldCache.present) { console.log( ` old cache ${roots.oldCache.path} ${mb(roots.oldCache.bytes ?? 0)} — the cache's old place, ` + "no longer read; derived, safe to delete", ); } } process.exit(r.ok && roots.ok ? 0 : 1); } // --------------------------------------------------------------------------- // storage: where each project's out/ lives, and moving it (release 17). // // umtool storage every project's out/: dir | link | DANGLING | none // umtool storage move-out

|--all out/ to the media root, a link left in its place // umtool storage move-back

|--all out/ back to a real directory in the project // umtool storage deliverables

--to media|local // clips/ and every share-*/ to the media root or // back, then storage.deliverables in the manifest // --dry-run measure and say; change nothing // // The movers are lib/report/storage.mjs's, which `umtool` and the app share. // A project is skipped (move-out/back) or refused (deliverables) while a // pipeline process works in it -- lib/report/busy.mjs says what that scan sees // (the app's build steps, cuts and share batches, which are all processes, and // the report's own scripts) and what it cannot (a hand-run command that is none // of them, another machine). The verify refuses a tree that keeps changing, but // a write it cannot see, in the last instant before the swap, would be lost // with the parked copy. // --------------------------------------------------------------------------- async function cmdStorage() { const sub = positional[0]; const dryRun = has("--dry-run"); const log = (m) => (json ? console.error(m) : console.log(m)); if (sub === "deliverables") return cmdStorageDeliverables(dryRun, log); if (sub !== "move-out" && sub !== "move-back") { // `umtool storage`, `umtool storage `, `umtool storage status []`. const one = sub === "status" ? positional[1] : sub; const refs = one ? [await pick(one)] : await projectRefs(); const rows = []; for (const p of refs) { const row = { id: p.id, ...(await outDirState(p.dir)) }; // A report video's deliverables, when it has any or its switch is set. const d = p.kind === "report-video" ? await deliverablesState(p.dir) : null; if (d && (d.dirs.length || d.value !== undefined)) { row.deliverables = { mode: d.mode, value: d.value, dirs: d.dirs }; } rows.push(row); } if (json) return out({ media: { path: MEDIA_ROOT, tiered: MEDIA_TIERED }, projects: rows }); console.log(MEDIA_TIERED ? `media root ${MEDIA_ROOT}` : "media root unset (UMTOOL_MEDIA_DIR): out/ stays in each project"); const word = { absent: "none", dir: "dir", link: "link", dangling: "DANGLING", other: "OTHER" }; for (const r of rows) { const what = word[r.state] ?? r.state; console.log(`${what.padEnd(9)} ${r.id}${r.target ? ` -> ${r.target}` : ""}`); if (r.deliverables) { const d = r.deliverables; console.log( ` deliverables: ${d.mode ?? `INVALID ${JSON.stringify(d.value)}`}` + (d.dirs.length ? ` · ${d.dirs.map((x) => `${x.name} ${word[x.state] ?? x.state}${x.leftovers.length ? ` (+${x.leftovers.join(", ")})` : ""}`).join(" · ")}` : ""), ); } } return; } const move = sub === "move-out" ? moveDirToMedia : moveDirToLocal; if (sub === "move-out" && !MEDIA_TIERED) { die("UMTOOL_MEDIA_DIR is not set: there is no media root to move out/ to. Set it to a directory on the media drive (outside the reports root)."); } const all = has("--all"); if (!all && !positional[1]) die(`which project? \`umtool storage ${sub} \` or --all`); const refs = all ? await projectRefs() : [await pick(positional[1])]; const results = []; let failed = 0; for (const p of refs) { // The app's jobs live in its memory, but the pipeline runs as processes: // one whose command line names this project is building it now. const busy = pipelineProcessesFor(p); if (busy.length) { results.push({ id: p.id, state: "busy", pids: busy }); if (!json) console.log(`${"busy".padEnd(12)} ${p.id} — a pipeline process is writing it (pid ${busy.join(", ")}); skipped`); continue; } try { const r = await move(p.dir, "out", { dryRun, log }); results.push({ id: p.id, ...r }); if (!json && r.state !== "absent") { const size = r.bytes !== undefined ? ` ${r.files} file(s), ${mb(r.bytes)}` : ""; console.log(`${r.state.padEnd(12)} ${p.id}${size}`); if (r.mediaCopyLeft) console.log(` left in place: ${r.mediaCopyLeft} (not deleted; remove it by hand once checked)`); } } catch (e) { failed += 1; results.push({ id: p.id, state: "failed", error: e?.message ?? String(e) }); if (!json) console.log(`${"FAILED".padEnd(12)} ${p.id}\n ${e?.message ?? e}`); } } const bytes = results.reduce((n, r) => n + (r.bytes ?? 0), 0); if (json) out({ ok: failed === 0, dryRun, bytes, results }); else { const moved = results.filter((r) => r.state === "moved" || r.state === "would-move").length; console.log( `\n${moved} project(s) ${dryRun ? "would move" : "moved"}, ${mb(bytes)}` + (failed ? `; ${failed} FAILED` : "") + (dryRun ? " — dry run, nothing changed" : ""), ); } if (failed) process.exit(1); } /** * `umtool storage deliverables --to media|local [--dry-run]`: move * clips/ and every share-* directory with the movers, then set storage.deliverables * through the manifest's writer. Refused while a pipeline process works in the * project (lib/report/busy.mjs): a build step naming its directory, a cut or a * share batch whose `--project` is this project's id, name or directory, the * report's own apply/build scripts by their working directory. * * What the refusal cannot see: a hand-run command that is none of those * scripts (an ffmpeg writing into clips/), and another machine writing over a * network share. The app's own jobs are processes and are seen; run from the * bench, this is itself a job, so the app's one-job-at-a-time rule keeps its * cuts and batches out as well. The movers' verify refuses a tree that keeps * changing, but a write it cannot see, in the instant between the verify and * the swap, would be lost with the parked copy. */ async function cmdStorageDeliverables(dryRun, log) { const to = val("--to"); if (to !== "media" && to !== "local") die("where to? `umtool storage deliverables --to media|local`"); const p = await pick(positional[1]); if (p.kind !== "report-video") die(`${p.id} is a ${p.kind} project — deliverables are a report video's`); const busy = pipelineProcessesFor(p); if (busy.length) { const msg = `${p.id}: a pipeline process is working in it (pid ${busy.join(", ")}) — nothing moved; run this when it has finished`; if (json) out({ ok: false, busy: busy, error: msg }); else console.error(msg); process.exit(1); } let r; try { r = await moveDeliverables(p.dir, to, { dryRun, log, writeMode: (dir, mode) => updateStorage(dir, { deliverables: mode }), }); } catch (e) { if (json) out({ ok: false, error: e?.message ?? String(e) }); else console.error(e?.message ?? String(e)); process.exit(1); } if (json) out({ project: p.id, ...r }); else { if (!r.results.length) console.log(`${p.id}: no clips/ and no share-*/ yet`); for (const x of r.results) { const size = x.bytes !== undefined ? ` ${x.files} file(s), ${mb(x.bytes)}` : ""; console.log(`${String(x.state === "failed" ? "FAILED" : x.state).padEnd(12)} ${x.name}${size}`); if (x.error) console.log(` ${x.error}`); if (x.mediaCopyLeft) console.log(` left in place: ${x.mediaCopyLeft} (not deleted; remove it by hand once checked)`); } const before = r.before ?? "local (unset)"; console.log( dryRun ? `\nstorage.deliverables: ${before} — would be ${to}; dry run, nothing changed` : r.ok ? `\nstorage.deliverables: ${r.written ? `${before} -> ${to}` : `${to} (unchanged)`}` : `\nstorage.deliverables left at ${before}: ${r.results.filter((x) => x.state === "failed").length} FAILED — fix that and run it again`, ); } if (!r.ok) process.exit(1); } async function cmdSnapshot() { const p = await pick(positional[0]); try { const r = await createSnapshot(p.dir, { label: val("--label") ?? null }); if (json) return out({ ok: true, ...r }); console.log(`${p.id}: ${r.rel}`); } catch (e) { die(e?.message ?? String(e)); } } async function cmdDiff() { const p = await pick(positional[0]); const rel = positional[1]; if (!rel) { const snaps = await listSnapshots(p.dir); if (!snaps.length) die(`${p.id} has no snapshots — \`umtool snapshot ${p.id}\` makes one`); die(`which snapshot?\n${snaps.map((sn) => ` ${sn.rel}`).join("\n")}`); } let before; try { before = await readSnapshot(p.dir, rel); } catch (e) { die(e?.message ?? String(e)); } const after = await readManifest(p.dir); const d = diffManifests(before, after); if (json) return out({ project: p.id, snapshot: rel, ...d }); console.log(`${rel} -> video.manifest.json (${d.entriesBefore} -> ${d.entriesAfter} entries)`); if (d.same) return console.log(" no change to the timeline"); for (const c of d.changes) console.log(` ${formatChange(c)}`); console.log( `\n${Object.entries(d.counts).map(([k, n]) => `${n} ${k}`).join(" · ")}`, ); } async function cmdExport() { const p = await pick(positional[0]); const format = val("--format"); if (!format) die(`--format is one of ${EXPORT_FORMATS.join(", ")}`); try { const r = await exportProject(p.dir, format, val("--variant") ? { variant: val("--variant") } : {}); if (json) return out(r); process.stdout.write(r.text); if (r.offsets.note) console.error(`# ${r.offsets.note}`); } catch (e) { die(e?.message ?? String(e)); } } async function cmdCheckSources() { // Print, never run -- the same posture as `build`. With no arguments, the // projects the dashboard would re-check: never checked, or older than 30 d. let refs; if (positional.length) { refs = []; for (const a of positional) refs.push(await pick(a)); } else { const all = await summaries(); const STALE = 30 * 86_400_000; refs = all.filter((p) => { const a = p.attrs ?? {}; if (!("sources" in a) || Number(a.sources) === 0) return false; const at = Number(a["sources-checked-at"] ?? 0); return !at || Date.now() - at > STALE; }); if (!refs.length) return console.log("every source was checked in the last 30 days"); } const steps = checkSourcesSteps(refs); if (json) return out({ projects: refs.map((p) => p.id), steps }); console.log(`# re-check sources of ${refs.length} project(s)`); for (const s of steps) { console.log(`# ${s.label}`); console.log(`(cd ${s.cwd} && ${s.argv.join(" ")})\n`); } console.log("# Nothing was run. The dashboard's Sources panel runs this chain through the job runner."); } function usage() { console.log( [ "umtool — the project tree, from a terminal", "", " umtool ls [--kind K] [--template T] [--state S] [--open] [--blocking]", " [--q TEXT] [--sort name|recent] [--json]", " umtool show [--json]", " umtool check [] [--json] exit 1 on anything blocking", " umtool decisions [--json]", " umtool folders [--json]", " umtool kinds [--json]", " umtool window [--start S] [--end E] [--lock|--lock-end|…]", " attribution too: --title, --date YYYY-MM-DD, --cite, --cite-url, --quote", " --correction TEXT what the REPORT got wrong here (never rendered)", " --verdict confirmed|incorrect what the walk said; incorrect needs --correction", " --cut-start S --cut-end E --lock-cut the cut the video renders", " an empty value (--title '') deletes the field", " umtool corrections what the report got wrong + the walk's coverage", " umtool build [--preset preview|fast|final] [--only ID]", " umtool index [--rebuild] [--prune] [--since MS] [--json]", " umtool new [--from ||/] [--site-origin URL] [--seed chapters]", " [--brand archilyzer-media] render.brand: the report-to-video brand preset", " umtool doctor [--json] exit 1 if the report pipeline is missing a tool", " or the media root is set and not there; the roots, and a leftover old cache", " umtool storage [] where each project's out/ lives (dir, link, DANGLING)", " umtool storage move-out|move-back |--all [--dry-run]", " umtool storage deliverables --to media|local [--dry-run] clips/ + share-*/, then the switch", " out/ to the media root (UMTOOL_MEDIA_DIR) and back; run when nothing is building", " umtool snapshot [--label L] copy the manifest into revisions/", " umtool diff what changed since that snapshot", " umtool export --format toc-bbcode|toc-markdown|description|chapters [--variant V]", " umtool check-sources […] prints the re-check chain (never-checked/old when no args)", " umtool notes [/||--all] the operator's notes, as markdown (open ones)", " umtool notes reply \"\" [--resolve] answer one; resolve | wontfix | reopen ", "", `reading ${REPORTS_ROOT} (set REPORTS_DIR to move it)`, "", "Run `check` before every build. It is what catches a manifest with no", "siteOrigin — the defect that shipped 19 QR codes reading `undefined/?v=…`.", ].join("\n"), ); } const COMMANDS = { ls: cmdLs, window: cmdWindow, corrections: cmdCorrections, build: cmdBuild, index: cmdIndex, new: cmdNew, show: cmdShow, check: cmdCheck, decisions: cmdDecisions, folders: cmdFolders, kinds: cmdKinds, doctor: cmdDoctor, storage: cmdStorage, snapshot: cmdSnapshot, diff: cmdDiff, export: cmdExport, "check-sources": cmdCheckSources, notes: async () => process.exit(await notesCommand(argv.slice(argv.indexOf("notes") + 1))), help: usage, }; const run = COMMANDS[cmd]; if (!run) die(`unknown command "${cmd}"\n\nRun \`umtool help\`.`); await run(); // --------------------------------------------------------------------------- // Writing. // --------------------------------------------------------------------------- async function cmdWindow() { const p = await pick(positional[0]); const clipId = positional[1]; if (!clipId) die("which clip? `umtool window --start S --end E`"); const patch = {}; const num = (n) => { const v = val(n); return v === undefined ? undefined : Number(v); }; if (num("--start") !== undefined) patch.start = num("--start"); if (num("--end") !== undefined) patch.end = num("--end"); // The cut the video renders, inside the reviewed extent. An empty value // clears it, like every other field here. for (const [flag, key] of [["--cut-start", "cutStart"], ["--cut-end", "cutEnd"]]) { if (val(flag) !== undefined) patch[key] = val(flag) === "" ? "" : Number(val(flag)); } // A flag and its negation, because `false` REMOVES the key -- the manifests // are read by humans and `"lockEnd": false` reads like a decision. for (const [flag, key] of [ ["--lock", "lock"], ["--lock-start", "lockStart"], ["--lock-end", "lockEnd"], // The cut, pinned against the resolver. `lock` is about the EXTENT. ["--lock-cut", "lockCut"], ]) { if (has(flag)) patch[key] = true; if (has(`--no-${flag.slice(2)}`)) patch[key] = false; } if (val("--note") !== undefined) patch.note = val("--note"); // The attribution: what the burned-in header and the QR will say. An empty // value DELETES the field, the same rule the flags keep -- `--title ''` is how // you go back to the archived record's own title. for (const [flag, key] of [ ["--title", "title"], ["--date", "date"], ["--cite", "cite"], ["--cite-url", "citeUrl"], ["--quote", "quote"], ["--correction", "correction"], // Not attribution: what the walk said about this clip. "confirmed" or // "incorrect" ('' clears it); the writer refuses `incorrect` with no note. ["--verdict", "verdict"], ]) { if (val(flag) !== undefined) patch[key] = val(flag); } if (!Object.keys(patch).length) die("nothing to change"); try { // Through the SAME writer the bench uses: 2 dp, the CLI's own formatting, // tmp+rename, one .bak. A second implementation here is how the two would // start disagreeing about a window. And through the same GUARD the bench's // routes use: on a generated manifest the change is also an `edit` note // for the agent that generates it, or the next rebuild undoes it silently. const { result: res, editNotes } = await withEditNotes(p, () => updateClip(p.dir, clipId, patch)); if (json) return out({ ok: true, ...res, editNotes }); console.log( `${clipId}: ${res.before.start}–${res.before.end} -> ${res.entry.start}–${res.entry.end}`, ); if (res.entry.cutStart != null) { console.log( ` cut: ${res.entry.cutStart}–${res.entry.cutEnd} ` + `(${(res.entry.cutEnd - res.entry.cutStart).toFixed(2)}s inside the extent)`, ); } else if (patch.cutStart !== undefined || patch.cutEnd !== undefined) { console.log(" cut cleared — the build will use the whole extent"); } const marks = ["lock", "lockStart", "lockEnd", "lockCut"].filter((k) => res.entry[k]); if (marks.length) console.log(` ${marks.join(", ")}`); // The line the renderer will burn in, printed so an attribution edit is // checkable without building anything. if (["title", "date", "cite", "citeUrl", "quote"].some((k) => patch[k] !== undefined)) { console.log( ` header: ${res.entry.title ?? "(the record's own title)"} · ` + `${res.entry.date ?? "(the record's upload date)"} @ ${hms(res.entry.cite ?? res.entry.start)}`, ); if (res.entry.citeUrl) console.log(` QR: ${res.entry.citeUrl}`); } if (patch.correction !== undefined) { console.log(res.entry.correction ? ` correction: ${res.entry.correction}` : " correction cleared"); } if (patch.verdict !== undefined || patch.correction !== undefined) { console.log(` review: ${clipVerdict(res.entry)}`); } if (editNotes) { const n = editNotes.added + editNotes.updated; console.log( editNotes.errors.length ? ` edit NOT noted (${editNotes.errors.join("; ")}) — ${editNotes.generatedBy} will overwrite it on the next rebuild` : n || editNotes.deleted ? ` edit noted for ${editNotes.generatedBy} (${editNotes.added} added, ${editNotes.updated} updated, ${editNotes.deleted} withdrawn) — port it into the generator's inputs` : ` (generated by ${editNotes.generatedBy}; nothing changed)`, ); } console.log(`\nRun resolve-windows to see whether the widener agrees:`); console.log(` node umtool/report-to-video/resolve-windows.mjs ${p.dir}/video.manifest.json`); } catch (e) { die(e?.message ?? String(e)); } } // What the REPORT got wrong, collected for the next pass. // // Markdown, and the moment link is the QR's own target -- so a bullet can be // pasted straight into a prompt or a sweep and the reader can open the exact // second the correction is about. That is the whole point: a correction that // cannot be checked is an assertion. async function cmdCorrections() { const p = await pick(positional[0]); const manifest = await readManifest(p.dir); if (!manifest) die("no manifest"); const rows = correctionsOf(manifest); // The walk's coverage rides along with the corrections, because the two // questions are one question: a list of three corrections means something // different when sixty clips have never been looked at. const review = reviewOf(manifest); if (json) return out({ project: p.id, corrections: rows, review }); const summary = `${review.total} clips: ${review.incorrect} incorrect, ` + `${review.confirmed} confirmed (${review.confirmedWithNote} with a note), ` + `${review.unreviewed} not yet reviewed` + (review.unreviewedIds.length ? ` (ids: ${review.unreviewedIds.join(", ")})` : ""); // Split by VERDICT, not by "has text". A note on a confirmed clip says why a // good clip's window moved; printing it under the same heading as a defect // sends the next pass off to fix something nobody complained about. const wrong = rows.filter((c) => c.verdict === "incorrect"); const notes = rows.filter((c) => c.verdict !== "incorrect"); const bullet = (c) => { console.log(`- **${c.id}** · ${c.channel}/${c.video} @ ${hms(c.at)} · <${c.href}>`); console.log(` ${c.text}`); }; console.log(`# Corrections for the next pass — ${manifest.title ?? p.id}\n`); console.log(`${summary}\n`); if (!wrong.length) console.log("No corrections: nobody has said the report got anything wrong here."); for (const c of wrong) bullet(c); if (notes.length) { console.log(`\n## Notes on confirmed clips\n`); console.log(`These clips are RIGHT. The note says why the window moved, or what a`); console.log(`reader should know -- not what to fix.\n`); for (const c of notes) bullet(c); } } async function cmdBuild() { const p = await pick(positional[0]); const preset = val("--preset") ?? "fast"; if (!(preset in PRESETS)) die(`--preset must be one of ${Object.keys(PRESETS).join(", ")}`); const manifest = await readManifest(p.dir); if (!manifest) die("no manifest"); const clipCount = (manifest.timeline ?? []).filter((e) => e.type === "clip").length; const steps = buildSteps(p, { preset, only: val("--only") ?? null, skipFetch: has("--skip-fetch"), clipCount, }); if (json) return out({ project: p.id, preset, steps }); // Print, never run. The app runs the chain through lib/jobs.ts, which owns the // cancellation, the timeouts and the process-group kill; a second runner here // would be a second set of those, and the one that got them right is not this. console.log(`# ${p.id} — ${PRESETS[preset].label}`); console.log(`# check first: umtool check ${p.id}\n`); for (const s of steps) { console.log(`# ${s.label}${s.timeoutMs ? ` (up to ${Math.round(s.timeoutMs / 60000)}m)` : ""}`); console.log(`(cd ${s.cwd} && ${s.argv.join(" ")})\n`); } if (!has("--dry")) { console.log("# Nothing was run. This prints the chain; the button on the project page runs it,"); console.log("# because cancellation and the process-group kill live in the server's job runner."); } } async function cmdIndex() { const ix = await openIndex(); if (!ix.ok) { if (json) return out({ ok: false, reason: "no index — the filesystem is the model anyway" }); console.log("no index (that is a normal state: everything still works, just slower)"); return; } if (has("--prune")) { const refs = await projectRefs(); const live = new Set(refs.map((p) => p.id)); let dropped = 0; for (const rec of ix.recent(10_000)) { if (live.has(rec.id)) continue; ix.del(rec.id); dropped += 1; } if (!json) console.log(`pruned ${dropped} record(s) for projects that are gone`); } if (has("--rebuild")) { // Re-reads every project and writes the record back. Never needed for // correctness -- every read verifies its own signature -- but it makes the // first page load after a big change fast instead of merely correct. for (const p of await projectRefs()) { const s = await summarise(p); const { kindById } = await import("../lib/projects/kinds.mjs"); const k = kindById(p.kind); const kindSig = k?.signature ? String(await k.signature(p.dir)) : "0"; ix.put({ ...s, sig: signRecord({ kindSig }) }); } if (!json) console.log("rebuilt"); } const sinceArg = val("--since"); if (sinceArg !== undefined) { const rows = ix.since(Number(sinceArg)); if (json) return out(rows); for (const r of rows) console.log(`${r.id} ${r.state} ${new Date(r.newestMtimeMs).toISOString()}`); console.log(`\n${rows.length} project(s) changed since ${new Date(Number(sinceArg)).toISOString()}`); await ix.close(); return; } const st = ix.stats(); if (json) return out(st); console.log(`${st.records} record(s), schema ${st.schema}`); console.log(st.path); console.log(`built ${st.builtAt ? new Date(st.builtAt).toISOString() : "never"}`); console.log("\nSafe to delete at any time. Every read verifies its own signature"); console.log("against the filesystem, so a stale record self-heals on the next load."); await ix.close(); } // --------------------------------------------------------------------------- // Scaffolding. The writer is lib/projects/scaffold.mjs, shared with the route. // --------------------------------------------------------------------------- async function cmdNew() { const slug = positional[0]; if (!slug) { die( "usage: umtool new [--kind report-video] [--title T]\n" + " [--from | | /]\n" + " [--site-origin ] [--seed chapters] [--brand archilyzer-media]", ); } const kind = val("--kind") ?? "report-video"; if (kind !== "report-video") die(`only report-video can be scaffolded from here, not ${kind}`); let r; try { r = await scaffoldReportVideo({ root: REPORTS_ROOT, slug, title: val("--title"), from: val("--from") ?? null, siteOrigin: val("--site-origin") ?? null, seed: val("--seed") ?? null, brand: val("--brand") ?? null, }); } catch (e) { die(e?.message ?? String(e)); } if (json) return out({ ok: true, ...r }); console.log(`${r.dir}`); if (r.seeded) { console.log(` video.manifest.json ${r.seeded} clip(s) SEEDED from digest chapters — review each`); } else { console.log(` video.manifest.json an EMPTY timeline — see README.md`); } if (r.brand) console.log(` render.brand ${r.brand} — palette, faces, title lockup, header mark, end card`); if (r.from?.kind === "report") console.log(` sweep-report.md copied from ${r.from.path}`); console.log(` README.md ${r.seeded ? "the seeded clips" : `${r.citations} citation(s)`} as a checklist`); for (const n of r.notes) console.log(` NOTE: ${n}`); console.log(""); console.log("Next, in order:"); if (r.siteOrigin) { console.log(` 1. provenance.siteOrigin is ${r.siteOrigin} — check it is the archive people will scan.`); } else { console.log(` 1. set provenance.siteOrigin — it is EMPTY, and \`check\` blocks until it is not.`); console.log(` Two finished videos shipped with QR codes that resolve to nothing.`); } console.log(` 2. write the timeline (umtool/docs/authoring.md)`); console.log(` 3. umtool check ${slug}`); console.log(` 4. umtool build ${slug} --preset fast`); }