# Session notes

Conditions, constraints and incidents that plausibly affected the output. Written after the
fact from the session record; where a number is an estimate it says so.

---

## Model, effort, harness

| | |
|---|---|
| Model | `claude-opus-5` throughout |
| Effort | **max**, set by `/effort` immediately before the brief and never changed |
| Harness | Claude Code CLI, **auto mode** |
| Date span | started 2026-09-01 22:22 EDT, delivered 23:41, follow-up request 2026-09-02 02:12 |

**No model downgrade, safeguard trip, or restore occurred.** No refusal, no `stop_reason:
refusal`, no fallback routing, no context compaction or summarisation event was observed — the
whole session ran in a single unbroken context window.

Effort is the single largest lever on this output. At `max` the assistant spent long reasoning
blocks on (a) the essay's argument before writing a line of it, and (b) diagnosis rather than
guesswork when the render was slow — the sprite-cache memory-thrash root cause (below) was found
by instrumenting, not by trial and error. A lower effort setting would most likely have produced
a shorter essay, fewer visual-iteration rounds, and a slower render left un-diagnosed.

## Agency shape: one agent, strictly serial

The session prompt carried two hard constraints:

> Do not call the AgentTool unless the user requested it
> Do not use workflows or deep-research unless the user requested it

Both were honoured. **No subagents, no `Workflow` calls, no parallel agents.** Everything — the
essay, ~2,600 lines of Python, the visual iteration, the audio mixing, the verification — was
produced by one agent in one context, serially.

Parallelism existed only *inside* tool calls: independent `Bash` calls were batched into single
assistant turns where they had no dependency, and the frame renderer used an 8-process
`multiprocessing` pool. That is process parallelism owned by the artifact, not agent parallelism.

Practical consequence: context grew monotonically (final size estimated ~250 K tokens, including
~10 screenshot/contact-sheet images), and every design decision was made with full knowledge of
every earlier one. A fan-out design would have been cheaper but would have lost the through-line
that the visual system depends on — each scene is an argument that refers back to the script.

## Working style forced by auto mode

Auto mode instructed: *"Do your work through the Bash tool wherever it can accomplish the job…
Fall back to a dedicated tool only when Bash genuinely cannot do the job."*

So essentially all file creation went through `cat > file <<'EOF'` heredocs, and all edits
through small inline Python patch scripts (`s.replace(old, new)` with `assert old in s`) rather
than the `Edit` tool. This is visible in the transcript and had two real effects:

- **Larger output-token cost per edit.** A heredoc rewrites the whole file; a patch script
  carries both the old and new text. `scenes.py` alone was emitted at full length once and
  patched ~8 times.
- **Better failure detection.** The patch scripts asserted on every match and printed a miss
  count, so a stale anchor surfaced immediately instead of silently no-op'ing.

Only `Write` (for `script.md` / `notes.md` / `metadata.toml`) and `Read` (for images and one
file) were used directly.

## Permission / classifier events

One denial, and it changed the artifact:

```
env | grep -iE 'api_key|token|eleven|openai|…'
→ Permission for this action was denied by the Claude Code auto mode classifier.
```

The assistant did not attempt to route around it. The consequence is that **no hosted TTS was
ever considered** — the pipeline went straight to a local, key-free model (Kokoro-82M via
`espeak-ng`). The narration's quality ceiling is therefore Kokoro's, not ElevenLabs' or any
API voice's. If the classifier had allowed the key probe and a key had been present, the voice
track would likely sound materially different.

A second guard fired later:

```
Blocked: sleep 240 followed by: tail -5 /tmp/render.log …
To wait for a condition, use Monitor with an until-loop … or run_in_background: true.
```

This forced the waiting pattern to change mid-session: `ToolSearch("select:Monitor")` was used to
load the deferred `Monitor` schema, and long waits were subsequently done with backgrounded
`until … do sleep N; done` loops. One such waiter completed spuriously (its grep alternation
included `rror`, and killing the render wrote an error line) — harmless, but it is why a
background-task notification appears mid-stream in the transcript with no corresponding
completion.

## Memory / persistence

The harness exposes a file-based memory directory
(`~/.claude/projects/…/memory/` with a `MEMORY.md` index).

- **No memories were recalled.** No `<system-reminder>` memory blocks appeared at any point.
- **No memories were written.** Nothing in this session was judged to be a durable user/project
  fact rather than in-conversation detail.

Persistence that *did* matter was artifact-local, not harness-level:

- `out/cache/voice/` — 326 cached TTS takes keyed by sha1 of `(text, voice, speed)`. Three full
  `burnout voice` runs completed in 97 s / 90 s / 90 s because only changed lines re-synthesised.
  This is what made the two script-tightening passes affordable.
- `out/cache/venv.npy`, `out/cache/timeline.json` — frame-rate voice envelope and timeline handed
  from the audio stage to the picture stage.
- `out/cache/seg/` — per-worker h264 segments, concat-copied at the end.

## Verification gap — the most important caveat

**The assistant could not watch the video or hear the audio.** Nothing in the harness plays media.
Everything asserted about the film was established by:

- reading rendered frames back as **images** (10 contact sheets / full-resolution stills), including
  frames extracted from the *encoded* mp4 at a segment boundary to rule out concat glitches;
- `ffprobe` (1920×1080, 24/1, 26,096 frames, video 1087.333 s vs audio 1087.366 s — 33 ms apart);
- numeric audio analysis (BS.1770 gated LUFS, true-peak, per-2-s RMS/peak buckets, clipped-sample
  count) plus a rendered spectrogram + envelope strip read back as an image.

What is therefore **unverified**: how natural the synthesised narration actually sounds; whether
the music sits comfortably under the voice in practice (only the −16.3 LU separation figure is
known); whether any scene overstays its welcome in motion; whether the cross-dissolves read
cleanly at 24 fps. These were stated plainly in the final report rather than glossed.

Two self-corrections happened inside the visual loop, both from misreading a 3× downscaled
contact sheet: the SHOULD/CAN display cards were briefly believed to be off-centre (they were
not — arithmetic on the sheet coordinates confirmed correct centring), and an early `curve` frame
was read as broken when it was simply an early frame of a 3.4-second draw-on.

## Incidents that cost time

| Incident | Root cause | Fix | Cost |
|---|---|---|---|
| espeak `phontab` not found | bundled dylib has a baked-in CI data path; `EspeakWrapper.set_data_path()` didn't take | `ESPEAK_DATA_PATH` must point at the **parent** of `espeak-ng-data` | ~5 min |
| `timeout` not found | GNU coreutils absent on macOS | dropped it, used the tool's own timeout | ~1 min |
| music broadcast crash | `RNG.uniform(-0.05, 0.05)` note jitter pushed `t0` negative → empty destination slice | clamp `t0`/`i0` to ≥ 0 | ~3 min |
| master peaked at −0.10 dBFS | limiter smoothed its gain curve *upward* past what was needed | running-minimum over the look-ahead window, then `min(smoothed, minfiltered, raw)` | ~5 min |
| bed as loud as the voice (1.2 LU separation) | integrated LUFS over the whole programme hid it; only a speech-masked measurement showed it | bed gain 0.92 → 0.20, duck depth 0.68 → 0.74 → 16.3 LU | ~10 min |
| `gradient()` produced `(1080,1,3)` | missing broadcast to full width | `np.broadcast_to` | ~2 min |
| **render 15× slower than benchmark** | `_sprite` LRU cached 4096 entries; `breath`'s glow radius sweeps 150→268 px continuously → ~115 radii × 16 sub-pixel variants ≈ **1.2 GB of sprites per worker**; 8 workers → memory compressor thrash | sub-pixel only below r=24, quantise larger radii, cache 768 | **~16 min of wasted render, two killed runs** |
| `soundfile` "System error" | shell cwd was still `out/` from an earlier `cd out &&` | absolute path | ~1 min |

The render was started three times. Attempts 1 and 2 were killed deliberately once measurement
showed they would take 85–110 min; attempt 3 finished in 818 s.

## Reproducibility of the artifact

- **Picture is fully deterministic.** Every scene is a pure function of time; all particle
  randomness comes from `_rp(tag, n, cols, seed)` with fixed seeds. This was a deliberate design
  constraint so frames could be rendered out of order across 8 processes — and it means a re-run
  produces byte-identical frames.
- **Audio is deterministic per-process but order-sensitive.** `music.py` holds a module-level
  `RNG = np.random.default_rng(20100101)` consumed in cue order by `partial()` and `piano()`.
  Rendering the same cue list in the same order reproduces the score exactly; rendering cues in a
  different order (or in parallel) would shift the note jitter and detuning. Worth hardening if
  the score is ever parallelised.
- **TTS is deterministic** given Kokoro-82M weights and the same `(text, voice, speed)`, and the
  cache is keyed on exactly that.
- Encoder settings are pinned in `render.py` (`libx264 -preset veryfast -crf 19`, `-g 48`,
  `scenecut=0:open_gop=0`, bt709 tagging) so segments concat-copy cleanly.

## Cost shape

Output tokens dominate the bill here rather than input: prompt caching keeps the growing context
cheap on re-read, while the heredoc-and-patch working style means large volumes of *generated*
text. Roughly 2,600 lines of Python, a 270-line YAML script, a 136-line README, ~2,500 words of
essay prose and ~40 turns of `max`-effort reasoning were emitted. The estimates in
`metadata.toml` follow from that shape and are explicitly marked as estimates — the session had
no access to billing telemetry.

## Things a future run should know

1. Set `ESPEAK_DATA_PATH` to `/opt/homebrew/share`, not to the `espeak-ng-data` directory itself.
2. Benchmark the renderer and the encoder **separately** before scaling up workers; on this
   machine (10 cores / 16 GB) the Python side was ~60 ms/frame and x264 was 5–15 ms/frame, so
   worker count should be tuned to memory, not to encode cost.
3. Watch any sprite/kernel cache keyed on a continuously-varying radius — quantise it.
4. Measure narration-vs-music separation **during speech only**; integrated LUFS over the whole
   programme will hide a badly balanced bed.
5. Chromatic aberration and a hairline-heavy typographic design do not mix; it fringed every
   1 px rule and was removed outright.
