Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Aumtool/bin/umtool.mjs | 302++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/ReportProject.tsx | 9++++-----
Mumtool/e2e/projects.spec.ts | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/projects/core.mjs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/projects/report.mjs | 13++++++++++++-
Mumtool/package.json | 3+++
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" } }