Deliberate behaviours (read before filing a bug)

This file lists behaviours that look like defects on a first read but are decisions, with the reason and the test that pins each one. An audit, a review bot or a contributor that finds one of these should report it only if the rationale no longer holds – and then the report should say which sentence below is wrong. Every entry names where the decision lives in code so it can be checked against the tree, not against this note.

Three earlier review rounds (2026-09-12) re-reported several of these; that is why the file exists. When a decision changes, edit the entry in the same PR.

By topic:

By release, review or plan (in the order they were added):

Time limits

Dry run

Colour

Outputs and files

Arguments

Process and packaging

Decided for 2.0 (issue #189 B), recorded in 1.x so the code moved toward them

External review, 2026-09-14 (1.17.0)

An outside review of the repository at 1.17.0, alongside eval 18. What it raised, and what was decided. Recorded here so none of it is re-proposed from scratch.

External review, 2026-09-13

An outside review of the repository at 1.13.0 (eval 14). What it raised, and what was decided. Each item is recorded here so it is not re-proposed from scratch.

1.15.0 — text people can see

1.16.0 — long-form delivery

The audiogram extends waveform.py; there is no audiogram.py. waveform.py already owns showwaves/showspectrum, --width/--height/--fps/--color/--background, --split-channels, --audio-stream, the one encoder line and the FFmpeg-5.x -t cap (#146). An audiogram.py would be that file plus a background image: a second spelling of one tool, which is exactly the mistake recorded for audio.py --chapters. The 1.x guarantee forbids removing a tool, so an audiogram.py would be permanent surface; --image is one additive argument. The composite job (visualisation over a plate, a title, captions burnt in, platform size, check.py) is a chain, and this repo already has one answer for a chain: a render.py template, as 1.14 established and templates/podcast.json precedes. The tool count therefore stays 42, which is asserted in five files by test_docs_tool_count_matches_the_real_tool_list. Do not re-propose it.

The title and the captions on an audiogram are second processes. waveform.py --title runs graphics.py --template sticker and --srt/--text runs caption.py on the rendered file, rather than adding drawtext or an ASS path here. One code path per job is worth two process spawns; the alternative is a second subtitle renderer that drifts from the first. Since 2.2.4 they are launched as render.py launches its stages: run_tool() with child_args() and --json, so --overwrite, --timeout, --fast and --dry-run reach them, and a failed stage is re-raised with the child’s own kind, exit code and hint (a private launcher had forwarded only --dry-run and called every failure kind: ffmpeg). The _vis / _titled intermediates are removed on the failure path too, and a missing --srt/--text is refused before the visualisation is encoded. Under --dry-run only, --title passes graphics.py an explicit --end (the source’s duration) because the _vis file it would probe was never written; a real run’s commands are unchanged. Tests: test_waveform_srt_and_title_rerun_with_overwrite, test_waveform_title_dry_run_completes, test_waveform_missing_srt_is_input_before_any_encode.

--auto-chapters lives in metadata.py, and the detectors moved into _common. metadata.py already owns the chapter format, the -c copy graph and the written-vs-asked-for count assertion; scenes.py --chapters-out would put chapter writing in a tool that cannot write chapters. No script in scripts/ imports a sibling tool (only the _-prefixed modules are shared), so silence.detect and scenes.detect_scenes moved into _common/probe.py byte-for-byte and all three tools import them from there. The merge/keep/drop decision itself is propose_chapters() in _common/decision.py: pure, subprocess-free, unit-testable.

The skill proposes chapter timestamps; it never names them. Every proposed title is Chapter N and the result says "titles": "placeholder". Naming a chapter needs knowing what is said in it, which is content understanding — the boundary SKILL.md’s “What this skill does and does not decide” holds. A request to title them is a refusal with the placeholder list offered.

Only the --srt FILE:lang suffix form. The parallel-list alternative (--srt a --srt b --lang en,ja) was specified and dropped: it can get out of order, and two spellings of one argument is the thing the deprecation policy exists to avoid. A single --srt with no suffix still honours --language, so nothing that worked before changed.

The Japanese particle table is a preference, not grammar — and it is a “do not strand at the start of a line” table. は が を に で と の へ も や から まで より come from the task brief plus the five a reader would add. A particle is enclitic: it attaches to the word before it and marks that word’s role, so kinsoku practice keeps the two together. The rule is therefore “prefer the break after a particle, forbid the break before one”, not the other way round. There is no upstream source for the list and no precedent in this repo; it is tunable data, applied among break positions that already fit, so it can never widen a line or change the line count.

R4 scores both directions, which is what makes it decide. An article or preposition opens the noun phrase it governs, so the break before it is the good break (0.2) and the break after it the bad one (0.8). Penalising only the bad direction leaves the greedy width rule to choose among everything else, which is how the first cut of this release still split eval 16’s dl1 cue mid-phrase. With both directions scored, "A third line the tool times for me" comes out as A third line / the tool times for me — one whole phrase per line — and dl4 is unchanged.

1.17.0 — throughput

No new tool. The tool count stays 42. Four candidates were considered and each landed as a flag on the tool that already owns the vocabulary: a beats.py analyser → scenes.py --beats (scenes already decodes the same PCM through audio_envelope(), and “where are the interesting times” must not be split across two scripts); a filler.py → silence.py --filler (filler removal is time-range removal — it reuses keep_ranges() and the identical aselect/concat graph, and a second tool would give the agent two ways to spell “tighten the talking”); a job runner → batch.py --jobs (batch already owns the item loop, the cache and the summary); a cache.py → render.py --cache (the cache key is a stage’s own arguments, and only render knows them). Going to 43 is not a one-line change: it means README.md ×4, SKILL.md ×2 with single digits of headroom, package.json, .claude-plugin/plugin.json, docs/contract.md, references/scripts.md|devices.md|gotchas.md|ci-platform-pitfalls.md, demos/CI.md, tests/corpus/report.md, tests/fixtures/mcp_tools.json, TOOL_META + the reencode table + provides, and test_docs_tool_count_matches_the_real_tool_list. Do not re-propose any of the four.

The caption legibility floor is 4.5 % of the frame height, one number for every destination. ass_units(0.045) = 13 against the 288-line ASS script grid — 87 px of type on a 1920-tall frame. It was chosen as the smallest size that is still comfortably above the ~3.5 % where mobile legibility studies and the platforms’ own caption UIs bottom out, not fitted to the eval cues; that every eval-17 cue happens to fit two lines at exactly 13 is stated in the code comment rather than hidden. There is no per-platform floor table, because nothing per-platform has been measured and a table of seven guesses reads as seven measurements. _platforms.PLATFORMS[name] can gain a min_size the day one is measured; until then one honest number.

One fitted size per file, not per cue. A caption track whose type size changes from cue to cue is the single most visible “this was machine-made” artefact, and it defeats the 1.12 readability work — one measured line width per file is also what makes the wrap regression lock mean anything. --fit-size-scope cue exists for the one outlier cue that would otherwise shrink a ten-minute file, and it is opt-in and named in the result.

--fit-size auto, not on and not off. With off as the default the feature would ship dark: no agent passes a flag it has no reason to know about, and the defect eval 17 measured is precisely that the default path produces four-line cues — eval 18 would measure 1.16.0 again. With on as the default, a caller who deliberately set --size 30 for a brand look would silently get 21, which breaks “the same behaviour for the same input and arguments” for a real, stated argument. auto changes only the path where the skill itself chose the number, and the CHANGELOG carries the behaviour line the stability guarantee requires. A brand.json caption size counts as stated for the same reason --size does: it is a decision about the look that somebody wrote down.

like, tipo, cioè and なんか are not ordinary filler words. They are discourse markers: grammatical in most sentences, so removing them cuts meaning rather than noise — a judgement about content, which this skill does not make. The first three are out of the default lists and reachable with --filler-extra, which says what adding one costs. なんか is in the ja list, because it is the most common Japanese filler and leaving it out makes the flag useless for Japanese; it is orthographically identical to the pronoun use, so every run that removes one warns and --filler-keep なんか takes it back out. The asymmetry is deliberate: English has usable fillers without like, Japanese does not have usable fillers without なんか.

There is no heuristic filler fallback without word timings. “Remove the 0.3 s blips that look like an ‘um’” would cut real speech — short words, breaths, the start of a sentence — and it would do so silently, with no way for the caller to check. A filler word is removed only where a speech engine measured a start < end pair for it; without timings the tool refuses and names --words and --transcribe. This is the same rule as --snap beats: never act on a measurement that was not made.

The ffmpeg version is inside the render cache key. A cached artifact is a file this skill did not produce in this run, and the only honest way to reuse one is to be certain the same code would have produced it. The ffmpeg build banner (the whole ffprobe -version first line, not major.minor: two 7.1.x builds with different libx264 write different bytes), the skill version, the contract version and the flags render forwards to its children are therefore part of the key, so a different build simply misses rather than being asked to trust a file it did not write — no “is this close enough” comparison, no staleness heuristic, and no way for a filter default that changed between builds to leak into a delivery. The cache is also opt-in with no default directory: a cache appearing on someone’s disk unasked contradicts the “a plan leaves nothing behind” posture the whole tool holds.

batch.py --jobs is capped at min(N, cpu_count, 8). Every item is itself an ffmpeg process that already threads across cores; beyond a few concurrent x264 encodes the jobs contend and wall-clock stops improving while memory does not. A number above the cap is clamped with a note rather than refused — refusing a number that is merely optimistic is unhelpful, and the result reports both jobs and jobs_requested so a report claiming “64 jobs” is checkable. The --timeout becomes the whole batch’s budget rather than each item’s, which is why the pool is topped up to jobs in flight rather than submitted all at once: a deadline that every item has already passed cannot stop anything. That shared budget is scoped so it cannot change 1.16: it applies when a --timeout was actually stated, or when --jobs > 1 asked for the batch to be treated as one piece of work. The default sequential run with the default 1800 s keeps the old per-item ceiling — otherwise a folder of forty files that used to finish would start exiting 124, for a flag nobody passed.

--beats, --filler, --jobs and --cache get no SKILL.md request row in 1.17.0. The existing rows already route (“cut out the pauses” → silence.py, “do this to every file in the folder” → batch.py, “a 60 s highlight” → scenes.py), references/scripts.md carries the flags, and SKILL.md has single digits of headroom under its 30,000-byte budget. Only the caption size got a row, because that one is a different answer to a request the table already claims to route. If eval 18 shows agents missing --filler or --snap beats, 1.18.0 buys the rows.

--shots’ flow measurement is scoped to stay dependency-free and fast: 48x27 grayscale frames at 4 fps, a 4x4 grid of block matches, +/-3 px search. A real optical-flow library was never on the table — the zero-dependency rule stdlib+ffmpeg covers every other script, and a Lucas-Kanade or dense-flow implementation in pure Python would be both slower and no more honest for what --shots actually needs, which is three labels, not a per-pixel field. 48x27 keeps a whole shot’s decode at a few KB (a feature-length input never risks memory the way --beats’ 22050 Hz PCM does), and 4 fps is enough to see whether the frame is panning, static or churning without sampling every frame ffmpeg decodes. The zero-shift tie-break in _block_match (see the code comment) exists because a textureless block — sky, an out-of-focus background, --shots’ own test_scenes_shots_static_clip_is_labelled_static fixture — ties every candidate offset on SAD, and without an explicit bias towards “no motion” the scan reported the search window’s first corner as the measured displacement: a still frame read as steady motion in one direction, every time. agreement (how consistently the 16 blocks agree on direction) is what tells a pan from motion-inside-a-static-frame: a camera move shifts the whole picture one way, a subject moving in front of a still background does not, and the two look identical in magnitude alone.

--audio-peaks writes a new audio_peaks_db key rather than changing audio_peaks. The unconditional audio_peaks list scenes.py has always reported ([{time, rms}]) is a different measurement in a different unit, used to score --highlights; the stability guarantee’s “no key given a different type” means an explicit request for dBFS-unit peaks needed its own name, not a unit change to a key a caller has been reading since 1.0.

--speech reports a ratio, not a label. A zero-crossing-rate proxy (this window’s ZCR over the file’s own median) says nothing about whether a stretch is speech or music — it says speech transients cross zero faster than sustained tones, which is true often enough to be a usable number and not true often enough to be a classifier. Naming the key speech_music_ratio rather than is_speech keeps the tool on its side of the “analysis tools report numbers, they never decide what’s interesting” line: the calling agent reads the number and decides what a high or low ratio means for its own request, the same way it already reads --rank-by audio’s RMS figures without this skill calling any of them “the best scene”.

--speech-aware composes with --filler by feeding the same keep_ranges()/merge_spans() pipeline --filler already uses, not a second removal pass. The composition risk was two independent cut lists disagreeing — a breath kept by --speech-aware getting cut anyway because --filler found a word inside it, or the reverse. speech_aware_silences() returns two lists (sentence-boundary silences to remove, breaths to keep) instead of a single filtered list, so a breath is simply never added to the removal side: it is not “removed then added back”, which would leave a seam. The one designed interaction is a filler word inside a kept breath: the breath itself is not cut, but merge_spans() still unions the filler span into the removal list the same way it has always merged a filler word sitting inside a plain silencedetect gap (the 1.17.0 regression fix), so a real disfluency is still removed even from air --speech-aware would otherwise keep whole. BREATH_FLOOR (0.12 s) exists because plain detect() only sees gaps at or above --min-silence; without a second, shorter silencedetect pass the breaths inside a sentence are invisible to begin with, not merely unclassified.

sync.py’s N-source shape adds a new optional more_sources positional rather than renaming second to a list. The stability guarantee treats an argparse dest as a CLI surface: renaming second to sources (even as a 1+ nargs) would have been a rename of a positional the contract promises never to rename, and the MCP inputSchema derives its property names directly from argparse dests (see _contract.input_schema), so the rename would have propagated into a removed MCP property too. second therefore keeps meaning exactly what it always has — the one other recording a 2-source call aligns — and more_sources (nargs="*", default []) is purely additive: omitted, the CLI and its JSON are unchanged from 1.17. args.sources = [second] + more_sources is built once, right after parsing, so the rest of main() (and measure_one(), factored out of the single-pair drift code that used to live inline) never has to know which positional a given source came from.

Why sources carries the single-source measurement too, additively, instead of only appearing at N>1. A caller that standardises on reading sources for a multi-camera job should not also need a special case for the 2-source call — sources[0] is always the same measurement as the top-level offset_seconds/confidence when there is exactly one. The reverse (a flat second key materialising when there are three sources) is not offered, because there is no single “the” second source to put there once there are two or three, and inventing one would be worse than leaving the key out.

multicam.py --switch energy’s window and merge rule. Loudness is measured every 0.25 s (four times finer than the default --min-shot, so a real cut point is not missed by more than 0.25 s) and picking the winner is a plain argmax over cameras with a video stream — the same “biggest number wins” rule scenes.py --rank-by audio already uses, not a smarter voice-activity model, because a second measurement method would be a second thing to keep honest and this one is easy to audit from the cuts list alone. Folding a run shorter than --min-shot into its neighbour (rather than, say, discarding it or holding the previous camera) was chosen because it never invents a cut that was not there and never drops the fact that a switch was measured at that instant — it only refuses to act on a switch too brief to be a readable shot. The direction of the fold (into the next run, or the previous one if it is the last) is arbitrary in the sense that either choice is defensible; it is documented here rather than left to be rediscovered from the code, and pinned by test_multicam_switch_energy_respects_min_shot.

The multicam timeline needed no new render.py project stage. A --switch cut list is already exactly what clips[] expresses: one clip per cut, src the camera’s own file, in/out on that camera’s own timeline (the reference cut, shifted by the camera’s measured offset). Inventing a "multicam" stage type would have meant a second way to say “play this camera from A to B” that render.py would have to keep in sync with clips[] forever; reusing clips[] means a switch list can be re-rendered with a different --min-shot by re-running multicam.py --switch energy and rewriting the same project’s clips array, with every other stage (captions, audio, export, check) working on the multicam edit exactly as it would on any other project. --edl writes the plain cut.py --segments file (START-END per line) rather than a render.py project directly, because the camera index a project’s clips need is already sitting in the JSON cuts field ([[start, end, camera], ...]) — turning that into clips[] is a few lines in the calling agent, not a new file format this tool would have to maintain.

2.1.0 — handing the cut to an editor

2.3.0 — captions that can be seen

2.2.4 — the first bad input, named

2.2.6 — silence is reported, not verified

2.3.0 — short, repeated and silent inputs

2.4.1 — a plain join keeps its picture

2.6.0 — the GPU

2.7.0 — a second speech engine

2.8.0 — cuts that say what they did

2.10.7 — geometry uses the displayed frame

2.10.5 — an accurate cut starts its picture at 0

2.10.0 — a video layer in a project

2.10.9 — FFmpeg 8.0.1 (Ubuntu 26.04)

2.10.9 — doctor’s FFmpeg advisory and the Python 3.9 deprecation

2.11.0–2.12.0 — nothing happens silently

The principle: every refusal, rejection, skip, fallback or swallowed error leaves a structured record a caller can read – fields in the --json document, not only prose in error.message – plus its stderr line. The entries below are the infrastructure and the first decisions taken under it; the rest of the silent paths are converted onto the same infrastructure in later PRs.

Part 2: a degraded or partial result is never reported as the asked-for one

Part 3 converts the remaining fallbacks, skips and stderr-only warnings onto that infrastructure. What was decided there:

2.14.0 — the FFmpeg 5.x deprecation

2.15.0 — render.py’s intermediate stages encode with veryfast

2.16.0 — intermediates at superfast, not ultrafast

2.17.0 — check.py –content’s dropouts row

2.17.2 — cut.py fades the sound at every –segments join it re-encodes