commit 88f972bb90a71f93f192e1e16a38151f616d2b70
parent 2ab4c35f0b72e4bbe5eb73fa6f9c910ed09f3baa
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 18 Aug 2026 21:55:01 -0400
umtool: a CLI, and `check` finds the two defects that already shipped
`umtool ls / show / check / decisions / folders / kinds`, reading 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. It honours REPORTS_DIR /
SONG_REPORTS_DIR / SONG_DIR / CHANNELS_DIR, which is how it is tested against
the e2e fixture.
The acceptance test, run against the real ~/reports:
BLOCKING ferret-rescue manifest-invalid provenance.siteOrigin
`http://localhost:3000` — every QR in this cut resolves to nothing
on anyone else's phone
BLOCKING quartering-employee-count manifest-invalid provenance.siteOrigin
missing — every QR in this cut encodes `undefined/?v=…`
OPEN quartering-walmart-shelves stale-build
12 project(s), 2 blocking, 1 open exit 1
Those are the two defects that shipped in finished videos. `check` exits
non-zero so a build script can gate on it, which is the point: it is a few
seconds in front of a twenty-minute build.
What it does NOT do, and says so: nine of the decision kinds are the song
reducer, computed in TypeScript against readSong(), the loudness cache and the
accepted cover set. Porting them would be a second implementation of the thing
this design exists to avoid having two of, so the CLI covers every report-video
decision and every routing one, and prints how many projects it only checked the
routing of.
`umtool show` immediately caught a disagreement between two halves of this
feature: it pilled four ferret-rescue clips "ends mid-sentence" while the
decisions inbox stayed silent about them. The inbox was right -- those uploads'
ASR emitted no terminators at all, so EVERY clip in them "ends mid-sentence" and
the fact says nothing about the cut. `endsSentence` is now null rather than
false when the source cannot support the question, and the pill says "source
unpunctuated". A confident wrong answer replaced by an admission.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat:
6 files changed, 541 insertions(+), 6 deletions(-)
diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs
@@ -0,0 +1,302 @@
+#!/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 <project> [--json]
+// umtool check [<project> | --all] [--json] exit 1 on anything blocking
+// umtool decisions [--json]
+// umtool folders [--json]
+// umtool kinds [--json]
+import process from "node:process";
+import {
+ PROJECT_KINDS,
+ REPORTS_ROOT,
+ decisionsAreComplete,
+ decisionsFor,
+ folders,
+ projectRefs,
+ resolveProject,
+ summarise,
+} from "../lib/projects/core.mjs";
+import { readClipDetail } from "../lib/projects/report.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) return out({ ...s, decisions: ds, entries: detail?.entries ?? null });
+
+ 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) {
+ if (e.kind === "card") {
+ console.log(` ${e.id.padEnd(5)} card ${e.seconds}s ${e.heading ?? e.title ?? ""}`);
+ 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}` : "",
+ ].filter(Boolean);
+ 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)` +
+ `${marks.length ? ` [${marks.join(" · ")}]` : ""}`,
+ );
+ }
+ }
+
+ 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(", ")}`);
+ }
+}
+
+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 <project> [--json]",
+ " umtool check [<project>] [--json] exit 1 on anything blocking",
+ " umtool decisions [--json]",
+ " umtool folders [--json]",
+ " umtool kinds [--json]",
+ "",
+ `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,
+ show: cmdShow,
+ check: cmdCheck,
+ decisions: cmdDecisions,
+ folders: cmdFolders,
+ kinds: cmdKinds,
+ help: usage,
+};
+
+const run = COMMANDS[cmd];
+if (!run) die(`unknown command "${cmd}"\n\nRun \`umtool help\`.`);
+await run();
diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx
@@ -163,6 +163,8 @@ export default async function ReportProject({
</li>
);
}
+ // `endsSentence === null` means the source has no punctuation to
+ // read, which is a different thing to say than "this cut is fine".
const midSentence = e.endsSentence === false && !e.lockEnd && !e.lock;
return (
<li
@@ -192,11 +194,8 @@ export default async function ReportProject({
{!e.hasCues && (
<Pill tone={TONE.blocking}>no cues</Pill>
)}
- {midSentence && (
- <Pill tone="open">
- ends mid-sentence
- </Pill>
- )}
+ {midSentence && <Pill tone="open">ends mid-sentence</Pill>}
+ {e.noPunctuation && <Pill>source unpunctuated</Pill>}
{e.proposed && (
<Pill tone="open">
widener would move it
diff --git a/umtool/e2e/projects.spec.ts b/umtool/e2e/projects.spec.ts
@@ -282,3 +282,86 @@ test("a kind the registry has never seen appears everywhere, with no code edit",
expect(j.meta).toContain("fix");
expect(j.detected).toEqual({ kind: "fixture-kind", template: "fixture" });
});
+
+// ---------------------------------------------------------------------------
+// The CLI, against the same fixture.
+//
+// It shares lib/projects/*.mjs with the app, so these are not really testing a
+// second implementation -- they are testing that there ISN'T one.
+// ---------------------------------------------------------------------------
+
+// Same path playwright.config.ts builds it at.
+const FIXTURE = path.join(UMTOOL, ".e2e-song");
+const cliEnv = {
+ ...process.env,
+ SONG_REPORTS_DIR: path.join(FIXTURE, "reports"),
+ SONG_DIR: path.join(FIXTURE, "data"),
+ CHANNELS_DIR: path.join(FIXTURE, "channels"),
+};
+const umtool = (args: string[]) =>
+ execFileSync("node", ["bin/umtool.mjs", ...args], { cwd: UMTOOL, encoding: "utf8", env: cliEnv });
+
+test("umtool ls sees the same projects the index does", () => {
+ const rows = JSON.parse(umtool(["ls", "--json"])) as { id: string; kind: string }[];
+ const ids = rows.map((r) => r.id);
+
+ expect(ids).toContain("reports/report-fixture");
+ expect(ids).toContain("reports/bike-fixture");
+ // Including the one that has no URL of its own -- it is listed, never dropped.
+ expect(ids).toContain("find");
+ expect(rows.find((r) => r.id === "reports/bike-fixture")?.kind).toBe("sweep-report");
+});
+
+test("umtool check exits 1 on a manifest that would ship dead QR codes", () => {
+ let code = 0;
+ let stdout = "";
+ try {
+ stdout = umtool(["check", "no-origin-fixture"]);
+ } catch (e) {
+ const err = e as { status: number; stdout: string };
+ code = err.status;
+ stdout = err.stdout;
+ }
+ // A build script can gate on this, which is the whole reason it exists.
+ expect(code).toBe(1);
+ expect(stdout).toContain("BLOCKING");
+ expect(stdout).toContain("siteOrigin");
+
+ // And a manifest with a real origin does not.
+ const ok = umtool(["check", "report-fixture", "--json"]);
+ expect(JSON.parse(ok).ok).toBe(true);
+});
+
+test("umtool show reports the same window facts the page draws", () => {
+ const j = JSON.parse(umtool(["show", "report-fixture", "--json"]));
+ const byId = Object.fromEntries(j.entries.map((e: { id: string }) => [e.id, e]));
+
+ // c01 ends inside a run-on cue, and the widener would carry it to the next
+ // sentence end at 9.0. Knowing that BEFORE running --write is the point.
+ expect(byId.c01.endsSentence).toBe(false);
+ expect(byId.c01.proposed).toEqual({ start: 3, end: 9 });
+ // c02 ends on a full stop, so there is nothing to propose.
+ expect(byId.c02.endsSentence).toBe(true);
+ expect(byId.c02.proposed).toBeNull();
+ // c03's source has no punctuation at all: the question cannot be answered, and
+ // `null` says so rather than a confident `false`.
+ expect(byId.c03.endsSentence).toBeNull();
+ expect(byId.c03.noPunctuation).toBe(true);
+
+ // The cached window is the one the build would fetch, found by containment.
+ expect(byId.c01.cached.name).toBe("vid1_0.00-9.00.mp4");
+ expect(byId.c02.cached).toBeNull();
+});
+
+test("umtool refuses a name that means two projects rather than picking one", () => {
+ // `alpha` is unique here, so it resolves -- the refusal path is exercised by
+ // asking for something that is not there at all, which must also not guess.
+ expect(JSON.parse(umtool(["show", "alpha", "--json"])).kind).toBe("song");
+ let code = 0;
+ try {
+ umtool(["show", "definitely-not-a-project"]);
+ } catch (e) {
+ code = (e as { status: number }).status;
+ }
+ expect(code).toBe(2);
+});
diff --git a/umtool/lib/projects/core.mjs b/umtool/lib/projects/core.mjs
@@ -0,0 +1,137 @@
+// The registry, assembled — for callers with no server.
+//
+// lib/projects.ts is the app's façade: it memoises, it dispatches song
+// decisions into the TypeScript reducer, and it is full of `import type`. This
+// is the same thing for `umtool` on the command line, in plain ESM, sharing the
+// walk, the kinds and every per-kind module with the app so the two cannot
+// disagree about what a project is or what is wrong with it.
+//
+// WHAT IT DOES NOT DO: the song reducer. Nine of the decision kinds
+// (unjudged-variant, stale-recipe, loudness, …) are computed by lib/decisions.ts
+// against readSong(), readSpec(), the loudness cache and the accepted cover set,
+// all of which are TypeScript beside the app. Porting them would be a second
+// implementation of the thing this file exists to avoid having two of. So the
+// CLI reports every report-video decision and every routing one — which is what
+// `umtool check` is for, since the two defects that shipped in real videos were
+// both manifest problems — and says plainly that song decisions live in the UI.
+import path from "node:path";
+import { REPORTS_ROOT } from "../paths.mjs";
+import { PROJECT_KINDS, kindById } from "./kinds.mjs";
+import { collapseFolders, foldersFor, walkProjects } from "./walk.mjs";
+
+export { PROJECT_KINDS, REPORTS_ROOT };
+
+export const ROUTING_DECISION_KINDS = ["shadowed-name", "unroutable-name", "ambiguous-project"];
+
+export async function projectRefs(root = REPORTS_ROOT) {
+ return walkProjects(root);
+}
+
+export async function summarise(p, root = REPORTS_ROOT) {
+ const k = kindById(p.kind);
+ const ctx = { ...p, root };
+ let body = {};
+ if (k?.summarise) {
+ try {
+ body = await k.summarise(ctx);
+ } catch (err) {
+ body = { flags: [`could not be read: ${err?.message ?? err}`] };
+ }
+ }
+ const flags = [...(body.flags ?? [])];
+ if (p.routing === "shadowed") flags.push(`/${p.id.split("/")[0]} is a tool page`);
+ if (p.routing === "unroutable") flags.push("name will not route");
+ if (p.ambiguousWith) flags.push(`two kinds match: ${p.ambiguousWith.join(", ")}`);
+ return {
+ ...p,
+ badge: k?.badge ?? p.kind,
+ title: body.title ?? p.name,
+ subtitle: body.subtitle ?? null,
+ state: body.state ?? "draft",
+ newestMtimeMs: body.newestMtimeMs ?? 0,
+ facts: body.facts ?? [],
+ flags,
+ attrs: body.attrs ?? {},
+ haystack: `${body.haystack ?? ""} ${p.id} ${p.kind} ${p.template}`.toLowerCase(),
+ };
+}
+
+/** Every decision this side can compute for one project. */
+export async function decisionsFor(p, root = REPORTS_ROOT) {
+ const out = [];
+ const k = kindById(p.kind);
+ const ctx = { ...p, root };
+ if (k?.decisions) {
+ try {
+ const s = k.summarise ? await k.summarise(ctx) : null;
+ out.push(...(await k.decisions(ctx, s)));
+ } catch (err) {
+ out.push({
+ kind: "unreadable",
+ project: p.id,
+ target: p.id,
+ why: `could not be read: ${err?.message ?? err}`,
+ severity: "blocking",
+ at: 0,
+ });
+ }
+ }
+ if (p.routing === "shadowed") {
+ out.push({
+ kind: "shadowed-name",
+ project: p.id,
+ target: p.id.split("/")[0],
+ why: `/browse/${p.id.split("/")[0]} is a tool page and always wins the route`,
+ severity: "blocking",
+ at: 0,
+ });
+ }
+ if (p.routing === "unroutable") {
+ out.push({
+ kind: "unroutable-name",
+ project: p.id,
+ target: p.name,
+ why: "this name cannot be a URL segment, so the project has no address of its own",
+ severity: "info",
+ at: 0,
+ });
+ }
+ if (p.ambiguousWith) {
+ out.push({
+ kind: "ambiguous-project",
+ project: p.id,
+ target: p.ambiguousWith.join(" + "),
+ why: "two kinds match this directory — it is read as the first, which is a bug",
+ severity: "blocking",
+ at: 0,
+ });
+ }
+ return out.map((d) => ({ ...d, project: p.id }));
+}
+
+/** Whether this kind's decisions are fully computable without the app. */
+export const decisionsAreComplete = (kind) => !!kindById(kind)?.decisions;
+
+/**
+ * Resolve a user-typed project argument.
+ *
+ * An exact id wins; otherwise a UNIQUE basename does. Two projects answering to
+ * one name is the reason ids are paths, so it is reported rather than resolved.
+ */
+export async function resolveProject(arg, root = REPORTS_ROOT) {
+ const refs = await projectRefs(root);
+ const exact = refs.find((p) => p.id === arg);
+ if (exact) return { project: exact };
+ // A path the user typed relative to the cwd, or absolute.
+ const abs = path.resolve(arg);
+ const byDir = refs.find((p) => p.dir === abs);
+ if (byDir) return { project: byDir };
+ const named = refs.filter((p) => p.name === arg);
+ if (named.length === 1) return { project: named[0] };
+ if (named.length > 1) return { ambiguous: named };
+ return {};
+}
+
+export async function folders(root = REPORTS_ROOT) {
+ return collapseFolders(foldersFor(await projectRefs(root)));
+}
diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs
@@ -553,7 +553,17 @@ export async function readClipDetail(dir, { manifest = null } = {}) {
.sort((a, b) => b.to - b.from - (a.to - a.from))[0] ?? null;
const endCue = doc ? cueAt(doc.cues, e.end, "end") : null;
- const endsSentence = endCue ? ENDS_SENTENCE.test(endCue.text ?? "") : null;
+ // NULL means "cannot be known", and that is a third answer worth having.
+ //
+ // In an upload whose ASR emitted no terminators, every clip "ends
+ // mid-sentence" and the fact says nothing about the cut. Reporting it as
+ // FALSE put four ferret-rescue clips under a warning the decisions inbox
+ // (which has always had this gate) correctly stayed silent about -- the page
+ // and the inbox disagreeing about the same clip. The honest answer is that
+ // the source cannot support the question.
+ const noPunctuation = !!doc && doc.punctuationRate < 0.1;
+ const endsSentence =
+ !endCue || noPunctuation ? null : ENDS_SENTENCE.test(endCue.text ?? "");
// What resolve-windows WOULD do, computed in-process because widen() is pure
// once the cues are read. It is the difference between "run the widener and
// see" and knowing before you touch anything.
@@ -577,6 +587,7 @@ export async function readClipDetail(dir, { manifest = null } = {}) {
punctuationRate: doc?.punctuationRate ?? null,
endCueText: endCue?.text ?? null,
endsSentence,
+ noPunctuation,
proposed,
cached: cached ? { name: cached.name, from: cached.from, to: cached.to } : null,
widest: widest ? { name: widest.name, from: widest.from, to: widest.to } : null,
diff --git a/umtool/package.json b/umtool/package.json
@@ -28,5 +28,8 @@
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
+ },
+ "bin": {
+ "umtool": "bin/umtool.mjs"
}
}