# umtool, from a terminal ``` pnpm --filter umtool exec umtool node umtool/bin/umtool.mjs ``` 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. ## Commands | | | |---|---| | `ls [--kind --template --state --open --blocking --q --sort --json]` | the index, as text or JSON | | `show [--json]` | one project: summary, the cut, per-clip status, decisions | | `check [] [--json]` | **exit 1 on anything blocking** — including an `out/`, `clips/` or `share-*/` link whose drive is not there (`storage-unreachable`) | | `decisions [--json]` | the inbox | | `folders [--json]` · `kinds [--json]` | the tree, the registry | | `window [--start S] [--end E] [--lock] [--lock-end] [--no-lock-end] [--note …]` | edit a window | | `build [--preset preview\|fast\|final] [--only ID]` | **prints** the chain | | `index [--rebuild] [--prune] [--since MS] [--json]` | the cache | | `new [--from \|\|/] [--site-origin URL] [--seed chapters]` | scaffold | | `doctor [--json]` | which tools are on this machine and where the roots are; **exit 1** if the report pipeline is missing one, or `UMTOOL_MEDIA_DIR` is set and not there | | `storage [] [--json]` | where each project's `out/` lives: dir, link, DANGLING, none — and a report's deliverables (`clips/`, `share-*/`) and its switch | | `storage move-out\|move-back \|--all [--dry-run] [--json]` | `out/` to the media root (a link left behind) and back — copied, mirrored, verified first; run when nothing is building | | `storage deliverables --to media\|local [--dry-run] [--json]` | `clips/` and every `share-*/` to the media root and back, then `storage.deliverables` in the manifest; refused while a build step, a cut or a share batch (by `--project` id, name or directory, as the app starts them) or the report's own scripts run in the project — a hand-run command that is none of these is not seen; **exit 1** on any failure (the switch is then left as it was) | | `snapshot [--label L]` | copy the manifest into `revisions/` | | `diff ` | added / removed / moved / window / retyped, by entry id | | `export --format toc-bbcode\|toc-markdown\|description\|chapters [--variant V]` | the posting artifacts, from the build's chapter offsets | | `check-sources […]` | **prints** the re-check chain; with no args, the never-checked and >30 d projects | A project argument is an exact id, a directory, or a **unique** basename. Two projects answering to one name is reported, never resolved by picking one. ## Environment `REPORTS_DIR`, `SONG_REPORTS_DIR`, `SONG_DIR`, `CHANNELS_DIR`, `UMTOOL_INDEX_DIR`, `UMTOOL_CACHE_DIR`, `UMTOOL_MEDIA_DIR` — which is how it is tested against the e2e fixture. The path defaults (`lib/paths.mjs`, `song/paths.mjs`): | Variable | Default | |---|---| | `SONG_DIR` | `/data` (`~/reports/quartering-uh-song/data`), through its realpath — a symlink there is the supported way to keep the data on another disk | | `SONG_REPORTS_DIR` | `~/reports/quartering-uh-song` | | `REPORTS_DIR` | `~/reports` (the parent of `SONG_REPORTS_DIR` when that is set) | | `UMTOOL_MEDIA_DIR` | unset = `REPORTS_DIR`: `out/` stays in each project. Set, each project's `out` is a link to the same path under it ([folders.md](folders.md)) | | `UMTOOL_CACHE_DIR` | `$XDG_CACHE_HOME/archilyzer/umtool`, else `~/.cache/archilyzer/umtool` (it was `/.cache/umtool`) | | `CHANNELS_DIR` | `$TRANSCRIPTS_DIR/channels`, else the checkout's `transcripts/channels` | | `SITES_DIR` | `$TRANSCRIPTS_DIR/sites`, else the checkout's `transcripts/sites` | "The checkout" is the one the CLI script itself lives in (`/umtool/bin/umtool.mjs`), whatever directory it is run from — `umtool notes --all` from a report workspace under `REPORTS_DIR` reads the corpus's sites. The app (`next dev`/`start`) finds it by walking up from its cwd to `pnpm-workspace.yaml`. | `VIDEO_ROOT` (`song/spec.mjs`, `song/video-dir.mjs`) | `~/reports/quartering-uh-song/videos` | ## `check` is the one to run before every build ``` $ umtool check 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 out/quartering-walmart-shelves.mp4 12 project(s), 2 blocking, 1 open $ echo $? 1 ``` Those are the two defects that shipped in finished videos. It is a few seconds in front of a twenty-minute build, and it exits non-zero so a script can gate on it. ## Two deliberate limits **`build` prints, it does not run.** Cancellation, per-step timeouts and the process-group kill live in the server's job runner; a second runner here would be a second, worse set of those. Use the button on the project page, or paste the printed commands. **`check` cannot compute the song reducer.** Nine decision kinds are TypeScript beside `readSong()`, the loudness cache and the accepted cover set. It reports how many projects it only checked the routing of, and points at `/browse/decisions`. ## `window` goes through the same writer the bench does 2 dp, the CLI's own formatting, tmp+rename, one `.bak`. A second implementation is how the two would start disagreeing about where a clip ends. And through the same guard (`lib/report/edit-guard.mjs`): on a GENERATED manifest (`generatedBy`) each change is also an `edit` note in the project's notes.json, for the agent to port into the generator's inputs — the next rebuild would otherwise undo it without a trace: ``` $ umtool window polemic-x e1 --start 11 e1: 10–20 -> 11–20 edit noted for polemics/video/make-videos.py (1 added, 0 updated, 0 withdrawn) — port it into the generator's inputs ``` ``` $ umtool window ferret-rescue c01 --start 43.12 --end 61.48 --lock-end c01: 43.12–61.48 -> 43.12–61.48 lockEnd ``` `--no-lock-end` **removes** the key rather than writing `false`: these manifests are read by humans, and `"lockEnd": false` reads like a decision. ## `show` lists the sources and the snapshots One row per distinct `(channel, video)`: which clips use it, whether its cue file is present / missing / unpunctuated, **cue coverage** (the last cue's end against the latest clip end on that source — `CUE GAP: cues end 880 s · c03 needs 897 s`), availability from the last recorded preflight with its age, and whether the cite target is derived or a per-clip `citeUrl` override (the Rumble two-ids case). Then every snapshot under `revisions/` and every legacy `video.manifest*.bak` / `video.manifest..json`, by mtime, marked `legacy`. ## `export` derives what used to be hand-made `toc-bbcode` reproduces the shape of `quartering-gout/toc.bbcode.txt` row for row (`time · clip title · [url=]open[/url]`); `toc-markdown` the same as a pipe table; `description` is title, subtitle, `mm:ss — title — ` rows and the source list; `chapters` is YouTube's `00:00 Title` form, every entry. Times come from `out//chapters.ffmeta` (or the pre-variant `out/chapters.ffmeta`), else from `segmentOffsets()` over the segments; with neither it refuses and says why. Cite URLs follow the QR's rule: the per-clip `citeUrl` when set, else the derived viewer moment. ## `new` writes an EMPTY timeline, on purpose It would be easy to derive first-draft clips from a report's citations — the shape is regular. It is not done, and that is the honest position rather than a missing feature: a report records **one** second per citation, a window needs a start and an end taken from `transcript.cues.json`, and matching a quote to its cues is the actual work. A generated timeline of guessed windows would look finished and be wrong, and every clip would have to be opened anyway. So it writes what can be known, lists the citations it found as a **checklist**, and leaves `siteOrigin` empty so `check` blocks until somebody sets it. `--from` is detected by **shape**, never guessed: a `.md` path is a report; a viewer share URL (`…/?v=%2F&t=`) or a bare `/` is a video ref, which sets `provenance.channelSlug`, records one citation at `t`, and (from a share URL) takes the origin as `siteOrigin` unless `--site-origin` says otherwise. Anything else is an error. **`--seed chapters`** is the one exception to the empty timeline, and it invents nothing: with a video ref and `CHANNELS_DIR`, it reads `ai-digest.json` + `ai-digest.overrides.json` directly (later source wins per id, `enabled: false` drops), and writes one unlocked clip per chapter — `start` is the chapter's start, `end` the next chapter's, the last from `metadata.info.json`'s duration or else **omitted and reported**. The README lists them as "seeded from digest chapters — review each". It refuses when the video directory or the digest is missing. Local media files are not a source: the manifest model is archive ids with cue files.