---
phase: 01-stabilize-video-pipeline
plan: 08
type: tdd
wave: 1
depends_on: []
files_modified:
  - package.json
  - package-lock.json
  - src/background/index.ts
  - src/background/webm-remux.ts
  - tests/background/webm-remux.test.ts
  - tests/fixtures/last_30sec.webm
  - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
autonomous: false
requirements:
  - REQ-video-ring-buffer
tags:
  - webm
  - remux
  - ts-ebml
  - webm-muxer
  - vp9
  - matroska

must_haves:
  truths:
    - "tests/offscreen/webm-playback.test.ts container-level format=duration assertion (>= 25_000 ms) is GREEN against the regenerated tests/fixtures/last_30sec.webm fixture."
    - "tests/offscreen/webm-playback.test.ts ffmpeg full-decode timeline assertion (>= 25_000 ms) is GREEN against the regenerated fixture."
    - "mergeVideoSegments() no longer exists in src/background/index.ts; its callers go through the new remuxSegments() in src/background/webm-remux.ts."
    - "The remux produces a single-EBML-header WebM (verified by byte-level probe asserting EBML header occurrence count === 1) covering all VP9 frames from every input segment with monotonically increasing timestamps."
    - "The 53 baseline GREEN tests across 11 files plus the 4 webm-playback tests all pass (`npx vitest run` exit 0)."
    - "Operator running ./smoke.sh + opening the produced last_30sec.webm in Chrome AND mpv sees ~30 s of continuous playback (NOT ~9 s)."
  artifacts:
    - path: "src/background/webm-remux.ts"
      provides: "remuxSegments(segments: VideoSegment[]): Promise<Blob> — parses each input segment via ts-ebml, extracts VP9 SimpleBlocks + cluster timecodes, re-mixes monotonic timestamps via webm-muxer into a single-EBML-headered WebM."
      min_lines: 120
      exports:
        - "remuxSegments"
    - path: "tests/background/webm-remux.test.ts"
      provides: "Unit-level RED→GREEN tests for the remux helper: single-EBML invariant, monotonic-timestamp invariant, frame-count preservation across input segments, keyframe-flag preservation."
      min_lines: 100
    - path: "src/background/index.ts"
      provides: "createArchive() calls remuxSegments() (await — async) instead of synchronous mergeVideoSegments(); EmptyVideoBufferError surface preserved."
      contains: "remuxSegments"
    - path: "tests/fixtures/last_30sec.webm"
      provides: "Regenerated fixture captured against the post-remux build; ffprobe container duration >= 25_000 ms; ffmpeg full-decode time >= 25_000 ms."
    - path: "package.json"
      provides: "Adds `ts-ebml` ^3.0.2 and `webm-muxer` ^5.1.4 to dependencies (both MIT, both SW-compatible per the d13 debug session library survey)."
      contains: "ts-ebml"
    - path: ".planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md"
      provides: "AMENDMENT block appending D-14-remux (ts-ebml + webm-muxer remux) supersedes D-13's file-concat merge; D-13's recorder-side restart-segments lifecycle preserved."
      contains: "D-14-remux"
  key_links:
    - from: "src/background/index.ts:createArchive"
      to: "src/background/webm-remux.ts:remuxSegments"
      via: "await remuxSegments(videoBufferResponse.segments)"
      pattern: "remuxSegments\\("
    - from: "src/background/webm-remux.ts"
      to: "ts-ebml package"
      via: "import { Decoder } from 'ts-ebml'"
      pattern: "from ['\"]ts-ebml['\"]"
    - from: "src/background/webm-remux.ts"
      to: "webm-muxer package"
      via: "import { Muxer, ArrayBufferTarget } from 'webm-muxer'"
      pattern: "from ['\"]webm-muxer['\"]"
    - from: "tests/offscreen/webm-playback.test.ts"
      to: "tests/fixtures/last_30sec.webm"
      via: "ffprobe + ffmpeg drive the regenerated fixture; the 2 RED assertions (lines 232-316) flip GREEN."
      pattern: "MIN_PLAYABLE_DURATION_MS"
---

<objective>
Replace `src/background/index.ts:mergeVideoSegments()` file-concat (D-13) with a real single-EBML-headered WebM **remux** via `ts-ebml` (parse) + `webm-muxer` (write). Each input segment carries its own EBML header + Segment + Cluster structure; the output is one EBML + one Segment whose clusters contain every VP9 SimpleBlock from every input segment, with timestamps adjusted to be monotonic against a single global timeline. D-13's recorder-side restart-segments lifecycle (the part that fixed orphan-P-frame freezes) is preserved; only the merge step is replaced.

Purpose: satisfy SPEC §10 #7 (`last_30sec.webm` plays back in a browser) for real. The 2026-05-15 closure shipped a multi-EBML concat that mpv / Chrome / ffprobe all play as ~9.94 s (first segment's Info.Duration only) instead of ~30 s. The two RED tests already on disk in `tests/offscreen/webm-playback.test.ts` (lines 231-316) pin the actual contract; this plan drives them GREEN.

Output:
- `src/background/webm-remux.ts` — new module exporting `remuxSegments(segments: VideoSegment[]): Promise<Blob>`.
- `src/background/index.ts` — `mergeVideoSegments()` deleted; `createArchive()` calls `await remuxSegments(...)` (becomes async-on-the-merge-path; saveArchive already awaits createArchive).
- `tests/background/webm-remux.test.ts` — RED→GREEN unit tests pinning the single-EBML / monotonic-timestamp / frame-count / keyframe-flag invariants without requiring a real MediaRecorder.
- Regenerated `tests/fixtures/last_30sec.webm` from a fresh `./smoke.sh` against the post-remux build, validated by the 2 new playback-duration assertions.
- `package.json` + `package-lock.json` — adds `ts-ebml` ^3.0.2 + `webm-muxer` ^5.1.4.
- CONTEXT.md amendment block (D-14-remux supersedes D-13's file-concat; D-13's recorder lifecycle preserved). The amendment block was authored at plan-creation time by the orchestrator; Task 4 step 9 verifies it remains intact via grep checks (no new file mutation expected).
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md
@.planning/phases/01-stabilize-video-pipeline/01-VERIFICATION.md
@.planning/phases/01-stabilize-video-pipeline/01-UAT.md
@.planning/debug/d13-multi-ebml-concat-unplayable.md
@tests/offscreen/webm-playback.test.ts
@src/background/index.ts
@src/shared/types.ts
@src/shared/binary.ts

<interfaces>
<!-- Key types and contracts the executor will use. Extracted from codebase. -->
<!-- Executor should use these directly — no codebase exploration needed for them. -->

From `src/shared/types.ts`:
```typescript
export interface VideoSegment {
  data: Blob;          // a self-contained ~10 s WebM blob (D-13 segment)
  timestamp: number;
}

export interface VideoBufferResponse {
  segments: VideoSegment[];
}
```

From `src/background/index.ts` (current call site to be replaced, lines 395-421 + 449):
```typescript
// CURRENT (D-13 file-concat — TO BE DELETED):
function mergeVideoSegments(segments: VideoSegment[]): Blob {
  const sortedSegments = [...segments].sort((a, b) => a.timestamp - b.timestamp);
  const blobs: Blob[] = sortedSegments.map((segment) => segment.data);
  return new Blob(blobs, { type: 'video/webm' });
}

// Called at line 449 (inside createArchive):
const videoBlob = mergeVideoSegments(videoBufferResponse.segments);
```

From the debug session research (`.planning/debug/d13-multi-ebml-concat-unplayable.md` lines 380-410), the remux algorithm:
```
1. For each segment blob, parse via ts-ebml Decoder → walk EBML tree →
   for each Cluster's SimpleBlock children, extract:
     • VP9 frame bytes (the SimpleBlock payload after the per-block header)
     • keyframe flag (Matroska SimpleBlock first-flag-byte bit 7)
     • cluster Timestamp (Cluster.Timecode element, ms)
     • block-local timestamp offset (relative to its enclosing cluster)
2. Compute monotonic adjusted timestamp:
     globalUs = (segmentBaseMs + clusterTsMs + blockOffsetMs) * 1000
   where segmentBaseMs accumulates the prior segment's total content
   duration (the last block's local timestamp + 1 frame worth, ~33 ms).
3. Stream all adjusted frames into a single webm-muxer Muxer with
   addVideoChunkRaw(frameData, isKey ? 'key' : 'delta', globalUs).
4. muxer.finalize() → target.buffer (ArrayBuffer) → wrap in Blob.
```

From `webm-muxer` 5.1.4 API surface (from npm registry; see https://github.com/Vanilagy/webm-muxer):
```typescript
import { Muxer, ArrayBufferTarget } from 'webm-muxer';

const target = new ArrayBufferTarget();
const muxer = new Muxer({
  target,
  video: {
    codec: 'V_VP9',                  // Matroska codec id for VP9
    width: <px>, height: <px>,       // probe from first segment's Video element
    frameRate: 30,                   // approximate — not strict
  },
  // No `audio` block — Phase 1 SPEC §9 / CAP-01 explicitly excludes audio.
  type: 'webm',                      // emit a WebM (Matroska subset) container
  firstTimestampBehavior: 'offset',  // tolerate a non-zero first frame ts
});

// timestamp is MICROSECONDS, integer.
muxer.addVideoChunkRaw(data: Uint8Array, type: 'key' | 'delta', timestamp: number, meta?: { decoderConfig?: { description?: Uint8Array } });

muxer.finalize();
// target.buffer is now an ArrayBuffer holding the single-EBML WebM.
```

From `ts-ebml` 3.0.2 API surface (from npm registry; see https://github.com/legokichi/ts-ebml):
```typescript
import { Decoder, tools } from 'ts-ebml';

const decoder = new Decoder();
const elements = decoder.decode(arrayBuffer);   // EBMLElementDetail[]
// Walk elements; each has { name, type, dataSize, value, isEnd, ... }.
// Element names of interest (Matroska element table):
//   'EBML', 'Segment', 'Tracks', 'TrackEntry', 'Video', 'PixelWidth',
//   'PixelHeight', 'CodecPrivate', 'Cluster', 'Timecode', 'SimpleBlock'.
// SimpleBlock payload is the raw block: variable-length track number prefix,
// 2 bytes signed timestamp delta, 1 flags byte (bit 7 = keyframe), then
// VP9 frame bytes.
```

Existing test infrastructure (reusable patterns from `tests/background/request-id-protocol.test.ts` and `tests/offscreen/codec-check.test.ts`):
- `vi.resetModules()` + dynamic `await import('../../src/background/...')` per test (fresh module state).
- No chrome stub needed for `webm-remux.ts` — it's a pure async function.
- `import.meta.url` + `node:fs` for fixture reads.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Install ts-ebml + webm-muxer; verify SW-compat by import-only test (Wave 0 contract).</name>
  <read_first>
    - package.json (current dependency list — confirm neither library is already installed)
    - vite.config.ts (confirm crxjs picks up new imports automatically; no Rollup external config needed)
    - .planning/debug/d13-multi-ebml-concat-unplayable.md, Evidence/library-survey section (lines 380-410) — confirms both libraries are SW-compatible (no `window`/`document` references)
    - tests/offscreen/codec-check.test.ts (test scaffold pattern: vi.resetModules + dynamic import)
  </read_first>
  <files>package.json, package-lock.json, tests/background/webm-remux-deps.test.ts</files>
  <behavior>
    - Test 1: `tests/background/webm-remux-deps.test.ts` imports `webm-muxer` and `ts-ebml` and asserts both exports surface (`Muxer`, `ArrayBufferTarget` from webm-muxer; `Decoder` from ts-ebml). RED if either is missing from `node_modules`; GREEN after `npm install` lands.
    - Test 2: same file asserts that loading both modules under Node's default globals (no jsdom shim) does not throw any `window is not defined`/`document is not defined` errors. RED if either dist accidentally hits a DOM global; GREEN by virtue of both libraries' published SW-compatibility (per debug session lines 380-410).
  </behavior>
  <action>
    1. RED:
       - Create `tests/background/webm-remux-deps.test.ts` with two `it` blocks:
         (a) `it('exports Muxer + ArrayBufferTarget + Decoder', async () => {...})` — dynamic-import both, expect named exports defined.
         (b) `it('loads under default Node globals without DOM-global ReferenceErrors', async () => {...})` — assert that `delete (globalThis as any).window; delete (globalThis as any).document;` followed by re-import does not throw.
       - Run `npx vitest run tests/background/webm-remux-deps.test.ts`; expect both tests RED with `Cannot find module` errors (libraries not installed yet).
    2. GREEN:
       - `npm install --save ts-ebml@^3.0.2 webm-muxer@^5.1.4` (versions verified live on npm registry 2026-05-16: ts-ebml 3.0.2 + webm-muxer 5.1.4; both MIT; both active).
       - Commit `package.json` + `package-lock.json` changes; do NOT touch other files.
       - Re-run the test; both flip GREEN.
    3. Re-run the full suite (`npx vitest run`) to confirm no collateral regression (53 baseline GREEN + 2 new GREEN = 55; the 2 existing RED tests in webm-playback.test.ts stay RED — they're Task 4's GREEN target).
    Use absolute imports (`import { Muxer } from 'webm-muxer'`, `import { Decoder } from 'ts-ebml'`) per the user's global style guide. No `as any`; no `@ts-ignore`. If TypeScript complains about missing types, install `@types/...` packages if available OR add a minimal `.d.ts` shim under `src/shared/` (whichever is canonical for the library's published shape — check the library's repo first, do NOT hand-roll types if the library ships its own).
  </action>
  <verify>
    <automated>npx vitest run tests/background/webm-remux-deps.test.ts &amp;&amp; npx tsc --noEmit</automated>
  </verify>
  <acceptance_criteria>
    - `package.json` dependencies include `ts-ebml` and `webm-muxer` at the pinned versions.
    - `tests/background/webm-remux-deps.test.ts` exists with the 2 tests above and both PASS.
    - `npx tsc --noEmit` exits 0 (no type errors from either library import).
    - `npx vitest run` shows 11 files / 53 GREEN + 2 new GREEN; the 2 RED in webm-playback.test.ts remain RED (this task does not touch the production code path yet).
  </acceptance_criteria>
  <done>Both libraries installed with pinned versions, the import-surface test pinning both library names is committed and GREEN, tsc clean, baseline suite preserved.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add unit-level RED tests for remuxSegments() invariants (single-EBML, monotonic ts, frame-count, keyframe-flag).</name>
  <read_first>
    - tests/offscreen/segment-rotation.test.ts (existing test pattern for D-13; reuse the `Blob`-fixture-building style — uses node `Buffer`)
    - tests/offscreen/webm-playback.test.ts lines 1-100 (FIXTURE_PATH idiom + ffprobe/ffmpeg helpers; we'll reuse the ffprobe binary path for any container-introspection assertions in this test file)
    - .planning/debug/d13-multi-ebml-concat-unplayable.md, Evidence/H4 byte-level EBML probe section (lines 311-373) — describes the exact EBML element IDs to scan for: EBML header `0x1A 0x45 0xDF 0xA3`, Segment `0x18 0x53 0x80 0x67`, Cluster `0x1F 0x43 0xB6 0x75`.
    - src/shared/types.ts (VideoSegment shape).
    - tests/background/request-id-protocol.test.ts lines 53-85 (vi.fn / port stub pattern — but webm-remux.ts needs no chrome stub).
  </read_first>
  <files>tests/background/webm-remux.test.ts</files>
  <behavior>
    - Test 1 (RED → GREEN by Task 3): given the canonical 3-segment fixture `tests/fixtures/last_30sec.webm` sliced into 3 `Blob` segments (via the byte-offset probe in the debug session: seg1 = bytes [0, 509038), seg2 = bytes [509038, 970967), seg3 = bytes [970967, EOF)), `remuxSegments([seg1, seg2, seg3])` returns a `Blob` whose bytes contain EXACTLY 1 occurrence of the EBML header magic `[0x1A, 0x45, 0xDF, 0xA3]` and EXACTLY 1 occurrence of the Segment magic `[0x18, 0x53, 0x80, 0x67]`.
    - Test 2: the same output blob's byte size is within [0.7×, 1.3×] of the sum of input segment sizes — sanity check that we're not silently dropping content NOR ballooning it 10x.
    - Test 3: `ffprobe -v error -show_entries format=duration -of csv=p=0` against the output blob (write to tmpfile first) reports a duration >= 25_000 ms (skip-if-missing per webm-playback.test.ts pattern — use `existsSync(FFPROBE_BIN)` gate).
    - Test 4: `ffprobe -count_frames -show_entries stream=nb_read_frames` against the output blob reports a frame count `n` where `905 <= n <= 912` (the per-segment counts from the debug session probe were `301+300+311 = 912`; the muxer may drop at most one partial frame per segment boundary, so 3 boundaries × max 1 partial frame each = ±3 frames absolute tolerance). I-01 fix (2026-05-16 checker pass): tightened from the prior ±20% band (which would accept catastrophic loss like 730 frames) to ±1% / ±3 absolute frames, sized to the documented muxer boundary partial-frame drop only.
    - Test 5: empty-input → `remuxSegments([])` returns a `Promise<Blob>` whose blob has `.size === 0`. (Defense-in-depth; saveArchive's EmptyVideoBufferError throw guards this path upstream, but the helper should be safe on its own.)
  </behavior>
  <action>
    1. Create `tests/background/webm-remux.test.ts` with the 5 `it` blocks described above.
    2. Reuse the `FFPROBE_BIN` constant + `ffprobeAvailable()` skip-gate from `tests/offscreen/webm-playback.test.ts` (copy them inline — the test file lives in a different directory so a direct import is awkward; keep both copies trivially in sync per the existing webm-playback.test.ts pattern).
    3. Use `node:fs` `readFileSync` + `Buffer.subarray(start, end)` to slice the fixture into 3 segment Blobs (use `new Blob([buffer])` to wrap each slice). Add a `splitFixtureIntoSegments(): Blob[]` helper at the top of the test file documenting the byte offsets from the debug session probe.
    4. To find EBML element occurrences, write a tiny `countMagic(bytes: Uint8Array, magic: Uint8Array): number` helper that does a simple byte-window scan (no library needed — 4-byte patterns, no false-positive risk at fixture scale). The Cluster magic should appear >= 3 times in the output (one Cluster per ~1-3 s); but ASSERT only that EBML headers and Segments are EXACTLY 1 each.
    5. Use `await import('../../src/background/webm-remux')` (dynamic import) so the import error in this task (the file doesn't exist yet) is the test failure signal rather than a Vitest collection error. Wrap each `it` body in try/catch + `expect.fail()` if the import itself throws — gives a readable RED message rather than `Cannot find module`.
    6. Run `npx vitest run tests/background/webm-remux.test.ts`; expect ALL 5 tests RED (the module doesn't exist yet).
    7. DO NOT IMPLEMENT THE MODULE YET — Task 3 is the GREEN side of this RED gate.
  </action>
  <verify>
    <automated>npx vitest run tests/background/webm-remux.test.ts 2>&amp;1 | grep -E "Tests.*[0-9]+ failed"</automated>
  </verify>
  <acceptance_criteria>
    - `tests/background/webm-remux.test.ts` exists with 5 tests.
    - All 5 tests are RED (production module not yet implemented).
    - Baseline 55 tests (53 + 2 from Task 1) still GREEN; the 2 webm-playback duration RED still RED; 5 new RED here. Total: 11 files / 60 tests / 7 failed | 53 passed. tsc still exit 0 (the test file is type-clean — uses VideoSegment from shared types).
    - No file under `src/` is modified by this task.
  </acceptance_criteria>
  <done>5 RED tests committed pinning the remux invariants at the unit level; baseline preserved.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: Implement remuxSegments() in src/background/webm-remux.ts — drives all 5 RED tests in webm-remux.test.ts to GREEN.</name>
  <read_first>
    - tests/background/webm-remux.test.ts (the contract — produced by Task 2)
    - .planning/debug/d13-multi-ebml-concat-unplayable.md, Evidence/library-survey + the algorithm description on lines 380-410 (the remux pipeline this implementation realizes)
    - src/shared/types.ts (VideoSegment + VideoBufferResponse)
    - src/shared/logger.ts (Logger class shape — same prefix-based instantiation used in SW; pick `Logger('Remux')` or equivalent)
    - The `ts-ebml` and `webm-muxer` README files in node_modules/ (installed by Task 1; READ the published library docs there rather than guessing API surface — the interfaces block in this plan is a sketch, the dist source is authoritative).
  </read_first>
  <files>src/background/webm-remux.ts</files>
  <behavior>
    Implements `export async function remuxSegments(segments: VideoSegment[]): Promise<Blob>` satisfying all 5 tests from Task 2. See behavior list there.
  </behavior>
  <action>
    1. Create `src/background/webm-remux.ts` with extensive JSDoc per project style (the user's global guide mandates extensive docstrings; mirror the existing src/background/index.ts comment density). Header comment cites D-14-remux (this plan's CONTEXT amendment, disambiguated from the historical "D-14: Not applicable" tab-switch entry per B-02 fix), references the d13 debug session, and explains the parse → adjust-timestamps → re-emit pipeline.
    2. Imports:
       - `import { Decoder } from 'ts-ebml';`
       - `import { Muxer, ArrayBufferTarget } from 'webm-muxer';`
       - `import type { VideoSegment } from '../shared/types';`
       - `import { Logger } from '../shared/logger';`
       Use absolute-style (no `from '..'` ascending more than one level except shared/) — matches existing src/background/index.ts.
    3. Helper functions (small + named — no `continue`, prefer if-else chains per project style):
       - `async function blobToArrayBuffer(b: Blob): Promise<ArrayBuffer>` — uses `b.arrayBuffer()` (web standard; available in Chrome SW).
       - `interface ExtractedFrame { data: Uint8Array; isKey: boolean; timestampMs: number; }`
       - `function extractFramesFromSegment(buffer: ArrayBuffer, segmentBaseMs: number): { frames: ExtractedFrame[]; segmentDurationMs: number; trackInfo: { width: number; height: number; codecPrivate?: Uint8Array; } | null }` — walks `Decoder.decode(buffer)`, tracks current Cluster Timecode, processes each SimpleBlock by parsing its first-byte-after-track-number flags (bit 7 = keyframe). Returns adjusted-by-segmentBaseMs timestamps.
       - SimpleBlock parsing: read VINT track number (Matroska VINT decode is library-trivial; copy the routine from the Matroska spec's "Variable Size Integer" section if ts-ebml does not surface it directly), then 2 bytes int16 BE timestamp delta, then 1 byte flags. Frame data = remaining bytes.
       - `function pickTrackInfoFromSegment(elements: EBMLElementDetail[]): { width, height, codecPrivate? } | null` — walks for Tracks → TrackEntry → Video → PixelWidth + PixelHeight; CodecPrivate if present.
    4. Main flow:
       a) Guard empty input: `if (segments.length === 0) return new Blob([], { type: 'video/webm' });` (satisfies Test 5).
       b) Sort by `timestamp` ascending (matches the SW-side ordering pattern in the deleted mergeVideoSegments).
       c) Parse first segment to derive `trackInfo` (width/height/CodecPrivate) — needed for the muxer's video config.
       d) Build a `Muxer<ArrayBufferTarget>` with:
          ```
          codec: 'V_VP9', width, height, frameRate: 30,
          type: 'webm', firstTimestampBehavior: 'offset',
          ```
          If `CodecPrivate` is present, pass it as `decoderConfig.description` (Uint8Array) via the per-chunk meta. (Most VP9 streams from MediaRecorder do not need CodecPrivate, but pass it through defensively.)
       e) Loop segments with an accumulating `segmentBaseMs`:
          - `extractFramesFromSegment(buffer, segmentBaseMs)` → frames + `segmentDurationMs`
          - For each frame: `muxer.addVideoChunkRaw(frame.data, frame.isKey ? 'key' : 'delta', frame.timestampMs * 1000)` (the muxer wants microseconds).
          - `segmentBaseMs += segmentDurationMs` (the next segment's first frame slots in just after the prior segment's last frame).
       f) `muxer.finalize()`, then `return new Blob([target.buffer], { type: 'video/webm' })`.
    5. Log at INFO: input segment count + sizes; per-segment extracted-frame count + duration; final blob size.
    6. NO `as any`; NO `@ts-ignore`. If a type narrowing is genuinely needed, use a typed predicate (`function isSimpleBlock(el: EBMLElementDetail): el is SimpleBlockElement {...}`).
    7. Run `npx vitest run tests/background/webm-remux.test.ts` — all 5 must flip GREEN.
    8. Run `npx tsc --noEmit` — must be 0.
    9. Run full suite — 11 files / 60 tests / 2 failed | 58 passed (the only remaining failures are the 2 webm-playback duration RED; those drive Task 4 GREEN once the call site swaps).
    DESIGN NOTE per project style: prefer if-else chains over early returns is RELAXED for guard clauses (the empty-input and null-trackInfo guards are clearer as `if (cond) return ...`). Document this exception in the file's header comment.
  </action>
  <verify>
    <automated>npx vitest run tests/background/webm-remux.test.ts &amp;&amp; npx tsc --noEmit</automated>
  </verify>
  <acceptance_criteria>
    - `src/background/webm-remux.ts` exists, exports `remuxSegments`, has extensive JSDoc per project style (block comment header + per-function docstrings).
    - All 5 tests in `tests/background/webm-remux.test.ts` GREEN.
    - `npx tsc --noEmit` exit 0.
    - Full suite: only the 2 webm-playback duration tests RED (they stay RED until the call site swap + fixture regeneration in Tasks 4-5).
    - No `as any`, no `@ts-ignore`, no `console.log` — uses `new Logger('Remux')` for diagnostics.
    - File size 120-300 LOC inclusive of comments (per debug session's estimate; if implementation balloons past 350 LOC, consider extracting EBML walk to a separate `webm-ebml-parser.ts` — but a single-file implementation is acceptable).
  </acceptance_criteria>
  <done>Pure helper module landed; unit invariants pinned GREEN; full-suite delta = 5 new GREEN; tsc clean.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 4: Swap mergeVideoSegments → remuxSegments at call site in src/background/index.ts; await the now-async merge.</name>
  <read_first>
    - src/background/index.ts lines 1-50 (imports + EmptyVideoBufferError class)
    - src/background/index.ts lines 385-505 (mergeVideoSegments definition + createArchive body)
    - src/background/index.ts lines 534-623 (saveArchive — confirms it already awaits createArchive)
    - src/background/webm-remux.ts (the new helper from Task 3)
    - tests/background/request-id-protocol.test.ts (asserts createArchive→saveArchive error-surface contract; the swap MUST preserve EmptyVideoBufferError throw on empty input)
  </read_first>
  <files>src/background/index.ts</files>
  <behavior>
    - `createArchive` calls `await remuxSegments(videoBufferResponse.segments)` instead of synchronous `mergeVideoSegments(...)`.
    - `mergeVideoSegments` function declaration is removed from the file (grep returns 0 hits).
    - `EmptyVideoBufferError` throw paths preserved on (a) zero segments AND (b) zero-byte merged blob.
    - All existing tests in tests/background/* still GREEN (the request-id-protocol contract tests don't care whether the merge is sync or async — they stub saveArchive at a higher level).
  </behavior>
  <action>
    1. RED step: rerun `npx vitest run` against current state — confirm the 2 RED webm-playback tests are still RED + remux unit tests GREEN. (No new failing test to write here; the gate is the existing webm-playback RED + the absence-of-mergeVideoSegments grep.)
    2. Add `import { remuxSegments } from './webm-remux';` near the top of `src/background/index.ts` (alphabetized with existing imports per project style).
    3. Replace the body of `createArchive`'s video-merge block (currently lines 444-456) with:
       ```typescript
       if (videoBufferResponse.segments.length === 0) {
         throw new EmptyVideoBufferError(
           'no video segments available — buffer fetch returned empty (port replacement timed out, or recorder never started)',
         );
       }
       const videoBlob = await remuxSegments(videoBufferResponse.segments);
       if (videoBlob.size === 0) {
         throw new EmptyVideoBufferError(
           `remuxed video blob is zero bytes (segment count=${videoBufferResponse.segments.length})`,
         );
       }
       zip.file('video/last_30sec.webm', videoBlob);
       logger.log(`✓ Added video (remuxed): ${videoBlob.size} bytes`);
       ```
       **W-01 fix (2026-05-16 checker pass): error message string change.**
       The EmptyVideoBufferError detail string changes from 'merged video blob is zero bytes' → 'remuxed video blob is zero bytes'. Before committing, pre-flight grep `src/` and `tests/` for the literal 'merged video blob is zero bytes' to confirm no downstream consumer matches on that string:
       ```
       grep -RIn 'merged video blob is zero bytes' src/ tests/ || true
       ```
       Empirical pre-check (executed 2026-05-16 from current tree): only `src/background/index.ts:452` carries the literal. `tests/background/request-id-protocol.test.ts` asserts on `error.code` ('empty-video-buffer'), NOT on the free-text message — so the rename is safe. Document the empty grep result in the commit message body so the diff reviewer sees the safety check ran.
    4. DELETE the entire `mergeVideoSegments` function declaration (currently lines 385-421 — including the JSDoc header explaining D-13 file-concat). Replace with a single comment line:
       ```typescript
       // mergeVideoSegments (D-13 file-concat) retired in Plan 01-08 (D-14-remux):
       // see src/background/webm-remux.ts for the single-EBML remux path.
       ```
    5. `createArchive` is already declared `async`; the new `await remuxSegments(...)` slots in without signature changes. Verify saveArchive's `const archiveBlob = await createArchive(...)` already awaits — no changes needed there.
    6. Run `npx tsc --noEmit` — exit 0.
    7. Run `npx vitest run`:
       - tests/background/webm-remux.test.ts: GREEN (Task 3)
       - tests/background/request-id-protocol.test.ts: GREEN (no contract change)
       - tests/background/port-lifecycle-continuous.test.ts: GREEN (no contract change)
       - tests/offscreen/webm-playback.test.ts: 2 of 4 still RED (the new duration assertions) — these stay RED until the FIXTURE is regenerated (Task 5).
       - All other tests: GREEN.
    8. Run `grep -nc 'mergeVideoSegments' src/background/index.ts` → must report `0` (or `0` lines with the function declaration; the deletion comment may or may not name it, your call).
    9. **B-01 fix (2026-05-16 checker pass): CONTEXT.md amendment provenance verification (folded in from retired Task 6).** The orchestrator already appended the D-14-remux amendment block to `.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md` at plan creation time (alongside D-15-display-surface, D-16-toolbar, D-17-onboarding for Plans 01-09 and 01-10). This step is idempotent grep verification — no file mutation expected. Run these four greps; ALL must return exactly one match:
       (a) `grep -c 'D-14-remux: WebM remux via ts-ebml + webm-muxer' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md` → must be 1. (B-02 fix: anchor on the disambiguated `D-14-remux` marker, NOT bare `D-14` which would also match the historical `**D-14:** **Not applicable**` line in the original decisions block.)
       (b) `grep -c '^- \*\*D-13:\*\* \*\*Fallback if D-12 fails:\*\*' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md` → must be 1. (Confirms the original D-13 text is intact.)
       (c) `grep -c 'Amendment .Phase 01-stabilize-video-pipeline, 2026-05-16. — D-17-port-lifecycle' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md` → must be 1. (Confirms the prior D-17-port-lifecycle amendment block intact; note the disambiguated name per B-02.)
       (d) Run, verbatim (note: the literal contains backticks, so use single quotes around the -F argument exactly as shown — do NOT wrap this entire command in Markdown inline-code backticks when copy-pasting):

           grep -cF '`src/background/webm-remux.ts` replaces' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md

           Must report exactly `1`. Confirms D-14-remux cites the new module (full path `src/background/webm-remux.ts`) replacing the old `mergeVideoSegments`. Anchor empirically verified to match CONTEXT.md line 412 (full path prefix is required; the un-prefixed form `webm-remux.ts` replaces` does not appear as a contiguous substring in the live file).
       If ANY of (a)–(d) returns zero, STOP and report to the orchestrator: the planner-time amendment was not committed or was clobbered downstream. Do NOT re-append a competing amendment block — debug the root cause first.
  </action>
  <verify>
    <automated>npx tsc --noEmit &amp;&amp; npx vitest run --reporter=dot &amp;&amp; test "$(grep -v '^\s*//' src/background/index.ts | grep -c 'mergeVideoSegments')" = "0" &amp;&amp; grep -q 'D-14-remux: WebM remux via ts-ebml + webm-muxer' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md &amp;&amp; grep -q '^- \*\*D-13:\*\* \*\*Fallback if D-12 fails:\*\*' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md &amp;&amp; grep -q 'D-17-port-lifecycle' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md &amp;&amp; grep -qF '`src/background/webm-remux.ts` replaces' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md</automated>
  </verify>
  <acceptance_criteria>
    - `src/background/index.ts` imports `remuxSegments` and awaits it in `createArchive`.
    - `mergeVideoSegments` is deleted from the file (grep excluding comments returns 0).
    - `EmptyVideoBufferError` throw paths preserved per the request-id-protocol contract tests.
    - `npx tsc --noEmit` exit 0.
    - `npx vitest run` shows the same 2 webm-playback duration RED still failing (because the FIXTURE on disk is the old multi-EBML one — Task 5 regenerates it); every other test GREEN.
    - `npm run build` exit 0 (verifies the new import resolves through crxjs/Vite into the SW bundle without externalizing ts-ebml or webm-muxer — both are pure JS without native deps so the bundle should swallow them cleanly).
    - **B-01 fix: CONTEXT.md provenance chain verified intact via grep** — all four checks in action step 9 pass (D-14-remux present; D-13 original preserved; D-17-port-lifecycle amendment preserved; webm-remux.ts cited in D-14-remux body). No file mutation by this verification step; if any grep fails, the executor STOPS and escalates rather than re-appending.
  </acceptance_criteria>
  <done>Call site swapped to remux; legacy merge gone; saveArchive's empty-buffer error surface preserved; build clean; 2 RED tests now waiting only on the fixture regen; CONTEXT.md amendment provenance verified intact (D-14-remux / D-13 / D-17-port-lifecycle / webm-remux.ts citation all grep-confirmed).</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 5: Operator regenerates tests/fixtures/last_30sec.webm via ./smoke.sh against the post-remux build; confirms ~30 s Chrome + mpv playback.</name>
  <files>(operator-driven; no specific source file modified by this checkpoint)</files>
  <action>See <how-to-verify> below — operator-driven empirical check; the executor agent must not bypass this checkpoint by stubbing.</action>
  <verify>
    <automated>echo "checkpoint:human-verify — see how-to-verify section; resume signal is the gate"</automated>
  </verify>
  <done>Operator types "approved" after running the how-to-verify steps. See <resume-signal> for the exact gate.</done>
  <what-built>
    Tasks 1-4 landed: ts-ebml + webm-muxer installed, `remuxSegments()` implemented in `src/background/webm-remux.ts`, `mergeVideoSegments` deleted from `src/background/index.ts`, and `createArchive` now produces a single-EBML-headered WebM. The 2 RED tests in `tests/offscreen/webm-playback.test.ts` (lines 232-316) still fail because they read the committed fixture `tests/fixtures/last_30sec.webm` which was captured against the OLD D-13 file-concat build. This checkpoint regenerates the fixture against the new build.
  </what-built>
  <how-to-verify>
    1. Build: `npm run build` (must exit 0; produces `dist/`).
    2. Run smoke: `KEEP_PROFILE=0 ./smoke.sh`.
    3. In the launched Chrome window: Load Unpacked → select `dist/` → click the extension toolbar icon → screen-share picker auto-accepts the "Mokosh Smoke Test" tab.
    4. Wait at least 35 seconds (longer is fine — wait 5+ minutes if convenient to also re-validate the post-Option-C port lifecycle still holds across the 290 s mark which is now an irrelevant timeline anyway).
    5. Click the extension icon → "Сохранить отчёт об ошибке". The script will detect the new `session_report_*.zip`, extract `video/last_30sec.webm`, and stage it to `/tmp/mokosh-last_30sec.webm`.
    6. **Empirical playback check (the actual gate)** — open `/tmp/mokosh-last_30sec.webm` in:
       (a) Chrome (drag the file into a fresh tab) — observe playback duration in the controls; MUST be approximately 30 s (>= 25 s), NOT ~9 s.
       (b) mpv (`mpv /tmp/mokosh-last_30sec.webm`) — observe the duration shown in the title bar and the playback proceeds the full ~30 s without stopping early.
    7. Run `ffprobe -v error -show_entries format=duration -of csv=p=0 /tmp/mokosh-last_30sec.webm` and confirm the reported duration in seconds is between 25.0 and 30.0.
    8. If steps 6+7 PASS: copy `/tmp/mokosh-last_30sec.webm` over `tests/fixtures/last_30sec.webm` (`cp /tmp/mokosh-last_30sec.webm tests/fixtures/last_30sec.webm`), then run `npx vitest run tests/offscreen/webm-playback.test.ts` — all 4 tests MUST flip GREEN.
    9. If any of steps 6/7 FAIL: do NOT replace the fixture. Report the failure mode (duration value, mpv error, ffmpeg stderr) so Tasks 3-4 can be revised. The most likely failure modes are (a) webm-muxer's `frameRate` config disagreeing with the actual VP9 frame cadence → may need to derive from cluster timestamps; (b) keyframe-flag parsing off-by-one → first frame of each segment must be 'key', subsequent 'delta'; (c) CodecPrivate omission causing Chrome to fail to initialize the VP9 decoder; (d) base64 decode path failure: confirm via SW console 'Decoded buffer segment N: blob size=...' lines from decodeBufferSegments fired BEFORE remuxSegments runs; if those lines are missing, segments arrived as base64-string blobs and remux will silently garbage out (the SimpleBlock walk reads non-sensical bytes); (e) ts-ebml parse failure on first segment: width/height derive from the first segment's track info; if `pickTrackInfoFromSegment` returns null, remux falls back to 1024x768 OR throws — check SW console for 'Remux: pickTrackInfoFromSegment returned null' diagnostic, which signals the first segment did not contain a Tracks → TrackEntry → Video element tree (segment shape changed from what the d13 probe documented).
  </how-to-verify>
  <resume-signal>
    Type "approved" after step 8 lands (fixture committed and all 4 webm-playback tests GREEN). If step 9 hit, paste the failure diagnostic and the executor will iterate on Task 3.
  </resume-signal>
</task>


</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| offscreen↔SW long-lived port (D-17-port-lifecycle architecture preserved) | base64-encoded VideoSegment bytes cross here; the SW deserializes via base64ToBlob and now hands them to remuxSegments. No new trust boundary introduced by this plan. |
| third-party JS libraries (ts-ebml + webm-muxer) | NEW: untrusted code from npm gains access to VP9 frame bytes. Both are MIT-licensed pure-JS libraries with no native deps. Per the d13 debug session library survey: ts-ebml has a single `typeof window` self-fallback (safe); webm-muxer is zero-DOM-refs. Verified by `tests/background/webm-remux-deps.test.ts`. |
| ffprobe + ffmpeg CLI invocations (existing — test-only) | Tests Task 2 and Task 5 invoke `/usr/bin/ffprobe` and `/usr/bin/ffmpeg`. Trust-boundary unchanged from existing tests/offscreen/webm-playback.test.ts pattern; binaries are system-installed and read-only. |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-08-01 | Tampering | `src/background/webm-remux.ts:remuxSegments` consuming attacker-crafted WebM segments | accept | Defense in depth: the segments originate INSIDE the extension's own offscreen MediaRecorder; an attacker controlling them already controls the extension. Wrapping ts-ebml parse in try/catch and returning `new Blob([])` on parse failure (which surfaces as EmptyVideoBufferError downstream) provides a clean failure mode without any new trust surface. |
| T-1-08-02 | Denial of Service | adversarial VideoSegment[] causing remuxSegments to allocate unbounded memory | accept | Buffer caller (saveArchive) already gates total input at 3 segments × ~800 KB = ~2.4 MB ceiling; ts-ebml + webm-muxer combined operate in pure-JS RAM without spawning sub-processes; SW heap headroom dominates the budget. |
| T-1-08-03 | Information Disclosure | npm supply-chain compromise of ts-ebml or webm-muxer | mitigate | `package-lock.json` pins exact resolved hashes. Both libraries vendored at release versions verified live on npm 2026-05-16 (`ts-ebml@3.0.2`, `webm-muxer@5.1.4`). No `npm audit` warnings at install time per Task 1 acceptance. Phase 5 hardening may add an automated SCA check; out of scope here. |
| T-1-08-04 | Repudiation | the regenerated fixture differing from operator's run-time recording shape | mitigate | Task 5 is the in-the-flesh empirical check by the operator (mpv + Chrome + ffprobe). The fixture commits ONLY after step 8 passes; if step 9 fails, the plan iterates rather than committing a misleading fixture. |
</threat_model>

<verification>
- `tests/offscreen/webm-playback.test.ts` shows 4 of 4 tests GREEN after Task 5 lands.
- `tests/background/webm-remux.test.ts` shows 5 of 5 tests GREEN after Task 3.
- `tests/background/webm-remux-deps.test.ts` shows 2 of 2 tests GREEN after Task 1.
- `npx vitest run` final: 12 files / 60 tests / ALL GREEN.
- `npx tsc --noEmit` exit 0.
- `npm run build` exit 0.
- `grep -c 'mergeVideoSegments' src/background/index.ts` excluding comments returns 0.
- ffprobe on `tests/fixtures/last_30sec.webm` reports container duration >= 25_000 ms.
- Operator empirical check: Chrome + mpv both play the fixture for ~30 s end-to-end.
- CONTEXT.md ends with D-14-remux amendment (disambiguated marker per B-02 fix); original D-13 and D-17-port-lifecycle amendment blocks unmodified.
</verification>

<success_criteria>
SPEC §10 #7 (`last_30sec.webm` plays back in a browser) is functionally satisfied at the codebase level when:
1. The regenerated `tests/fixtures/last_30sec.webm` plays for >= 25 s in Chrome AND mpv (operator-verified Task 5).
2. The 4 webm-playback.test.ts assertions (zero decoder errors, no ended-prematurely, container duration >= 25 s, ffmpeg decode timeline >= 25 s) all GREEN.
3. The remux helper unit tests (5) all GREEN against fixture-derived inputs.
4. No file-concat merge path remains in the SW.
5. The 53 baseline GREEN tests (Plan 01-07 closure suite + Option C tests) remain GREEN — no collateral regression.
6. CONTEXT.md amendment block landed (D-14-remux disambiguated marker; verified by Task 4 step 9 grep checks).
</success_criteria>

<output>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-08-SUMMARY.md` using the standard template. Cite: the 2 RED tests that flipped GREEN, the deleted mergeVideoSegments + new remuxSegments file, the regenerated fixture size + ffprobe duration, the CONTEXT amendment landing, npm dep additions with pinned versions, the total bundle size delta (`npm run build` output before vs after).
</output>
