commit 3bbdc4ad67c90e801a7f6bdbae336e8e0a528d75
parent 12fb080f2cf4ab721d8da439669df12686c59383
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 19:39:19 -0400
plans: slice P after review — the record's review round (M1 all-or-neither, L1 calendar date, L2 forward-only version; gates re-run: common 2,015, test:scripts 174 + 1, e2e 53/53 in 1.8 min; proof round 2), L3/L4 left; the changelog bullet's wording (L5 + L6)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 101 insertions(+), 25 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -9,7 +9,7 @@
- **An agent working through the MCP server asks the editor for a clip instead of running yt-dlp.** The MCP server has a new tool, `fetch_clip`. Given a citation's channel, video id, start and end and a one-line reason, it asks the local editor for that window through `POST /api/media/fetch-window`: the same paced, cookie-aware job umtool uses, which records who asked and why beside the file. It answers with the file's path in the corpus (`channels/<slug>/data/<id>/clips/`). The window is the cited span with 3 seconds either side, at most 15 minutes. `full: true` asks for the whole recording instead, which lands in the saved-video store and needs a video the editor already knows. A Rumble citation's id (the embed id the archive publishes) is mapped to the id the editor names the video's folder by, through the archive record's link. The editor must already archive the channel: pointed at a public site with a fresh editor, every clip gets a 404 `Channel "<slug>" not found`. The tool waits up to 90 seconds by default (at most 300) and otherwise returns the job's id, to wait on with `job`; the fetch carries on in the editor either way. While it waits it sends a progress notification per poll to a client that asks for progress. A client whose requests time out at 60 seconds (the MCP SDK's default) must raise that or pass `wait_seconds` of 50 or less. No request to the editor waits more than 15 seconds. If the editor stops answering mid-fetch, the answer gives the job's id and says not to ask again from scratch. The `/ask` and `/sweep` plans now tell the agent to use it and never to run yt-dlp itself. The MCP needs `ARCHILYZER_EDITOR_URL` and `WORKER_TOKEN` (the editor's own) in its environment, so re-register it with the two `--env` lines in the README; without them the tool says so and fetches nothing. The MCP server itself still writes nothing. The README's `yt-dlp --download-sections` command is now only the fallback for a machine with no editor.
- **"Persist source video" and a whole-recording fetch download the video even when it already has a transcript.** On a channel that takes YouTube's subtitles, the button — and a whole-recording request from umtool or the MCP server's `fetch_clip` with `full: true`, which run the same job — fetched only the subtitles again when the video already had a transcript or captions, and finished with no file. It now downloads the source and moves it into the saved-video store. YouTube's subtitles are fetched again first, as on any re-download; a Whisper transcript is not touched, and no audio is extracted beside a transcript. **Persist kept now** on a channel's Cleanup stage does the same for every kept video, so on such a channel it now downloads each kept video's source. The video page's Source video card offers the button on these channels too; it used to say persistence was for transcribe-handling channels only.
- **Each video keeps a history of how its metadata changed at the source.** Every download that rewrites a video's `metadata.info.json` and changes anything in it adds one entry to `metadata.history.json` beside it: the old and new value of each field that changed (title, description, duration, availability, chapters and the rest), the view, like and comment counts that moved, and which of the fields that change on every fetch (format URLs, thumbnails, caption URLs) differed, compared by fingerprint only. A caption language appearing or disappearing counts as a change. The newest 200 entries are kept. The video page shows the history under the description: "Metadata rewritten N× · last … by …: <what changed>", with each entry's old → new values when opened. The history starts with the first rewrite after this update.
-- **Release notes can be cut from the command line, with or without the editor running.** `archilyzer release show` prints each changelog's latest release, its date, how many bullets wait under `[Unreleased]`, and what `next` and `next-minor` would be. `archilyzer release cut <editor|export|all> <X.Y.Z|next|next-minor> [--commit] [--date YYYY-MM-DD]` turns `## [Unreleased]` into the dated heading and prints one line per changelog, for example `editor: ## [0.10.0] - 2026-09-26 (committed 1a2b3c4d)`. `next` is the patch bump of the latest heading and `next-minor` the minor bump. `all` cuts both changelogs with one version, worked out from the higher of their two latest headings. With `--commit` it makes one `Release <workspace> <version>` commit per changelog, and it stops at the first failure. Run it from the checkout (`pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts release …`); no editor is needed. A running editor takes the same cut over HTTP: `pnpm ops cut-release --json '{"workspace":"all","version":"next","commit":true}'` (`POST /api/ops/cut-release`). The two commands and the **Cut release** form on `/changelog` and `/sites` share one code path, so they refuse the same things in the same words: nothing pending, a malformed version or date, and a commit while any file other than the changelogs is uncommitted. The form itself is unchanged. No `package.json` version is bumped and no tag is made: the changelog heading is the version.
+- **Release notes can be cut from the command line, with or without the editor running.** `archilyzer release show` prints each changelog's latest release, its date, how many bullets wait under `[Unreleased]`, and what `next` and `next-minor` would be. `archilyzer release cut <editor|export|all> <X.Y.Z|next|next-minor> [--commit] [--date YYYY-MM-DD]` turns `## [Unreleased]` into the dated heading and prints one line per changelog, for example `editor: ## [0.10.0] - 2026-09-26 (committed 1a2b3c4d)`. `next` is the patch bump of the latest heading and `next-minor` the minor bump. `all` cuts both changelogs with one version, worked out from the higher of their two latest headings, and with `--commit` makes one `Release <workspace> <version>` commit per changelog. It cuts both or neither: both changelogs are checked before either is written, so if one cannot be cut, nothing is written or committed. Only a write or commit that fails after the editor's has gone through can leave the editor cut (and committed) without the export, and the command then says so. Run it from the checkout (`pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts release …`); no editor is needed. A running editor takes the same cut over HTTP: `pnpm ops cut-release --json '{"workspace":"all","version":"next","commit":true}'` (`POST /api/ops/cut-release`). The two commands and the **Cut release** form on `/changelog` and `/sites` share one code path, so they refuse the same things in the same words: nothing pending; a malformed version; a version that is not newer than the latest release (new for the form too); a date that is not a real day; and a commit while any file is uncommitted other than the changelog being cut (for `all`, either changelog). The form's fields are unchanged, and its version box now also accepts `next` and `next-minor`. No `package.json` version is bumped and no tag is made: the changelog heading is the version.
## [0.9.0] - 2026-09-26
- **Every page now has a ground and an accent to choose, and the five theme families are gone.** The theme menu (the palette button beside the quick toggle, in the editor's sidebar and in the header of every published site, the hub and the homepage) has two groups. **Base** is System, Light, Sepia or Dark; Sepia is new, a warm paper ground for long reading. **Accent** is Signal, Brass, Vermilion, Violet, Sakura, Blue or Green, with the site's own tagged *default*; a site with a custom hex offers it first as *Site colour*. The quick toggle cycles System → Light → Sepia → Dark. A published site opens on the reader's system setting, in the accent its site form sets. The hub and the homepage open on Dark, in Signal, even with JavaScript off, and the editor follows the system, in Signal. Each accent has a value for each ground that reads at 4.5:1, and a custom hex is darkened or lightened per ground to match. A reader's accent is remembered only while it differs from the site's: picking the site's own again forgets it, so the reader follows the site if its accent changes later. Base, Archive, Selenized, Swiss and Archilyzer are gone. A choice made before this update carries over once: light stays light (Archive light becomes Sepia), dark stays dark and system stays system; the family itself is dropped. Headings are Archivo, text is IBM Plex Sans and figures are IBM Plex Mono everywhere, with one corner radius. Success, warning and other status text reads at 4.5:1 on its own tinted fill on every ground; on Light, success and warning are a shade deeper than before for it. Chart colours are fixed per ground and never follow the accent; the third is a violet, well clear of the red that marks a recording as gone. The phone's browser bar takes the page's ground, not the accent. Needs a rebuild and deploy of every site, the hub and the homepage.
diff --git a/plans/release-10.md b/plans/release-10.md
@@ -1533,34 +1533,56 @@ three hub bullets.
Branch `cli/cut-release` off `main` `84c502f3`, worktree `/home/user/Projects/cli-cut-release`,
one Opus implementer. The plan is [`cut-release-cli.md`](cut-release-cli.md), Implementation
-items 1–5. The operator asked (2026-09-26): "Are you able to cut releases with the CLI ops? add that
+items 1–5. Reviewed SHIP AFTER FIXES; the fixes are in (the review round is under Gates). The
+operator asked (2026-09-26): "Are you able to cut releases with the CLI ops? add that
ability if not." The answer was no: the only cutter was the "Cut release" form. Now three callers
share one writer:
-- the form, which is unchanged;
+- the form, whose fields and labels are unchanged (its version box now also takes `next` /
+ `next-minor`);
- `archilyzer release cut|show`, local, with no editor running;
- `pnpm ops cut-release`, through a new `POST /api/ops/cut-release`.
**The writer** is `common/controller/cutRelease.ts`.
- `cutReleaseForWorkspace({workspace, version, commit, date?, root?})` is the old server action's
body moved to common, in the same order: the dirty-tree guard, the read, `cutRelease`, the
- atomic write, then the path-limited `Release <workspace> <version>` commit. Every refusal keeps
- its old wording. It returns
+ atomic write, then the path-limited `Release <workspace> <version>` commit. Every old refusal
+ keeps its wording. It returns
`{ok: true, workspace, version, heading, committed, commitSha?} | {ok: false, workspace, error}`.
+ Internally it is a PLAN (read, resolve, cut in memory: every refusal about the changelog) and
+ an APPLY (the write and the commit: only I/O and git can fail there).
+- **Two new refusals, in `changelog.cutRelease` itself so every caller has them** (review L1 +
+ L2). They are new for the form too.
+ - A date must be a calendar day, not just YYYY-MM-DD-shaped (`dateISOProblem`, a `Date.UTC`
+ round trip): `Date "2026-13-45" is not a calendar date.`
+ - A version must be newer than the latest heading, by semver precedence (`compareVersions`: a
+ release ranks above its own prereleases, so `1.0.0-rc.1` passes over `0.9.0` and
+ `0.9.0-rc.1` does not): `Version 0.9.0 is not newer than the latest release, 0.9.0.`
+ `next` / `next-minor` pass by construction.
- `version` is a literal X.Y.Z(-pre), `next` (`suggestNextVersion`, the patch bump the form
pre-fills) or `next-minor` (the new `suggestNextMinorVersion`: `0.9.3` → `0.10.0`).
-- `cutReleases({workspace: "all", …})` cuts editor, then export, with the SAME version:
- - A keyword resolves against the HIGHER of the two latest headings (`compareVersionCores`), so
+- `cutReleases({workspace: "all", …})` cuts editor, then export, with the SAME version, and
+ **cuts both or neither** (review M1):
+ - A keyword resolves against the HIGHER of the two latest headings (`compareVersions`), so
neither changelog goes backwards when they have drifted apart.
+ - **The preflight, before either write:** it reads both changelogs, plans both cuts in memory,
+ and (with `commit`) runs the dirty-tree guard once. Any refusal there returns
+ `{ok: false, version, results: [<the failing workspace's refusal>], notAttempted: [<the
+ other>], untouched: true}`, and both files and the log are left alone. A guard refusal is
+ reported against `editor`, the first commit it blocks.
- Both changelogs may be dirty. The guard's allowed set is both files, so export's uncommitted
- bullets do not block editor's commit.
- - It makes two commits in today's message form.
- - It stops at the first failure and returns `{ok, version, results, notAttempted}`. `results`
- lists every workspace attempted, so a refused export still reports the editor it already cut
- and committed.
+ bullets do not block editor's commit. (A single-workspace cut allows only its own changelog,
+ so there a dirty other changelog does block the commit, as it always did.)
+ - Only then does it write and commit each, in order: two commits in today's message form.
+ Past the preflight only I/O or git can stop it: a write or a commit that fails after the
+ editor's went through. The outcome then lists what was done (`results`) and what was not
+ tried (`notAttempted`), without `untouched`.
+ - Before the review, `all` wrote (and with `--commit` committed) the editor before it looked at
+ the export. "Only the editor has changes" left a lone `Release editor` commit behind a
+ non-zero exit.
- `describeRelease(workspace)` is the read-only side of `release show`. It uses the new
`getLatestRelease` (version + date), `hasUnreleasedHeading` and `countUnreleasedBullets`
(top-level bullets; nested items are not counted).
-- **One behaviour change, deliberate.** A failed write (`writeFileAtomic` throwing) is now
+- **A failed write is a result, not a throw (deliberate).** A failed write (`writeFileAtomic` throwing) is now
`{ok: false, error: "Could not write <file>: …"}` instead of an exception, so `all` can still
say what it did. The form shows such a failure as its alert instead of an error boundary.
- `root` is for tests only. It means the standard layout under that directory and ignores the
@@ -1572,8 +1594,12 @@ share one writer:
**The form** (`editor/app/sites/lib/cutReleaseAction.ts`) is now a FormData adapter over
`cutReleaseForWorkspace`. Its fields, labels, "Invalid workspace." / "Version is required." and
-returned state are unchanged, and `cut-release.spec.ts` passes unmodified (3/3). Its three
-`revalidatePath`s moved to `revalidateAfterReleaseCut.ts`, which the ops route shares.
+returned state are unchanged, and `cut-release.spec.ts` passes unmodified (3/3). What it accepts
+changed in two ways, both through the shared writer:
+- its version box also takes `next` and `next-minor` (resolved against the file);
+- a version that is not newer than the latest heading is now refused.
+
+Its three `revalidatePath`s moved to `revalidateAfterReleaseCut.ts`, which the ops route shares.
**The local verbs** are two rows in `common/bin/archilyzer.ts` over `common/bin/release.ts`:
- `archilyzer release show [editor|export]`. `all` is accepted as "both", the default. It prints
@@ -1583,9 +1609,12 @@ returned state are unchanged, and `cut-release.spec.ts` passes unmodified (3/3).
- `archilyzer release cut <editor|export|all> <X.Y.Z|next|next-minor> [--commit] [--date YYYY-MM-DD]`.
- It prints one line per changelog, for example
`editor: ## [0.10.0] - 2026-09-26 (committed 1a2b3c4d)`, `… (not committed)`,
- `export: failed — <sentence>` or `export: not cut — stopped at the failure above`.
- - A bad target, version or date is refused before anything is read, with exit 2. A failed cut
- exits 1.
+ `export: failed — <sentence>`, and for a workspace `all` did not reach either
+ `editor: not cut — all cuts both or neither, and nothing was written` (the preflight refused)
+ or `export: not cut — stopped at the failure above` (an I/O or git failure after the editor's
+ cut).
+ - A bad target, version or date (shape or calendar) is refused before anything is read, with
+ exit 2. A failed cut exits 1.
- `--date` is used verbatim (decision 5). The default is today, in local time, as the form
stamps it.
- Both verbs work with no editor running, from the checkout they are run in.
@@ -1596,13 +1625,15 @@ is synchronous and built on `ops()`.
400 before any read.
- `commit` defaults to false. The CLI's `--commit` is opt-in too; only the form defaults it on.
- A full cut answers 200 `{ok: true, version, results}`.
-- A refusal answers 400 `{ok: false, error, version, results, notAttempted}`. `error` is the
- writer's sentence, prefixed `<workspace>: ` for `all`.
+- A refusal answers 400 `{ok: false, error, version, results, notAttempted, untouched?}`.
+ `error` is the writer's sentence, prefixed `<workspace>: ` for `all`. `untouched: true` means
+ `all` was refused before anything was written.
- The changelog pages are revalidated whenever anything was cut.
- `scripts/archilyzer-ops.mjs`: `ACTIONS` gains `cut-release`, and the header gains an example.
- The usage block says what the body takes, that `all` cuts both with one version and a commit
- each, and that only an editor built from release 10 or later has the route (an older one
- answers 404). With no editor running, it points to `archilyzer release cut`.
+ The usage block says what the body takes (`"commit": boolean (default false)`), that `all` cuts
+ both with one version and a commit each or neither, and that only an editor built from release
+ 10 or later has the route (an older one answers 404). With no editor running, it points to
+ `archilyzer release cut`.
**Two fixes the route made necessary.**
- **The e2e server's export changelog.** `EDITOR_CHANGELOG_FILE` already pointed the test server
@@ -1630,7 +1661,11 @@ the parent's.
| `867aaf15` | `editor:` `POST /api/ops/cut-release`; `pnpm ops` `ACTIONS` + usage; `archilyzer-ops.test.mjs` +1 |
| `7ef9dd6c` | `editor(e2e):` `ops-cut-release.spec.ts` (3); the test server's `EXPORT_CHANGELOG_FILE` + `.gitignore`; the `paths.ts` comment |
| `80212507` | `editor(e2e):` the second-cut expectation corrected (see e2e run 1) |
-| _this_ | `plans:` this record; the `editor/CHANGELOG.md` `[Unreleased]` bullet |
+| `6a258d5d` | `plans:` this record (first cut); the `editor/CHANGELOG.md` `[Unreleased]` bullet |
+| `7a1ea858` | `common:` review L1 + L2 in `cutRelease` itself: a calendar date (`dateISOProblem`), a version newer than the latest (`compareVersions` replaces `compareVersionCores`); tests changelog +2, cutRelease +1 |
+| `eeb1e587` | `common:` review M1: `all` cuts both or neither (plan/apply, the preflight, `untouched`); the CLI line and the route body say so; cutRelease tests 13 → 16; `ops-cut-release.spec.ts` updated (+2 bad-input cases) |
+| `7011ba62` | `scripts:` `pnpm ops` usage: "or neither"; `"commit": boolean` (review nit) |
+| _this_ | `plans:` this record updated for the review round; the changelog bullet's wording (review L5 + L6) |
**Gates**, all from the worktree root; the logs are `p-*.log` in the job's scratch dir.
- **tsc** (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) was clean before every
@@ -1681,6 +1716,38 @@ the parent's.
After the cut, `git diff` was the one heading line; `git checkout -- editor/CHANGELOG.md`
restored it. `--commit` was never run against a real checkout; the commit path is proven by
`cutRelease.test.ts` in temp repos.
+- **The review round** (verdict SHIP AFTER FIXES, `p-review.md`; M1, L1, L2, L5, L6 and one nit
+ taken; L3 and L4 left, below):
+ - tsc clean before each commit, 37 s and 36 s (`p-tsc6`, `p-tsc7`). `7011ba62` touched only
+ `.mjs` and `test:scripts` covers it.
+ - **common 2,015/2,015** (2,009 + 6: changelog +2, cutRelease +4 net), 40 s. **editor unit
+ 85/85**, 3 s. **`test:scripts` 174 + 1 skip**, 9 s. **mcp 269/269**, 19 s (`p-units2.log`).
+ - **editor build ok**, 40 s (`p-e2e3.log`).
+ - **e2e, the same spec list, run 3: 53 passed, 0 failed, 1.8 min** (no queue wait; no dangling
+ `export/public` link). `ops-cut-release` 3/3 with the updated partial-`all` expectation
+ (`untouched`, the editor byte-identical) and the two new bad-input cases.
+ - **Manual proof, round 2** (`p-proof2.log`, `p-proof3.log`; worktree at `7011ba62`; both
+ changelogs copied to scratch and copied back, md5s identical before and after; no commit):
+ ```
+ $ archilyzer release cut editor 0.9.0
+ editor: failed — Version 0.9.0 is not newer than the latest release, 0.9.0.
+ $ archilyzer release cut editor next --date 2026-13-45
+ release cut: Date "2026-13-45" is not a calendar date.
+ $ archilyzer release cut all next
+ editor: ## [0.9.1] - 2026-09-26 (not committed)
+ export: ## [0.9.1] - 2026-09-26 (not committed)
+ $ archilyzer release cut all next # again
+ editor: failed — Could not find a `## [Unreleased]` heading to cut from.
+ export: not cut — all cuts both or neither, and nothing was written
+ # restored; then the export alone cut, so only the editor has pending bullets:
+ $ archilyzer release cut export next
+ export: ## [0.9.1] - 2026-09-26 (not committed)
+ $ archilyzer release cut all next
+ export: failed — Could not find a `## [Unreleased]` heading to cut from.
+ editor: not cut — all cuts both or neither, and nothing was written
+ ```
+ The editor changelog's md5 was the same before and after that last `all`. This is M1's case,
+ and before the fix it cut the editor.
- **Numbers tool: none.**
**Found and left.**
@@ -1694,11 +1761,20 @@ the parent's.
- **The `_lib.ts` header says every ops route "call[s] ONE existing server action".** This one
calls the controller that the server action also calls. The route's comment says so; the
header is left as it is.
+- **Review L3, left: a commit that fails after its write revalidates nothing.** The file changed,
+ but the result is `{ok: false}` with no sign of the write, so neither the form nor the route
+ revalidates. The form always behaved this way. The pages are `force-dynamic` and re-read the
+ file on the next request, so the cost is small. The fix would carry `written: true` on that
+ failure.
+- **Review L4, left: the route's top-level `error` for an `all` stopped half-way names only the
+ failure.** The earlier cut is in `results`, not in `error`. After M1 this happens only on an I/O
+ or git failure, and the 400 then lacks `untouched`.
- **Commit trailers** carry `Claude Opus 5.5 (1M context)`, as slices M–O's do.
- **Rollout** is the plan's. From the primary, after this merges and before step 1's restart:
`pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts release show`, then
`… release cut all next --commit`. With both changelogs at 0.9.0, that cuts both as 0.9.1; use
- `next-minor` or an explicit version for 0.10.0. After the restart, check `pnpm ops cut-release`
+ `next-minor` or an explicit version for 0.10.0. If either changelog cannot be cut, neither is.
+ After the restart, check `pnpm ops cut-release`
on the live editor with a request that is refused before anything is written, for example
`--json '{"workspace":"site","version":"next"}'` (expect a 400 naming the three workspaces).
The route has no read-only mode: `release show` is the CLI's.