commit af054e224d5cf9021fe1696411e7c60fd0e6bf55
parent 19e0f6b76ec44d4e137752be52f3c31a9f61f0bb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 12:12:27 -0400
umtool: its own e2e ports per worktree, a hermetic mix.spec, `umtool window` notes its edits, two-block Note, SITES_DIR from the CLI's checkout, /sites is "articles"
Six debts from the articles + notes merge:
- Per-worktree e2e ports. umtool's `e2e` script now runs through the
worktree injector (`worktree.mjs run`), so the documented
`pnpm --filter umtool run e2e` gets the worktree's own ports. And the
injector's index was the FIRST worktree root containing the cwd: every
`.claude/worktrees/<agent>` is nested in the main checkout, so all of them
got offset 0 — the main checkout's ports — for every suite. It is the most
specific root now (indexForPath, tested).
- mix.spec's order dependence (FACTS: `:166`/`:201` red in every full run,
green alone). Specs before it fetch windows through the editor stub into
the fixture corpus's clips/, which the bench folds in with a project's
clips-raw. The file now moves the corpus's clip windows aside for its
tests and puts them back after; and the render-scratch test matches the
option label exactly instead of as a substring.
- `umtool window` goes through the routes' edit guard: on a generated
manifest each change is an `edit` note for the generator. The guard is
lib/report/edit-guard.mjs now (plain JS, so the CLI runs the same one);
guard.ts only types it.
- A selection that runs across two sections gets its Note button: it is
anchored in the section it starts in, to that section's end (else the
one it ends in, from its start). A spec covers it.
- The CLI finds SITES_DIR (and CHANNELS_DIR) from its OWN checkout:
REPO_ROOT is the repo of `<repo>/umtool/bin/<cli>.mjs` when that is the
entry script, so `umtool notes --all` run from a ~/reports workspace reads
the corpus's sites. The app keeps the cwd walk. umtool/lib/*.test.mjs
joins test:scripts.
- The nav entry and crumbs for /sites read "articles" (lowercase, like every
umtool nav entry): the editor's /sites is the sites themselves. The URL
is unchanged; sites.spec asserts the new name and that no "sites" link
is left.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
20 files changed, 446 insertions(+), 64 deletions(-)
diff --git a/package.json b/package.json
@@ -23,7 +23,7 @@
"heavy": "node scripts/queue-lock.mjs --heavy --",
"wt": "node scripts/worktree.mjs",
"e2e:sharded": "node scripts/run-sharded-e2e.mjs",
- "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs umtool/lib/report/*.test.mjs umtool/lib/annotations/*.test.mjs umtool/lib/articles/*.test.mjs",
+ "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs umtool/lib/*.test.mjs umtool/lib/report/*.test.mjs umtool/lib/annotations/*.test.mjs umtool/lib/articles/*.test.mjs",
"test": "pnpm -r --no-bail --no-sort --workspace-concurrency=1 run test; a=$?; pnpm run test:scripts; b=$?; [ $a -eq 0 ] && [ $b -eq 0 ]",
"typecheck": "pnpm -r --no-bail --no-sort --workspace-concurrency=1 exec tsc --noEmit",
"lint": "pnpm --filter export run lint",
diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs
@@ -76,12 +76,28 @@ function offsetForIndex(index) {
// Index of the worktree containing `dir` (default: cwd) in the worktree list.
function indexForDir(dir = process.cwd()) {
const trees = listWorktrees();
- const target = realpath(dir);
- for (let i = 0; i < trees.length; i++) {
- const root = realpath(trees[i].path);
- if (target === root || target.startsWith(root + path.sep)) return i;
+ return indexForPath(
+ trees.map((t) => realpath(t.path)),
+ realpath(dir),
+ );
+}
+
+// THE MOST SPECIFIC root containing `target`, by its index in `roots` (0 when
+// none does). Not the first: a worktree NESTED in the main checkout -- every
+// `.claude/worktrees/<agent>` is -- is also "inside" the main root, which
+// comes first in the list, so a first-match gave every agent worktree the
+// main checkout's ports (offset 0) and its e2e servers collided on them.
+export function indexForPath(roots, target) {
+ let best = 0;
+ let bestLen = -1;
+ for (let i = 0; i < roots.length; i++) {
+ const root = roots[i];
+ if ((target === root || target.startsWith(root + path.sep)) && root.length > bestLen) {
+ best = i;
+ bestLen = root.length;
+ }
}
- return 0;
+ return best;
}
// Read a simple KEY=VALUE file (e.g. .worktree-env) into an object.
diff --git a/scripts/worktree.test.mjs b/scripts/worktree.test.mjs
@@ -5,7 +5,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import path from "node:path";
-import { worktreeDirFor } from "./worktree.mjs";
+import { indexForPath, worktreeDirFor } from "./worktree.mjs";
const MAIN = "/home/u/Projects/yt-dlp-transcript-browser";
const SIBLING = path.dirname(MAIN);
@@ -44,3 +44,19 @@ test("nothing escapes the sibling directory", () => {
assert.equal(dir, path.join(SIBLING, "..-..-etc-passwd"));
assert.equal(path.dirname(dir), SIBLING);
});
+
+test("a worktree nested in the main checkout gets its OWN index, not the main one's", () => {
+ // `.claude/worktrees/<agent>` lives INSIDE the main checkout. A first-match
+ // walk found the main root first and gave every such worktree offset 0 --
+ // the main checkout's ports -- so two agents' umtool suites bound the same
+ // 3051/3052.
+ const roots = [MAIN, path.join(SIBLING, "feature-x"), path.join(MAIN, ".claude", "worktrees", "agent-1")];
+ assert.equal(indexForPath(roots, MAIN), 0);
+ assert.equal(indexForPath(roots, path.join(MAIN, "umtool")), 0);
+ assert.equal(indexForPath(roots, path.join(SIBLING, "feature-x", "editor")), 1);
+ assert.equal(indexForPath(roots, path.join(MAIN, ".claude", "worktrees", "agent-1")), 2);
+ assert.equal(indexForPath(roots, path.join(MAIN, ".claude", "worktrees", "agent-1", "umtool")), 2);
+ // A sibling whose name only STARTS like the main root is not inside it.
+ assert.equal(indexForPath(roots, `${MAIN}-other`), 0);
+ assert.equal(indexForPath(roots, "/elsewhere"), 0);
+});
diff --git a/umtool/app/sites/[site]/[report]/evidence/page.tsx b/umtool/app/sites/[site]/[report]/evidence/page.tsx
@@ -43,9 +43,9 @@ export default async function EvidenceWalkPage({
return (
<div className="flex h-full flex-col">
<BrowseHeader
- active="sites"
+ active="articles"
crumbs={[
- { href: "/sites", label: "sites" },
+ { href: "/sites", label: "articles" },
{ href: `/sites/${site.siteId}`, label: site.siteId },
{ href: `/sites/${site.siteId}/${reportId}`, label: reportId },
{ label: "evidence" },
diff --git a/umtool/app/sites/[site]/[report]/page.tsx b/umtool/app/sites/[site]/[report]/page.tsx
@@ -94,8 +94,8 @@ export default async function ArticlePage({
const header = (
<BrowseHeader
- active="sites"
- crumbs={[{ href: "/sites", label: "sites" }, { href: `/sites/${site.siteId}`, label: site.siteId }, { label: reportId }]}
+ active="articles"
+ crumbs={[{ href: "/sites", label: "articles" }, { href: `/sites/${site.siteId}`, label: site.siteId }, { label: reportId }]}
note={`${notes.doc?.notes.filter((n) => n.status === "open").length ?? 0} open notes`}
/>
);
diff --git a/umtool/app/sites/[site]/page.tsx b/umtool/app/sites/[site]/page.tsx
@@ -47,8 +47,8 @@ export default async function SitePage({
return (
<div className="flex h-full flex-col">
<BrowseHeader
- active="sites"
- crumbs={[{ href: "/sites", label: "sites" }, { label: row.title }]}
+ active="articles"
+ crumbs={[{ href: "/sites", label: "articles" }, { label: row.title }]}
note={`${row.published} published · ${row.drafts} drafts · ${row.openNotes} open notes`}
/>
<main className="deck-main flex-1 space-y-6 p-4">
diff --git a/umtool/app/sites/page.tsx b/umtool/app/sites/page.tsx
@@ -35,7 +35,7 @@ export default async function SitesPage({ searchParams }: { searchParams: Promis
return (
<div className="flex h-full flex-col">
- <BrowseHeader active="sites" crumbs={[{ label: "sites" }]} note={`${all.length} sites · ${articles.length} articles · ${open} open notes`} />
+ <BrowseHeader active="articles" crumbs={[{ label: "articles" }]} note={`${all.length} sites · ${articles.length} articles · ${open} open notes`} />
<main className="deck-main flex-1 p-4">
<div className="mb-3 flex flex-wrap items-center gap-1.5">
<span className="micro">site</span>
diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs
@@ -63,6 +63,7 @@ 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";
@@ -795,9 +796,11 @@ async function cmdWindow() {
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.
- const res = await updateClip(p.dir, clipId, patch);
- if (json) return out({ ok: true, ...res });
+ // 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}`,
);
@@ -826,6 +829,16 @@ async function cmdWindow() {
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) {
diff --git a/umtool/components/AppNav.tsx b/umtool/components/AppNav.tsx
@@ -6,7 +6,7 @@ import NavGroup from "./NavGroup";
// is waiting, the two benches that are not a project (mix, find), and the song
// piles folded under one entry.
//
-// SEVEN visible entries (home, browse, decisions, sites, mix, find, song ▸),
+// SEVEN visible entries (home, browse, decisions, articles, mix, find, song ▸),
// and the cap is still NINE. A tenth wraps the header on
// a laptop, and a nav that wraps stops reading as one row of places and starts
// reading as a list. The next tool goes UNDER one of these, not beside them --
@@ -32,8 +32,10 @@ export default function AppNav({ active }: { active: string }) {
// browse because that is where every decision it names gets settled.
{ href: "/browse/decisions", label: "decisions" },
// Every site's articles -- published and drafts -- with their notes, their
- // evidence and the workspace they were written in.
- { href: "/sites", label: "sites" },
+ // evidence and the workspace they were written in. Named for what it lists:
+ // the editor's /sites is the sites themselves, and two "sites" a tab apart
+ // were two places with one name. The URL stays /sites.
+ { href: "/sites", label: "articles" },
{ href: "/mix", label: "mix" },
// Every occurrence of a word across the corpus. It sits with browse because
// what it retrieves is raw material for a build, not a pile to judge.
diff --git a/umtool/components/articles/anchorDom.ts b/umtool/components/articles/anchorDom.ts
@@ -82,17 +82,39 @@ export function wrapRange(root: Element, start: number, end: number, attrs: Reco
return out;
}
-/** The block a selection lies in, and its offsets, or null (collapsed, or across blocks). */
+/**
+ * The block a selection lies in, and its offsets, or null (collapsed, or in no
+ * block of `container`).
+ *
+ * A selection that runs ACROSS blocks -- the end of one section into the next,
+ * which is what a drag past a paragraph does -- is noted in ONE of them: a text
+ * anchor names one section (lib/annotations/anchor.mjs), and that is where
+ * `umtool notes` finds it again. The block it starts in, from the start to the
+ * block's end, when that part has any text; else the block it ends in, from its
+ * beginning. It used to get no Note button at all.
+ */
export function selectionIn(container: Element): { block: Element; start: number; end: number; rect: DOMRect } | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
const range = sel.getRangeAt(0);
const el = (n: Node) => (n.nodeType === Node.ELEMENT_NODE ? (n as Element) : n.parentElement);
- const a = el(range.startContainer)?.closest("[data-block]");
- const b = el(range.endContainer)?.closest("[data-block]");
- if (!a || a !== b || !container.contains(a)) return null;
- const start = offsetOf(a, range.startContainer, range.startOffset);
- const end = offsetOf(a, range.endContainer, range.endOffset);
- if (end <= start) return null;
- return { block: a, start, end, rect: range.getBoundingClientRect() };
+ const within = (e: Element | null | undefined) => (e && container.contains(e) ? e : null);
+ const a = within(el(range.startContainer)?.closest("[data-block]"));
+ const b = within(el(range.endContainer)?.closest("[data-block]"));
+ const rect = range.getBoundingClientRect();
+ if (a && a === b) {
+ const start = offsetOf(a, range.startContainer, range.startOffset);
+ const end = offsetOf(a, range.endContainer, range.endOffset);
+ return end > start ? { block: a, start, end, rect } : null;
+ }
+ if (a) {
+ const start = offsetOf(a, range.startContainer, range.startOffset);
+ const text = blockText(a).text;
+ if (text.slice(start).trim()) return { block: a, start, end: text.length, rect };
+ }
+ if (b) {
+ const end = offsetOf(b, range.endContainer, range.endOffset);
+ if (blockText(b).text.slice(0, end).trim()) return { block: b, start: 0, end, rect };
+ }
+ return null;
}
diff --git a/umtool/docs/cli.md b/umtool/docs/cli.md
@@ -51,7 +51,13 @@ projects answering to one name is reported, never resolved by picking one.
| `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 `<SONG_DIR>/.cache/umtool`) |
-| `CHANNELS_DIR` | `$TRANSCRIPTS_DIR/channels`, else the checkout's `transcripts/channels` (found by walking up from the cwd to `pnpm-workspace.yaml`) |
+| `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 (`<repo>/umtool/bin/umtool.mjs`),
+whatever directory it is run from — `umtool notes --all` from a report workspace under
+`~/reports` 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
@@ -85,7 +91,16 @@ 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.
+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
diff --git a/umtool/e2e/article-notes.spec.ts b/umtool/e2e/article-notes.spec.ts
@@ -71,6 +71,43 @@ test("select text, Note, save: a mark on the quote, a note beside report.json",
await expect(page.locator("mark[data-note]")).toHaveAttribute("data-active", "true");
});
+// A drag that runs past the end of a section into the next one: the note is
+// anchored in the section it STARTED in, from there to that section's end (a
+// text anchor names one section). It used to get no Note button at all.
+test("a selection across two sections gets a Note, anchored in the first", async ({ page }) => {
+ await page.goto(PAGE);
+ await page.evaluate(() => {
+ const at = (block: string, text: string): [Text, number] => {
+ const root = document.querySelector(`[data-block="${block}"]`)!;
+ const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ for (let n = w.nextNode() as Text | null; n; n = w.nextNode() as Text | null) {
+ const i = n.data.indexOf(text);
+ if (i >= 0) return [n, i];
+ }
+ throw new Error(`no "${text}" in ${block}`);
+ };
+ const [a, i] = at("first", "Nobody checked the claim");
+ const [b, j] = at("later", "on a different show");
+ const r = document.createRange();
+ r.setStart(a, i);
+ r.setEnd(b, j + "on a different".length);
+ const s = getSelection()!;
+ s.removeAllRanges();
+ s.addRange(r);
+ });
+ await page.locator('[data-block="later"]').dispatchEvent("mouseup");
+ await page.getByRole("button", { name: "Note", exact: true }).click();
+ await page.getByLabel("note text").fill("This runs on.");
+ await page.getByRole("button", { name: "save note" }).click();
+
+ const anchor = JSON.parse(readFileSync(NOTES, "utf8")).notes[0].anchor;
+ expect(anchor).toMatchObject({ kind: "text", section: "first" });
+ expect(anchor.quote.startsWith("Nobody checked the claim")).toBe(true);
+ expect(anchor.quote).toContain("for the record.");
+ expect(anchor.quote).not.toContain("different show");
+ await expect(page.locator('[data-block="first"] mark[data-note]').first()).toBeVisible();
+});
+
test("section, whole-article and citation notes; resolve, reopen, reply, filters", async ({ page }) => {
await page.goto(PAGE);
await page.getByRole("button", { name: "note on Later" }).click();
diff --git a/umtool/e2e/mix.spec.ts b/umtool/e2e/mix.spec.ts
@@ -1,4 +1,7 @@
import { test, expect } from "@playwright/test";
+import { existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
// The mix bench, against SYNTHESISED tracks whose true answers are known in
// advance (see make-fixture.mjs):
@@ -15,6 +18,65 @@ import { test, expect } from "@playwright/test";
const BG = "bg.mp4";
const SONG = "song.mp4";
+// THE CORPUS'S CLIP WINDOWS, SET ASIDE FOR THIS FILE.
+//
+// The bench folds the corpus's clip windows (channels/<slug>/data/<id>/clips/,
+// what the editor's fetch writes) in with a project's own clips-raw. The
+// fixture is built once per run and specs before this one fetch windows
+// through the editor stub (testchan/vid1 0-14, say), so which file a clip
+// links to, and whether c02 is fetched at all, depended on what ran first:
+// `:166` and `:201` failed in every full run and passed alone (FACTS, "mix.spec.ts
+// IS ORDER-DEPENDENT"). These tests are about the project's clips-raw, so they
+// start with the corpus windows moved aside, and put them back after, for the
+// specs that follow.
+const FIXTURE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".e2e-song");
+const CHANNELS = path.join(FIXTURE, "channels");
+const ASIDE = path.join(FIXTURE, "mix-spec-clips-aside");
+
+function clipDirs(): string[] {
+ const out: string[] = [];
+ if (!existsSync(CHANNELS)) return out;
+ for (const slug of readdirSync(CHANNELS)) {
+ const data = path.join(CHANNELS, slug, "data");
+ if (!existsSync(data)) continue;
+ for (const id of readdirSync(data)) {
+ const clips = path.join(data, id, "clips");
+ if (existsSync(clips)) out.push(path.relative(CHANNELS, clips));
+ }
+ }
+ return out;
+}
+
+function restoreClips() {
+ if (!existsSync(ASIDE)) return;
+ for (const rel of clipDirsUnder(ASIDE)) {
+ const back = path.join(CHANNELS, rel);
+ if (existsSync(back)) rmSync(back, { recursive: true, force: true });
+ mkdirSync(path.dirname(back), { recursive: true });
+ renameSync(path.join(ASIDE, rel), back);
+ }
+ rmSync(ASIDE, { recursive: true, force: true });
+}
+
+function clipDirsUnder(root: string): string[] {
+ const out: string[] = [];
+ for (const slug of readdirSync(root)) {
+ const data = path.join(root, slug, "data");
+ if (!existsSync(data)) continue;
+ for (const id of readdirSync(data)) out.push(path.join(slug, "data", id, "clips"));
+ }
+ return out;
+}
+
+test.beforeAll(() => {
+ restoreClips(); // a run killed mid-file left some aside
+ for (const rel of clipDirs()) {
+ mkdirSync(path.dirname(path.join(ASIDE, rel)), { recursive: true });
+ renameSync(path.join(CHANNELS, rel), path.join(ASIDE, rel));
+ }
+});
+test.afterAll(restoreClips);
+
test("the analysis finds a cue an envelope cannot see", async ({ request }) => {
const r = await request.get(`/api/mix/track?path=${encodeURIComponent(BG)}`);
expect(r.ok()).toBe(true);
@@ -112,7 +174,9 @@ test("the bench loads, lists real tracks, and hides render scratch", async ({ pa
await expect(page.getByRole("link", { name: "mix", exact: true })).toBeVisible();
const body = page.locator("select").first();
- await expect(body.locator("option", { hasText: "song.mp4" })).toHaveCount(1);
+ // The label exactly: `hasText` is a substring match, and a render another
+ // spec left (`…song.mp4`) counted as a second song.
+ await expect(body.locator("option", { hasText: /^song\.mp4$/ })).toHaveCount(1);
// polytmp-*/ and poly-song-*.wav are working files, never offerable.
await expect(page.locator("option").filter({ hasText: /poly-song-|polytmp-/ })).toHaveCount(0);
});
diff --git a/umtool/e2e/sites.spec.ts b/umtool/e2e/sites.spec.ts
@@ -20,7 +20,9 @@ test.beforeAll(async ({ playwright }) => {
test("/sites lists every site, private first, with published and draft articles", async ({ page }) => {
const res = await page.goto("/sites");
expect(res?.status()).toBe(200);
- await expect(page.getByRole("link", { name: "sites", exact: true }).first()).toHaveAttribute("aria-current", "page");
+ // The nav calls it "articles" (the editor's /sites is the sites themselves).
+ await expect(page.getByRole("link", { name: "articles", exact: true }).first()).toHaveAttribute("aria-current", "page");
+ await expect(page.getByRole("link", { name: "sites", exact: true })).toHaveCount(0);
const sites = page.locator("[data-site]");
await expect(sites).toHaveCount(2);
diff --git a/umtool/lib/paths.mjs b/umtool/lib/paths.mjs
@@ -4,7 +4,7 @@
// directory is read and which is written must not be able to differ between
// `umtool ls` and the page it is supposed to describe. lib/paths.ts re-exports
// everything here with types; nothing computes a root twice.
-import { existsSync } from "node:fs";
+import { existsSync, realpathSync } from "node:fs";
import { lstat, realpath } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -199,7 +199,35 @@ export function findRepoRoot(start) {
}
}
-export const REPO_ROOT = findRepoRoot(process.cwd());
+/**
+ * The checkout a umtool CLI belongs to -- `<repo>/umtool/bin/<cli>.mjs` as the
+ * entry script (`process.argv[1]`) -- or null for anything else (the Next
+ * server, a test runner, report-to-video's own CLIs).
+ *
+ * A CLI is run from wherever its user stands: `node ~/…/umtool/bin/umtool.mjs
+ * notes --all` from a report workspace under ~/reports has no
+ * pnpm-workspace.yaml above its cwd, so the cwd walk fell back to the cwd's
+ * parent and SITES_DIR (and CHANNELS_DIR) pointed at nothing. The script's own
+ * path names the checkout it is from. Only the bin/ entries: the server keeps
+ * the cwd walk, for the reason findRepoRoot gives.
+ *
+ * @param {string | undefined} entry
+ * @returns {string | null}
+ */
+export function cliRepoRoot(entry) {
+ if (!entry || !/[\\/]umtool[\\/]bin[\\/][^\\/]+\.mjs$/.test(entry)) return null;
+ let real;
+ try {
+ real = realpathSync(/* turbopackIgnore: true */ entry);
+ } catch {
+ return null;
+ }
+ // <repo>/umtool/bin/x.mjs -> <repo>, when <repo> is a checkout.
+ const repo = path.resolve(/* turbopackIgnore: true */ path.dirname(/* turbopackIgnore: true */ real), "..", "..");
+ return existsSync(path.join(/* turbopackIgnore: true */ repo, "pnpm-workspace.yaml")) ? repo : null;
+}
+
+export const REPO_ROOT = cliRepoRoot(process.argv[1]) ?? findRepoRoot(process.cwd());
export const CHANNELS_DIR = path.resolve(
/* turbopackIgnore: true */
diff --git a/umtool/lib/paths.test.mjs b/umtool/lib/paths.test.mjs
@@ -0,0 +1,69 @@
+// Where a umtool CLI finds the corpus: from its OWN checkout (the entry
+// script's path), whatever directory it is run from.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { cliRepoRoot } from "./paths.mjs";
+
+const PATHS = new URL("./paths.mjs", import.meta.url);
+
+function checkout() {
+ const root = mkdtempSync(path.join(tmpdir(), "umtool-paths-"));
+ writeFileSync(path.join(root, "pnpm-workspace.yaml"), "packages: []\n");
+ mkdirSync(path.join(root, "umtool", "bin"), { recursive: true });
+ return root;
+}
+
+test("cliRepoRoot: a umtool/bin entry names its checkout; anything else is null", (t) => {
+ const root = checkout();
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const cli = path.join(root, "umtool", "bin", "umtool.mjs");
+ writeFileSync(cli, "");
+ assert.equal(cliRepoRoot(cli), root);
+ // The server, a test file, report-to-video's CLIs: the cwd walk decides.
+ assert.equal(cliRepoRoot(path.join(root, "node_modules", "next", "dist", "bin", "next")), null);
+ assert.equal(cliRepoRoot(path.join(root, "umtool", "report-to-video", "build-video.mjs")), null);
+ assert.equal(cliRepoRoot(undefined), null);
+ // A bin/ whose grandparent is not a checkout, and one that does not exist.
+ const loose = mkdtempSync(path.join(tmpdir(), "umtool-loose-"));
+ t.after(() => rmSync(loose, { recursive: true, force: true }));
+ mkdirSync(path.join(loose, "umtool", "bin"), { recursive: true });
+ writeFileSync(path.join(loose, "umtool", "bin", "x.mjs"), "");
+ assert.equal(cliRepoRoot(path.join(loose, "umtool", "bin", "x.mjs")), null);
+ assert.equal(cliRepoRoot(path.join(root, "umtool", "bin", "missing.mjs")), null);
+ // Reached through a link: the TARGET's checkout, not the link's.
+ const link = path.join(loose, "umtool", "bin", "linked.mjs");
+ symlinkSync(cli, link);
+ assert.equal(cliRepoRoot(link), root);
+});
+
+test("a CLI run from outside its checkout reads that checkout's sites and channels", (t) => {
+ const root = checkout();
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const probe = path.join(root, "umtool", "bin", "probe.mjs");
+ writeFileSync(
+ probe,
+ `const p = await import(${JSON.stringify(PATHS.href)});\n` +
+ "console.log(JSON.stringify({ repo: p.REPO_ROOT, sites: p.SITES_DIR, channels: p.CHANNELS_DIR }));\n",
+ );
+ const away = mkdtempSync(path.join(tmpdir(), "umtool-away-"));
+ t.after(() => rmSync(away, { recursive: true, force: true }));
+ const env = { ...process.env };
+ for (const k of ["SITES_DIR", "CHANNELS_DIR", "TRANSCRIPTS_DIR"]) delete env[k];
+ const got = JSON.parse(execFileSync(process.execPath, [probe], { cwd: away, env, encoding: "utf8" }));
+ assert.deepEqual(got, {
+ repo: root,
+ sites: path.join(root, "transcripts", "sites"),
+ channels: path.join(root, "transcripts", "channels"),
+ });
+ // SITES_DIR still wins when it is set.
+ const set = JSON.parse(
+ execFileSync(process.execPath, [probe], { cwd: away, env: { ...env, SITES_DIR: away }, encoding: "utf8" }),
+ );
+ assert.equal(set.sites, away);
+});
diff --git a/umtool/lib/report/edit-guard.mjs b/umtool/lib/report/edit-guard.mjs
@@ -0,0 +1,46 @@
+// THE one wrapper every manifest writer goes through -- the routes (through
+// lib/report/guard.ts, which types it) and the `umtool window` CLI alike.
+//
+// A manifest with `generatedBy` is rebuilt by its generator, and the rebuild
+// overwrites edits made here (the banner on the project page, the bench and
+// the On-screen section says so). The edit is still made; what this adds is a
+// record of it: the manifest is read before and after the write, and every
+// change becomes an `edit` note in the project's notes.json for the agent to
+// port into the generator's inputs (./edit-notes.mjs). A hand-edited manifest
+// (no `generatedBy`) is written exactly as before.
+//
+// The notes are written AFTER the manifest and never fail the write: the edit
+// is saved either way, and `editNotes.errors` says when its note is not.
+//
+// Plain .mjs so the CLI can run it with bare node; the routes read it through
+// guard.ts.
+import { projectTargetFor } from "../annotations/targets.mjs";
+import { readManifest } from "../projects/report.mjs";
+import { editsBetween, recordEdits } from "./edit-notes.mjs";
+
+/**
+ * @template T
+ * @param {{ id: string, dir: string, kind: string }} project
+ * @param {() => Promise<T>} write
+ * @param {{ reportsRoot?: string }} [opts] the reports root notes may live under (tests)
+ * @returns {Promise<{ result: T, editNotes: { generatedBy: string, added: number, updated: number, deleted: number, errors: string[] } | null }>}
+ */
+export async function withEditNotes(project, write, opts = {}) {
+ const before = await readManifest(project.dir);
+ const result = await write();
+ const generatedBy = typeof before?.generatedBy === "string" ? before.generatedBy.trim() : "";
+ if (!generatedBy) return { result, editNotes: null };
+ const after = await readManifest(project.dir);
+ const edits = editsBetween(before, after);
+ if (!edits.length) return { result, editNotes: { generatedBy, added: 0, updated: 0, deleted: 0, errors: [] } };
+ try {
+ const target = await projectTargetFor(project, opts.reportsRoot ? { reportsRoot: opts.reportsRoot } : undefined);
+ const counts = await recordEdits(target, edits, generatedBy);
+ return { result, editNotes: { generatedBy, ...counts } };
+ } catch (e) {
+ return {
+ result,
+ editNotes: { generatedBy, added: 0, updated: 0, deleted: 0, errors: [e instanceof Error ? e.message : String(e)] },
+ };
+ }
+}
diff --git a/umtool/lib/report/edit-guard.test.mjs b/umtool/lib/report/edit-guard.test.mjs
@@ -0,0 +1,74 @@
+// The manifest writers' guard (edit-guard.mjs) and `umtool window` through it:
+// an edit to a GENERATED manifest is an `edit` note for its generator; a
+// hand-edited manifest gets none.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { readNotes } from "../annotations/store.mjs";
+import { updateClip } from "./manifest.mjs";
+import { withEditNotes } from "./edit-guard.mjs";
+
+const CLI = fileURLToPath(new URL("../../bin/umtool.mjs", import.meta.url));
+
+async function project(manifest) {
+ const root = await mkdtemp(path.join(tmpdir(), "umtool-editguard-"));
+ const dir = path.join(root, "ws", "clipcut");
+ await mkdir(dir, { recursive: true });
+ await writeFile(path.join(dir, "video.manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
+ return { root, dir, project: { id: "ws/clipcut", dir, kind: "report-video" } };
+}
+
+const MANIFEST = (generated) => ({
+ schemaVersion: 1,
+ slug: "clipcut",
+ ...(generated ? { generatedBy: "polemics/video/make-videos.py" } : {}),
+ timeline: [{ type: "clip", id: "e1", channel: "ch", video: "v1", start: 10, end: 20 }],
+});
+
+test("an edit to a generated manifest is written, and noted for its generator", async () => {
+ const { root, dir, project: p } = await project(MANIFEST(true));
+ try {
+ const { result, editNotes } = await withEditNotes(p, () => updateClip(dir, "e1", { start: 12 }), { reportsRoot: root });
+ assert.equal(result.entry.start, 12);
+ assert.deepEqual(editNotes, { generatedBy: "polemics/video/make-videos.py", added: 1, updated: 0, deleted: 0, errors: [] });
+ const { doc } = await readNotes(path.join(dir, "notes.json"));
+ assert.equal(doc.notes.length, 1);
+ assert.deepEqual(doc.notes[0].anchor, { kind: "edit", entry: "e1", field: "start", from: 10, to: 12 });
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("a hand-edited manifest is written with no note", async () => {
+ const { root, dir, project: p } = await project(MANIFEST(false));
+ try {
+ const { editNotes } = await withEditNotes(p, () => updateClip(dir, "e1", { start: 12 }), { reportsRoot: root });
+ assert.equal(editNotes, null);
+ await assert.rejects(readFile(path.join(dir, "notes.json")));
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("`umtool window` on a generated manifest says it noted the edit, and the note is there", async () => {
+ const { root, dir } = await project(MANIFEST(true));
+ try {
+ const out = execFileSync(process.execPath, [CLI, "window", dir, "e1", "--start", "11", "--end", "21"], {
+ cwd: root,
+ env: { ...process.env, REPORTS_DIR: root },
+ encoding: "utf8",
+ });
+ assert.match(out, /e1: 10–20 -> 11–21/);
+ assert.match(out, /edit noted for polemics\/video\/make-videos\.py \(2 added/);
+ const { doc } = await readNotes(path.join(dir, "notes.json"));
+ assert.deepEqual(doc.notes.map((n) => n.anchor.field).sort(), ["end", "start"]);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
diff --git a/umtool/lib/report/guard.ts b/umtool/lib/report/guard.ts
@@ -1,19 +1,10 @@
-import { projectTargetFor } from "@/lib/annotations/targets.mjs";
-import { readManifest } from "@/lib/projects/report.mjs";
-import { editsBetween, recordEdits } from "./edit-notes.mjs";
+import { withEditNotes as withEditNotesMjs } from "./edit-guard.mjs";
-// THE one wrapper every manifest writer's route goes through.
-//
-// A manifest with `generatedBy` is rebuilt by its generator, and the rebuild
-// overwrites edits made here (the banner on the project page, the bench and
-// the On-screen section says so). The edit is still made; what this adds is a
-// record of it: the manifest is read before and after the write, and every
-// change becomes an `edit` note in the project's notes.json for the agent to
-// port into the generator's inputs (lib/report/edit-notes.mjs). A hand-edited
-// manifest (no `generatedBy`) is written exactly as before.
-//
-// The notes are written AFTER the manifest and never fail the request: the
-// edit is saved either way, and `editNotes.errors` says when its note is not.
+// THE one wrapper every manifest writer's route goes through: the manifest is
+// read before and after the write, and on a GENERATED manifest every change
+// becomes an `edit` note for the agent that generates it. The implementation
+// is ./edit-guard.mjs, plain JS so the `umtool window` CLI runs the same one;
+// this file only types it for the routes.
export type EditNotes = { generatedBy: string; added: number; updated: number; deleted: number; errors: string[] };
@@ -21,18 +12,5 @@ export async function withEditNotes<T>(
project: { id: string; dir: string; kind: string },
write: () => Promise<T>,
): Promise<{ result: T; editNotes: EditNotes | null }> {
- const before = await readManifest(project.dir);
- const result = await write();
- const generatedBy = typeof before?.generatedBy === "string" ? before.generatedBy.trim() : "";
- if (!generatedBy) return { result, editNotes: null };
- const after = await readManifest(project.dir);
- const edits = editsBetween(before, after);
- if (!edits.length) return { result, editNotes: { generatedBy, added: 0, updated: 0, deleted: 0, errors: [] } };
- try {
- const target = await projectTargetFor(project);
- const counts = await recordEdits(target, edits, generatedBy);
- return { result, editNotes: { generatedBy, ...counts } };
- } catch (e) {
- return { result, editNotes: { generatedBy, added: 0, updated: 0, deleted: 0, errors: [e instanceof Error ? e.message : String(e)] } };
- }
+ return withEditNotesMjs(project, write) as Promise<{ result: T; editNotes: EditNotes | null }>;
}
diff --git a/umtool/package.json b/umtool/package.json
@@ -8,7 +8,7 @@
"build": "next build",
"start": "next start --port ${UMTOOL_PORT:-3050}",
"typecheck": "tsc --noEmit",
- "e2e": "node ../scripts/queue-lock.mjs --ports UMTOOL_E2E_PORT:3051,EDITOR_STUB_PORT:3052 -- playwright test"
+ "e2e": "node ../scripts/worktree.mjs run -- node ../scripts/queue-lock.mjs --ports UMTOOL_E2E_PORT:3051,EDITOR_STUB_PORT:3052 -- playwright test"
},
"dependencies": {
"class-variance-authority": "^0.7.1",