Plans cover the post-D-13 architecture and the auto-start UX charter
expansion that landed during 2026-05-16 UAT:
- Plan 01-08 — WebM remux via ts-ebml@3.0.2 + webm-muxer@5.1.4. Replaces
the broken file-concat in mergeVideoSegments with a real single-EBML
remux. Drives the 2 RED tests in tests/offscreen/webm-playback.test.ts
to GREEN. Regenerates the canonical fixture against the remuxer.
5 tasks (4 TDD + 1 operator empirical checkpoint), wave 1.
- Plan 01-09 — Whole-desktop constraint (displaySurface:'monitor',
cursor:'always') + post-grant validation, chrome.action.onClicked
direct toolbar invocation, chrome.action badge state machine
(REC/OFF/ERROR), chrome.runtime.onStartup notification + recovery
notification on onUserStoppedSharing, popup scoped to SAVE-only.
17 new test assertions across 4 test files. smoke.sh updated to
auto-select an entire screen. 5 tasks (4 TDD + 1 operator empirical
checkpoint), wave 2 (depends on 01-08).
- Plan 01-10 — chrome.runtime.onInstalled welcome tab on first install
via chrome.storage.local guard; vanilla welcome.html/ts/css bundle
with single "Начать запись" button consuming install-time activation.
Uses centralized Logger pattern. 4 tasks (3 TDD + 1 operator empirical
checkpoint), wave 3 (depends on 01-09).
CONTEXT.md amendment block appended with 4 disambiguated decisions:
- D-14-remux: WebM remux supersedes D-13 file-concat
- D-15-display-surface: whole-desktop + cursor visibility lifted from
Phase 5 deferral
- D-16-toolbar: toolbar onClicked + popup SAVE-only + badge state
machine + onStartup/recovery notifications
- D-17-onboarding: welcome tab on first install (distinct from
D-17-port-lifecycle from Option C)
The earlier D-17 port-lifecycle heading also renamed to hyphenated
form for cross-ref consistency.
Plan-check loop: 3 iterations (initial + 2 revisions). Iteration 1
surfaced 11 findings (2 BLOCKER + 6 WARNING + 3 INFO); all addressed
via revision iter 1 with checker-recommended fixes. Iteration 2
surfaced 3 derivative regressions (literal-string grep anchors from
the iter-1 fixes did not match live CONTEXT.md); all addressed in iter
2 with empirically-validated literals. Iteration 3 PASSED clean.
Validation: gsd-sdk frontmatter.validate + verify.plan-structure both
return valid=True for all 3 plans. Plan 01-08 Task 4 verify-chain
grep tested end-to-end against live CONTEXT.md (exit 0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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).
path
provides
min_lines
exports
src/background/webm-remux.ts
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.
120
remuxSegments
path
provides
min_lines
tests/background/webm-remux.test.ts
Unit-level RED→GREEN tests for the remux helper: single-EBML invariant, monotonic-timestamp invariant, frame-count preservation across input segments, keyframe-flag preservation.
import { Muxer, ArrayBufferTarget } from 'webm-muxer'
from ['"]webm-muxer['"]
from
to
via
pattern
tests/offscreen/webm-playback.test.ts
tests/fixtures/last_30sec.webm
ffprobe + ffmpeg drive the regenerated fixture; the 2 RED assertions (lines 232-316) flip GREEN.
MIN_PLAYABLE_DURATION_MS
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>.
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.
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).
exportinterfaceVideoSegment{data: Blob;// a self-contained ~10 s WebM blob (D-13 segment)
timestamp: number;}exportinterfaceVideoBufferResponse{segments: VideoSegment[];}
From src/background/index.ts (current call site to be replaced, lines 395-421 + 449):
// CURRENT (D-13 file-concat — TO BE DELETED):
functionmergeVideoSegments(segments: VideoSegment[]):Blob{constsortedSegments=[...segments].sort((a,b)=>a.timestamp-b.timestamp);constblobs: Blob[]=sortedSegments.map((segment)=>segment.data);returnnewBlob(blobs,{type:'video/webm'});}// Called at line 449 (inside createArchive):
constvideoBlob=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.
import{Muxer,ArrayBufferTarget}from'webm-muxer';consttarget=newArrayBufferTarget();constmuxer=newMuxer({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.
import{Decoder,tools}from'ts-ebml';constdecoder=newDecoder();constelements=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.
Task 1: Install ts-ebml + webm-muxer; verify SW-compat by import-only test (Wave 0 contract).
- 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)
package.json, package-lock.json, tests/background/webm-remux-deps.test.ts
- 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).
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).
npx vitest run tests/background/webm-remux-deps.test.ts && npx tsc --noEmit
- `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).
Both libraries installed with pinned versions, the import-surface test pinning both library names is committed and GREEN, tsc clean, baseline suite preserved.
Task 2: Add unit-level RED tests for remuxSegments() invariants (single-EBML, monotonic ts, frame-count, keyframe-flag).
- 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).
tests/background/webm-remux.test.ts
- 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` 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.)
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.
npx vitest run tests/background/webm-remux.test.ts 2>&1 | grep -E "Tests.*[0-9]+ failed"
- `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.
5 RED tests committed pinning the remux invariants at the unit level; baseline preserved.
Task 3: Implement remuxSegments() in src/background/webm-remux.ts — drives all 5 RED tests in webm-remux.test.ts to GREEN.
- 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).
src/background/webm-remux.ts
Implements `export async function remuxSegments(segments: VideoSegment[]): Promise` satisfying all 5 tests from Task 2. See behavior list there.
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` — 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` 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.
npx vitest run tests/background/webm-remux.test.ts && npx tsc --noEmit
- `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).
Pure helper module landed; unit invariants pinned GREEN; full-suite delta = 5 new GREEN; tsc clean.
Task 4: Swap mergeVideoSegments → remuxSegments at call site in src/background/index.ts; await the now-async merge.
- 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)
src/background/index.ts
- `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).
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.
npx tsc --noEmit && npx vitest run --reporter=dot && test "$(grep -v '^\s*//' src/background/index.ts | grep -c 'mergeVideoSegments')" = "0" && grep -q 'D-14-remux: WebM remux via ts-ebml + webm-muxer' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md && grep -q '^- \*\*D-13:\*\* \*\*Fallback if D-12 fails:\*\*' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md && grep -q 'D-17-port-lifecycle' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md && grep -qF '`src/background/webm-remux.ts` replaces' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
- `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.
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).
Task 5: Operator regenerates tests/fixtures/last_30sec.webm via ./smoke.sh against the post-remux build; confirms ~30 s Chrome + mpv playback.
(operator-driven; no specific source file modified by this checkpoint)
See below — operator-driven empirical check; the executor agent must not bypass this checkpoint by stubbing.
echo "checkpoint:human-verify — see how-to-verify section; resume signal is the gate"
Operator types "approved" after running the how-to-verify steps. See for the exact gate.
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.
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).
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.
<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.
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.
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>
- `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.
<success_criteria>
SPEC §10 #7 (last_30sec.webm plays back in a browser) is functionally satisfied at the codebase level when:
The regenerated tests/fixtures/last_30sec.webm plays for >= 25 s in Chrome AND mpv (operator-verified Task 5).
The 4 webm-playback.test.ts assertions (zero decoder errors, no ended-prematurely, container duration >= 25 s, ffmpeg decode timeline >= 25 s) all GREEN.
The remux helper unit tests (5) all GREEN against fixture-derived inputs.
No file-concat merge path remains in the SW.
The 53 baseline GREEN tests (Plan 01-07 closure suite + Option C tests) remain GREEN — no collateral regression.
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).