Milestone v1 (v2.0.0): Mokosh — Session Capture #1
Reference in New Issue
Block a user
Delete Branch "gsd/phase-04-harden-clean-up-optional"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Milestone v1 (
v2.0.0in the GSD tracker) of Mokosh — Session Capture, aChrome MV3 extension that records the operator's browser session continuously
(30-second WebM ring buffer + 10-minute rrweb DOM snapshots + user-event log)
and packages everything into a single zip with one click for support bug
reports.
Status: Verified ✓ across all 4 phases — independent
gsd-verifiergoal-backward audits passed on every phase boundary.
Volume: 31 plans landed across 4 phases; 297 commits ahead of
main;8 operator-empirical acks; 3
/gsd-debugsessions during Phase 4 alone.Branch ships everything —
mainwas last touched at the start of Phase 1planning (
acb9033); this is the first push of working v1 to the remote.Built end-to-end across 4 phases
collapsed the offscreen duality, fixed the MediaRecorder shadow bug, replaced
the broken
chrome.tabCaptureflow with offscreen-documentgetDisplayMedia,shipped the WebM ring buffer with restart-segments + ts-ebml/webm-muxer
remux for playable archives, landed the Approach B UAT harness (15 → 24
GREEN assertions across this phase), shipped welcome onboarding (Loom-mark
fonts (MV3 CSP discipline), and integrated the brand design system
(
src/shared/tokens.csscanonical with the D-04 Loom palette).replaced the base64
data:URL download path with Blob URLs (closes auditP0-6), expanded
meta.jsonfrom 7 → 8 fields withschemaVersionandmeta.urlsfrom a tab-URL tracker, and added harness A24-A28 forend-to-end export verification.
2026-05-20) — verified all 9 SPEC §10 acceptance criteria (8 automated +
1 best-effort scaffolding with operator/alpha fallback for §10 #9 RAM
ceiling), absorbed REQ-rrweb-dom-buffer + REQ-user-event-log into A29-A32,
introduced the canonical cs-injection-world pattern (
chrome.tabs.create+chrome.scripting.executeScript({world: 'ISOLATED'})) for in-pageverification.
closed 2026-05-26) — the final v1 hardening pass. See the per-phase
breakdown below for the full detail.
Changes — Phase Breakdown
Phase 1: Stabilize video pipeline (14 plans)
Audit P0 #1-#5 + #7 + the design integration that preceded shipping.
Key plans: 01-01 (doc cascade) · 01-03 (offscreen recorder TDD) · 01-05
(SW shrink: deleted alarms + IndexedDB + tabCapture paths) · 01-06 (build
pipeline collapse —
vite.config.ts226 → 21 lines) · 01-07 (smoke + ffprobefallbacks) · 01-08 (WebM remux via ts-ebml + webm-muxer) · 01-09 (toolbar
onClicked direct flow + 3-state badge state machine) · 01-10 (welcome tab +
first-install onboarding) · 01-12 (design integration — fonts + tokens + 16
i18n × 2 locales + branded icons) · 01-13 (UAT harness Approach B; 15/15
GREEN) · 01-14 (monitorTypeSurfaces picker).
Verification:
01-VERIFICATION.md—status: passed; 17/17 must-haves(11 REQs/charters + 6 cross-cutting gates).
Phase 2: Stabilize export pipeline (4 plans)
Audit P0-6 closure + meta.json schema + harness export coverage.
Key plans: 02-01 (Wave 0 RED tests) · 02-02 (Blob URL pipeline via
base64-on-wire; SW
downloadArchiverewrite) · 02-03 (meta.urls + tab-urltracker; SessionMetadata 7 → 8 fields) · 02-04 (harness A24-A28 + operator
empirical).
Verification:
02-VERIFICATION.md—status: passed; 5/5 must-haves;T5 override applied for operator empirical (harness coverage substitutes per
saved-memory
feedback-trust-harness-over-manual-uat).Phase 3: SPEC §10 smoke + DOM/event-log verification (5 plans)
All 9 SPEC §10 acceptance criteria + REQ-rrweb-dom-buffer + REQ-user-event-log
formal validation.
Key plans: 03-01 (A29 rrweb DOM verification) · 03-02 (A30 event-log
verification + introduces cs-injection-world pattern) · 03-03 (A31 password
filter PARTIAL per D-P3-02 charter) · 03-04 (A32 RAM ceiling best-effort with
Puppeteer page.metrics scaffolding) · 03-05 (VERIFICATION aggregator).
Verification:
03-VERIFICATION.md—status: passed; 5/5 ROADMAP SCs +9/9 SPEC §10 acceptance criteria.
Phase 4: Harden + clean up (8 plans + 3 /gsd-debug sessions)
Final v1 hardening + the most ceremony-heavy phase of the milestone:
Plans:
(Request narrow at
src/content/index.ts:194+214, navigation URL trackingfix, rrweb timestamp normalization)
setimmediatepolyfill replacement via4-mechanism layered mitigation (runtime guard + nodePolyfills exclude +
resolve.alias stub + custom Rollup
generateBundlepost-transform pluginbecause JSZip's pre-bundled CJS distribution embeds setimmediate beyond
normal exclusion reach); generate-icons.js → .cjs rename (ESM/CJS
mismatch fix); dead-code grep regression pin. Gate 2 (
new Functionin SW chunk) flipped from 1 documented exception → 0 hits in dist/.
flake via own probe tab + strict-sentinel filter on
IncrementalSource.Mutationtest-methodology layer; routed through two
/gsd-debugsessions whichempirically REFUTED the architecture-broken interpretation:
d614462)4ea1bbb); root cause = canvascaptureStream(30)invisible-source throttling (Chrome bug 653548), NOTthe offscreen-RAM
let segments: Blob[] = []architecture (whichsurvives SW kill structurally: POST-KILL probe count = 3). Saved ~2-4h
of misdirected IndexedDB persistence work.
9ac5808(race-tolerant offscreen targetattach in
tests/uat/lib/launch.ts).network_errorempirical — closes ROADMAP SC #2;validates Plan 04-01 P1 #11 end-to-end (fetch
targetcarries real URL,not
[object Request]); both fetch and XMLHttpRequest paths covered viacs-injection-world.
dark-surface logo contrast strategy via
currentColorSVG + inline injectionthrough 3 planner passes + 2 plan-checker passes + 1 /gsd-debug fix
cycle before operator empirical ack:
to ink-900 indigo on madder (lower contrast than light)
--mks-mark-strokebrand-component tokendecoupling (commit
a8bcc17) — theme-independent token resolves tolinen-50 in both themes; live-DOM probe confirms light + dark
computedStroke = rgb(250, 247, 241)(identical)04-VERIFICATION.md(253 lines;comprehensive evidence trail) + ROADMAP backfill + REQUIREMENTS marker
flips + PROJECT.md evolution.
Replaced canvas
captureStreamsource withHTMLVideoElement.captureStreamfed by a bundled WebM fixture (via Vite
?rawsemantic with explicitweb_accessible_resourcesentry). Spike re-run with valid methodologyproduced
videoSize = 1,797,178 bytes(1.8 MB) vs the broken-methodologybaseline of 8505 bytes — 211× larger. SC #1 closes empirically.
3 /gsd-debug sessions during Phase 4:
archived at
.planning/debug/)7e0da63; A33.1 now gates onrace-free fresh-archive-on-disk signal instead of best-effort
sendMessageack)
a8bcc17; archived at.planning/debug/resolved/)Verification:
04-VERIFICATION.md—status: passed; 4/4 ROADMAP SCs +3/3 audit P1 items + 6/6 hardening items + 36/36 UAT + 188/188 vitest +
6/6 bundle gates + 12/12 Tier-1 FORBIDDEN_HOOK_STRINGS + Tier-2 leak gate
present; 0 overrides applied (independent audit at commit
8ffc6cb).Requirements Addressed
10/11 REQs Validated. REQ-password-confidentiality explicitly Out of Scope v1
per user decision throughout the project ("we don't care about privacy
hardening. At least here.") — carried as a v2 candidate.
Verification
01-VERIFICATION.md— 17/17 must-haves02-VERIFICATION.md— 5/5 must-haves03-VERIFICATION.md— 5/5 ROADMAP SCs + 9/9 SPEC §1004-VERIFICATION.md— 4/4 ROADMAP SCs + 11/11 truths (auditcommit
8ffc6cb)HEADLESS=1 SKIP_LONG_UAT=1 npm run test:uat); A33 has 5 sub-checks including A35.5 light+darkdecouple-proof
parallel-vitest flake family is tolerated by the behavior-based gate —
both
webm-remux.test.tsandstrict-meta-json-validation.test.tspass inisolation, but can race in the parallel suite; documented in
04-CONTEXT.md)(
npm run buildclean; SW CSP-safetynew Function= 0 +eval= 0 afterPlan 04-02; Node-globals + DOM-globals checks; manifest validation;
en↔ru locale parity; Tier-2
synthetic-display-sourceleak gate added inPlan 04-08)
Phase 4 (every harness assertion added — A33/A34/A35 — rides production
surfaces; no
__MOKOSH_UAT__-gated symbols introduced)Amendment 2; Wave 7 brand-fit substitutes)
04-06")
Key Decisions
against the shipped codebase
chrome.tabCapturereplaced withoffscreen
getDisplayMedia(whole-desktop picker) per the audit P0cascade
MediaRecorder every 10 s for self-contained WebM segments (3 × 10 s
rolling window); resolved A3 cluster-alignment failure mid-execution
has
URL.createObjectURL; SW does not)meta.urlsfrom tab-url tracker viachrome.tabs.onActivated/onUpdatedschemaVersionfield for forward-compat(
maskInputFnreturns***for password fields; user decisionreaffirmed: privacy hardening Out of Scope v1)
(full scope; all 3 audit P1 items; both visual polish items; alpha
out-of-band; ROADMAP backfill in scope)
feedback-no-unilateral-scope-reductionfeedback-gsd-ceremony-for-fixesfeedback-pre-checkpoint-bundle-gatesfeedback-trust-harness-over-manual-uatKnown Issues / Deferred
v1.1 candidates (logged in
04-VERIFICATION.mdDeferred Items):leftover from Plans 03-02/03-03; Plan 04-03 fixed A29 specifically; A30/A31
still intermittently fail in full-suite runs — clean 35/35 runs achievable;
documented at
tests/uat/extension-page-harness.ts:3345)(both
webm-remux.test.tsandstrict-meta-json-validation.test.tsGREENin isolation 5/5 and 8/8 respectively; can race in parallel suite — a
vitest-config harness investigation candidate)
[ ]checkboxesnot flipped to
[x]despite their closures — pre-existing doc-hygiene gap;Phase 1 and Phase 4 rows ARE correctly flipped)
v2 candidates:
verified stable for this milestone)
chrome.devtools.Memory(currentlybest-effort scaffolding + operator/alpha path per D-P3-04)
tests/uat/spike-a33-sw-persistence.tsispreserved as forensic evidence + future regression-test scaffold
Test Plan
For the reviewer (smoke verification of the shipped state):
npm install && npm run build— clean exit;dist/populatedHEADLESS=1 SKIP_LONG_UAT=1 npm run test:uat— 36/36 GREEN in ~95 snpm test— 188/188 GREEN (or 187/1 with one flake; re-run thefailing test in isolation; if green → tolerated 04-CONTEXT #9/#10 family)
npx tsc --noEmit— exits 0dist/as unpacked extension in Chrome:updates)
всегда под рукой." renders in Lora; English subtitle "Thirty seconds
ago, always at hand."; mark inside the madder circle renders crisp
linen-50 in both light and dark modes)
mokosh-*.zip)last_30sec.webm(plays in VLC/Chrome;ffprobeexit0),
rrweb/session.json,logs/events.json,meta.json,screenshot.png🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com
Four RED tests pin D-10 (header pinning) and D-11 (30s trim) contracts: - 'first chunk is header' — isFirst marker on first addChunk - 'second chunk is NOT header' — only the first is pinned - 'trim 30s — keeps header, evicts aged tail' — header survives indefinitely - 'trim with empty buffer does not throw' — defensive edge case Plan 03 must export {addChunk, trimAged, getBuffer, resetBuffer} from src/offscreen/recorder.ts to flip these to GREEN. Also stages tests/fixtures/.gitkeep so the fixture dir survives clean checkouts (Plan 07 drops a known-good last_30sec.webm into it after the manual smoke test). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Three RED tests pin Pattern 4 (handshake) and Pattern 5 / Pitfall 4 (port reconnect on disconnect) contracts: handshake.test.ts: - 'sends OFFSCREEN_READY after listener registration' — exactly one OFFSCREEN_READY emitted at module load, AFTER onMessage.addListener port.test.ts: - 'connects on module load' — chrome.runtime.connect called once - 'reconnects when port disconnects' — firing onDisconnect triggers immediate re-connect (Pitfall 4 idle-timer reset) chrome.runtime is stubbed locally (no vitest-chrome dependency added). No 'as any' / no '@ts-ignore'; casts are 'as unknown as T'. Plan 04 must wire OFFSCREEN_READY send + port.connect({ name: 'video-keepalive' }) + onDisconnect-driven reconnect at the import-side effect layer of src/offscreen/recorder.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>- Add PORT_PING_MS (25s) and PORT_RECONNECT_MS (290s) constants - Replace stub bootstrap with full long-lived port lifecycle: - connectPort() registers onMessage/onDisconnect listeners, schedules 25s PING postMessages and a 290s pre-emptive reconnect (Pitfall 4 belt-and-braces against Chrome's ~5min port lifetime cap) - onDisconnect handler synchronously calls connectPort() again (Plan 02 port.test.ts pins this; flips reconnect test to GREEN) - REQUEST_BUFFER over the port responds with { type: 'BUFFER', chunks: getBuffer() } (Plan 05 SW-side will issue REQUEST_BUFFER on export) - Keep defensive guard on chrome.runtime sub-APIs so pure ring-buffer and codec-check tests can import the module without a full chrome stub - Remove the no-longer-needed 'void keepalivePort' workaround (the variable is now used by onPortMessage + connectPort) - T-1-04 mitigation: explicit message-shape switch in onPortMessage (any unknown port message type silently dropped); comment block documents the SW-side sender.id check contract for Plan 05 GREEN: all 4 test files in tests/offscreen/ pass (9 tests total — ring-buffer 4 + codec-check 2 + handshake 1 + port 2). npx tsc --noEmit exits 0. Zero 'as any' / '@ts-ignore' in recorder.ts.Plan 05 Task 1 — finish the SW shrink: - DELETE videoBuffer: VideoChunk[] module state (buffer lives in offscreen per D-16) - DELETE setupKeepalive + chrome.alarms registration (D-18; alarms never reset SW idle timer — port does) - DELETE chrome.tabCapture.getMediaStreamId call (D-01: getDisplayMedia now runs inside offscreen) - DELETE chrome.permissions.contains/request for tabCapture (broken — desktopCapture is the new manifest entry, but getDisplayMedia needs no runtime perm) - DELETE comment-only references to removed symbols (so grep gates pass) - REPLACE 'USER_MEDIA' as any → chrome.offscreen.Reason.DISPLAY_MEDIA (D-02; @types/chrome 0.0.268 exposes it) - REPLACE justification copy to match RESEARCH.md Example C - FIX (error as any) → instanceof Error pattern (CLAUDE.md rule) - FIX chrome.tabs.sendMessage cast: explicit response type instead of 'as any' - COLLAPSE REQUEST_PERMISSIONS handler: under getDisplayMedia, no runtime perm check is meaningful — just call startVideoCapture() (Rule 1 deviation; old code returned granted=false because tabCapture is no longer in manifest) - Temporary stub: getVideoBuffer() returns { chunks: [] } — Task 2 deletes this and wires the port-based getVideoBufferFromOffscreen() Verified: npx tsc --noEmit clean, npx vitest run 9/9 green, no as any / @ts-ignore.Plan 05 Task 2 — make the SW a pure coordinator that talks to the offscreen via the long-lived 'video-keepalive' port (D-17, RESEARCH.md Pattern 5). Additions: - chrome.runtime.onConnect.addListener handler scoped to port name 'video-keepalive' + T-1-04 mitigation (port.sender?.id check). Stores port in module-level videoPort: chrome.runtime.Port | null. - getVideoBufferFromOffscreen(): port-based REQUEST_BUFFER round-trip with a 2s timeout fallback to { chunks: [] }. Replaces the synchronous SW-local getVideoBuffer() stub from Task 1. - offscreenReady Promise + OFFSCREEN_READY onMessage case (RESEARCH.md Pattern 4): startVideoCapture awaits this before sending START_RECORDING, closing the 'Receiving end does not exist' race window (audit P1 #12). - onMessage GET_VIDEO_BUFFER + SAVE_ARCHIVE rewritten to fetch the buffer via the port instead of the deleted local array. - onMessage sender.id !== chrome.runtime.id guard at handler top (T-1-NEW-05-01 mitigation). - chrome.runtime.onInstalled now calls indexedDB.deleteDatabase('VideoRecorderDB') once to clean up the orphaned database from pre-Phase-01 builds (T-1-NEW-05-02 / RESEARCH.md Runtime State Inventory). Rule 2 deviation (orchestrator-flagged robustness): - initialize() now calls chrome.offscreen.hasDocument() to detect existing offscreen documents across SW respawns and update offscreenCreated accordingly (audit P1 #8). Guarded with a typeof check to stay safe under partial chrome stubs. Verified: npx tsc --noEmit clean; npx vitest run 9/9 green (Plan 04 offscreen-side tests stay un-touched); no as any / @ts-ignore.- Add blobToBase64 / base64ToBlob in src/shared/binary.ts: portable Blob↔base64 round-trip for the chrome.runtime.Port wire-format. JSON.stringify(blob) returns "{}" across extension contexts, so binary payloads must travel as base64 strings. - Mirror the GREEN-block helper signatures from tests/offscreen/port-serialization.test.ts so the same test pins both the standalone helpers and the production wire format. - Land tests/offscreen/port-serialization.test.ts as the RED+GREEN executable contract for the D-12 fix: the RED block reproduces the 75-byte "[object Object]" failure mode byte-for-byte; the GREEN block pins the base64 wire-format the fix must implement. - Uses arrayBuffer() + btoa(String.fromCharCode...) rather than FileReader: FileReader is browser-only; the chosen approach works in both Chrome extension contexts and the Node-based vitest environment. Refs: debug session d12-blob-port-transfer-fails.- Add TransferredVideoChunk { data: string (base64); type: string (MIME); timestamp: number; isFirst?: boolean } as the JSON-serializable shape for chunks traversing the offscreen↔SW chrome.runtime.Port boundary. - Retarget PortMessage.chunks?: TransferredVideoChunk[] (was VideoChunk[]). The in-memory VideoChunk type is unchanged — both the offscreen ring buffer and the SW-side merge step keep using it after decoding the wire payload. - No behavior change yet; this commit only lands the type. The encode/decode call sites land in the next two commits. Refs: debug session d12-blob-port-transfer-fails.- Convert each VideoChunk's Blob to a TransferredVideoChunk via blobToBase64 before keepalivePort.postMessage. JSON.stringify(blob) === "{}" across extension contexts, so the previous direct send of VideoChunk[] was silently corrupting binary content on the wire. - Move the work into encodeAndSendBuffer() to keep onPortMessage synchronous-typed (chrome.runtime.Port.onMessage ignores return values; the listener stays void-returning, the async work is fire-and-forget). - Defensive per-chunk encode: log + skip individual encoding failures rather than crashing the whole BUFFER response. Operators get partial video > no video. - Re-check keepalivePort !== null AFTER the await: the port may have disconnected during encoding (~100 ms for 15 chunks of ~100 KB each per the d12 sizing estimate). Refs: debug session d12-blob-port-transfer-fails, D-17 port lifecycle.Per debug session webm-playback-freeze "Activation Plan" step 4: the D-09..D-11 ring-buffer semantics (first-chunk header pin + 30 s age trim) are being replaced by D-13 restart-segments. The pinned-header assertions were architecture-specific and become meaningless once each segment is a self-contained WebM with its own EBML header and seed keyframe. Changes: - tests/offscreen/ring-buffer.test.ts: collapsed to a single breadcrumb test pointing at the successor file. Kept the path so git history / failure bisects land on the retirement commit cleanly. - tests/offscreen/segment-rotation.test.ts (new): 8 tests pinning the D-13 invariants against the production recorder module: * MAX_SEGMENTS = 3, SEGMENT_DURATION_MS = 10_000 (= legacy 30 s window) * empty-by-default, ordered, oldest-evicted-at-cap * resetBuffer clears * getSegments returns a defensive snapshot (no internal aliasing) Uses a `pushSegmentForTest` seam so vitest can drive rotation deterministically without instantiating a real MediaRecorder. RED today by design (TDD discipline) — the segment-rotation suite imports `getSegments`, `pushSegmentForTest`, `MAX_SEGMENTS`, `SEGMENT_DURATION_MS` from src/offscreen/recorder.ts. Those exports land in the next commit. tsconfig.include is "src" only so tsc stays clean during the RED window.Replaces the single-continuous-MediaRecorder + age-trim lifecycle (D-09..D-11, retired) with the restart-segments lifecycle pre-staged in CONTEXT.md D-13 and RESEARCH.md Pattern 3. Debug session webm-playback-freeze proved empirically that the prior approach orphans VP9 P-frame keyframe references when middle chunks are trimmed — ffmpeg dry-run on last_30sec.webm produced 8 "Error submitting packet to decoder" lines and the playback froze ~1 s in Chrome (root-cause-confirmed in the debug session). Architecture change: - MediaRecorder is rotated every SEGMENT_DURATION_MS = 10_000 ms on the SAME mediaStream (so no new getDisplayMedia user gesture). - Each segment is started fresh, so the encoder seeds an EBML header + initial VP9 keyframe — segment is independently decodable. - Up to MAX_SEGMENTS = 3 finalized segments are retained (3 × 10 s = 30 s, matching the legacy operator-facing window). - MediaRecorder.start() runs without a timeslice — exactly one ondataavailable per .stop() yields exactly one Blob per segment. - onstop → onSegmentStopped finalizes currentChunks, evicts oldest if over the cap, and immediately starts the next segment. - ~50–200 ms recording gap at each rotation boundary is the documented trade-off per CONTEXT.md D-13. API surface delta: - REMOVED: addChunk, trimAged, getBuffer, firstChunkSaved (entire D-09..D-11 ring-buffer module retired) - ADDED: getSegments, pushSegmentForTest (test seam), MAX_SEGMENTS, SEGMENT_DURATION_MS exports - KEPT: assertCodecSupported, resetBuffer (semantics updated to clear segments + currentChunks + rotation timer), VIDEO_BUFFER_DURATION_MS (now derived as MAX_SEGMENTS * SEGMENT_DURATION_MS = 30 s) - bootstrap(), port lifecycle, OFFSCREEN_READY handshake, T-1-04 sender-id check — all unchanged. encodeAndSendBuffer iterates segments + the in-flight currentChunks (if non-empty) so SAVE_ARCHIVE seconds after START_RECORDING still returns the partial first segment instead of an empty buffer. Each segment is base64-encoded per the D-12 wire-format contract. Verification: - npx vitest run → 28 passed / 2 failed; the 2 failures are exactly the empirical ffmpeg dry-runs in tests/offscreen/webm-playback.test.ts which stay RED until the operator regenerates the committed fixture via ./smoke.sh (expected and documented). - tests/offscreen/segment-keyframes.test.ts production-driven RED block is now GREEN — getSegments exists and meets the cap contract. - tests/offscreen/segment-rotation.test.ts (8 tests, added in the prior commit) all GREEN. - npx tsc --noEmit clean. - Zero new `as any` / `@ts-ignore` introduced.Pure rename pass — zero behavioural change at any call site. The prior "chunk" naming is a vestige of D-09..D-11's chunk-level buffer; under D-13 the unit of transfer is a self-contained ~10 s WebM segment, so the type name now matches the data shape. Renames propagated atomically: - src/shared/types.ts * TransferredVideoChunk → TransferredVideoSegment (also: the `isFirst?: boolean` field is dropped — D-13 segments are all implicitly their own header, so the flag is meaningless and only existed for the retired ring-buffer's pin semantics) * VideoChunk → VideoSegment (drops `isFirst?` for the same reason) * PortMessage.chunks? → PortMessage.segments? * VideoBufferResponse.chunks → VideoBufferResponse.segments - src/offscreen/recorder.ts * import type rename * encodeAndSendBuffer's per-element accumulator + filter type * the port.postMessage payload field - src/background/index.ts * import type rename * getVideoBufferFromOffscreen reads `(msg as {segments?}).segments` (matching the new wire field name) * empty-buffer returns `{ segments: [] }` * mergeVideoSegments signature takes VideoSegment[] * createArchive consumes videoBufferResponse.segments * saveArchive log line says "segments" Verification: - npx tsc --noEmit clean. - npx vitest run → 28 passed / 2 failed (the 2 fixture-empirical ffmpeg dry-runs; unchanged — they're gated on ./smoke.sh regen). - npm run build succeeds, all 60 modules transformed. - grep predicates clean in src/: * no addChunk / trimAged / firstChunkSaved / isFirst * no TransferredVideoChunk / VideoChunk (old names) * 21 occurrences of new names propagated correctly - Pre-existing port-serialization.test.ts still GREEN: its `isFirst` references are inside inline-test-scoped objects (not imports from production types), and its tests assert JSON-roundtrip behaviour rather than the production type shape.Captures the RED contracts that the webm-playback-freeze debug session landed (before this fix-a3 cycle started) plus the original Plan 07 smoke fixture they run against. None of these files were modified by this fix cycle — they are landed as-is from the debug session to make the test history bisectable. Files staged: - tests/offscreen/segment-keyframes.test.ts Three describe blocks (~340 LOC): * documentation — pure-simulation tests that pin the D-09..D-11 failure mode as executable evidence (regression guard against re-introducing single-continuous-recorder semantics) * GREEN-pinning — pure-simulation tests that pin the D-13 segment-keyframe invariant * production-driven — imports src/offscreen/recorder.ts and asserts (i) `getSegments` exported as a function, (ii) it returns at most 3 Blobs. THIS BLOCK IS NOW GREEN after the D-13 activation in the prior commits — was the genuine TDD anchor for fix-a3. - tests/offscreen/webm-playback.test.ts Two empirical-ffmpeg assertions on tests/fixtures/last_30sec.webm: * zero "Error submitting packet to decoder" lines from the VP9 decoder * no "File ended prematurely" container-finalization error Both STAY RED in this commit because the committed fixture is still the stale one from Plan 07's pre-fix smoke. They flip GREEN after the operator runs ./smoke.sh to regenerate the fixture against the D-13 recorder — see the closing message and the NEXT-STEP block of the resolved debug session. - tests/fixtures/last_30sec.webm The 2.1 MB Plan 07 smoke artifact. Committed deliberately so the empirical RED test has something to run against. Will be overwritten by the next ./smoke.sh run (single-file rotation — the path is fixed by the smoke script + zip extraction step in the debug-session reproduction). Verification: - npx vitest run --reporter=dot → Tests 2 failed | 28 passed (30) - The 2 fails are EXACTLY the two empirical-ffmpeg assertions in webm-playback.test.ts; the structural production-driven block in segment-keyframes.test.ts is fully GREEN. - npx tsc --noEmit clean. - npm run build succeeds. Operator action required before Phase 1 close (Plan 07 still owns REQ-video-ring-buffer): re-run ./smoke.sh per the documented 6-step reproduction in the debug session, then re-run `npx vitest run tests/offscreen/webm-playback.test.ts` and expect both assertions to flip GREEN. Plan 07 success criterion §10 #7 (playback) lands at that point.What was wrong: - CR-01 (recorder.ts): encodeAndSendBuffer captured no port identity before awaiting Promise.all(blobToBase64). If the port disconnected mid-encode and onDisconnect synchronously reconnected (re-assigning keepalivePort to a fresh instance), the post-await null-check evaluated false and the BUFFER was posted on the NEW port — but the SW's per-request onMessage listener was still bound to the OLD port (captured at getVideoBufferFromOffscreen line 110). Result: SW timed out after 2 s, SAVE_ARCHIVE produced an empty-segments zip, data-loss path masquerading as a benign timeout. - CR-02 (background/index.ts): SW's onConnect handler attached ONLY onDisconnect — no permanent onMessage sink. PING traffic had no listener when getVideoBufferFromOffscreen wasn't running (the normal idle state of the port), and field reports note Chrome's SW idle-timer reset behaves inconsistently when no listener is attached. Risk: PINGs silently dropped, SW evicted ~30 s into recording, port torn down, next SAVE_ARCHIVE fails entirely. - CR-03 (background/index.ts): offscreenReady is a one-shot Promise resolved on the FIRST OFFSCREEN_READY message. If the SW is evicted while the offscreen document persists, the next SW lifetime creates a fresh Promise and waits on it forever — the offscreen never re-emits OFFSCREEN_READY. startVideoCapture() hangs at `await offscreenReady` until Chrome restarts. - WR-03 (recorder.ts): `baseTimestamp + idx` (Date.now() + idx) used millisecond resolution + array offset. Two REQUEST_BUFFER calls within the same millisecond would collide, breaking the sort-by- timestamp contract in SW-side mergeVideoSegments. - WR-09 (recorder.ts): encodeAndSendBuffer always appended the unfinalized in-flight segment to the BUFFER. That segment lacks the Matroska SegmentSize and Cues that MediaRecorder.stop() writes — re-introducing the "File ended prematurely" symptom documented in debug session webm-playback-freeze. What changed: - recorder.ts encodeAndSendBuffer: - Capture `portAtRequest = keepalivePort` BEFORE the encode. - After the await, refuse to post if `keepalivePort !== portAtRequest` (port was replaced by reconnect). SW already times out cleanly after BUFFER_FETCH_TIMEOUT_MS = 2 s; the next SAVE_ARCHIVE re-issues REQUEST_BUFFER on the fresh port. Stale data NEVER reaches a stranger port. - Include the in-flight segment ONLY when finalized.length === 0 (preserve the SAVE-within-first-10-s UX trade-off documented at the original comment) — otherwise drop the unfinalized tail. - Replace `baseTimestamp + idx` with module-level monotonic `++segmentSeq` counter (zero wall-clock dependency). - Switch from Promise.all/map+filter to a sequential for-loop because each iteration now mutates the shared `segmentSeq`; Promise.all timing would interleave assignments. Throughput impact negligible (3 segments × ~50 ms base64 each ≈ 150 ms vs ~50 ms parallel — still well under the 2 s SW budget). - background/index.ts onConnect: - Install a permanent `port.onMessage.addListener` that explicitly drains PING and silently drops unknown traffic. Per-request BUFFER listener still wins because it's attached LATER in the listener chain when getVideoBufferFromOffscreen fires; this sink only catches the idle PING stream and guarantees the SW idle-timer reset is consumed by a real handler. - background/index.ts initialize(): - When `chrome.offscreen.hasDocument()` returns true on SW init, immediately resolve `offscreenReady` AND null `offscreenReadyResolve`. The offscreen MUST have completed its bootstrap before it was observable via hasDocument(); waiting for an OFFSCREEN_READY that will never come is a deadlock. Why these fixes vs alternatives: - CR-01: alternatives considered: (a) cancel encoding when port disconnects (requires AbortController plumbing into blobToBase64); (b) re-route the BUFFER through the new port via a per-port request-id correlation. Both add machinery for a case the SW already handles correctly (2 s timeout → retry). The capture- identity check is the minimum-mechanism fix and matches REVIEW.md CR-01 fix guidance exactly. - CR-02: alternative considered: documenting "rely on kernel-level port-message side effect for idle-timer reset" — REJECTED, this is what the existing comment did and the field evidence shows it's unreliable. Explicit listener is the safe default. - CR-03: alternative considered: (a) have offscreen re-emit OFFSCREEN_READY on every inbound SW message — adds noise to the message bus and races with the original-bootstrap-emit. Option (b) (resolve-on-hasDocument-true) is simpler, narrower, and was explicitly recommended by REVIEW.md. - WR-03: alternative considered: keeping Date.now() and adding a microsecond-resolution offset via performance.now() — fragile across SW respawns where performance.now() resets. Module-level monotonic counter has zero wall-clock dependency. - WR-09: alternative considered: forcing a synchronous rotation at REQUEST_BUFFER time. Rejected — adds ~50–200 ms latency to every save AND races with scheduleRotation()'s timer. The "exclude unless empty" trade-off matches REVIEW.md option (a) exactly and preserves the documented first-10-s UX path. Validation evidence: - npx tsc --noEmit: exit 0 (no type errors). - npx vitest run --reporter=dot: 30/30 tests pass in 2.67 s (8 test files, including port.test.ts which pins the reconnect invariant and port-serialization.test.ts which pins the wire format). - grep "as any\|@ts-ignore" src/offscreen/ src/background/index.ts src/shared/: no matches (type-safety gate stays clean).Per .planning/debug/empty-archive-port-race.md "Fix Strategy: Option C (Architectural)", land RED tests that pin the 4 sub-behaviours the refactor must satisfy at the unit level. These complement the operator- facing contract already pinned by port-reconnect-race.test.ts (H1+H2). Offscreen side (tests/offscreen/port-health-probe.test.ts): A. Bootstrap installs no 290_000 ms pre-emptive reconnect timer (the timing-based race window fromb064a21is gone). B. Missed PONGs (5 PINGs without echo) trigger a clean reconnect via the same path the onDisconnect handler uses. C. PONG echoes within timeout keep the port alive indefinitely (counter-test for over-eager probe — already GREEN today). D. REQUEST_BUFFER with requestId → BUFFER response echoes the same id (the architectural mechanism that retires cross-talk). SW side (tests/background/request-id-protocol.test.ts): 1. getVideoBufferFromOffscreen sends REQUEST_BUFFER with a generated uuid requestId on the live videoPort. 2. Stale BUFFER (mismatched requestId) is ignored — no resolution. 3. Port replacement mid-request → SW re-issues REQUEST_BUFFER on the new port with the SAME requestId. Retires the H2 silent-drop class. 4. Empty video segments → saveArchive returns {success:false, error} (operator-visible) instead of {success:true} with no-video archive. 5. SW echoes PONG on PING, closing the health-probe loop. Suite status: 10 files / 52 tests (42 GREEN, 10 RED). - 40 baseline + 2 new GREEN (port-health-probe C; request-id 2 & 4 accidentally pass due to test-stub side effects — they will continue to pass after fix for the right reasons). - 3 RED in port-reconnect-race + 3 RED in port-health-probe + 4 RED in request-id-protocol. Quality gates: tsc --noEmit exit 0; type-safety grep clean. No production code touched in this commit — fix lands in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Implements the offscreen-side architectural refactor per .planning/debug/empty-archive-port-race.md "Fix Strategy: Option C": 1. **Retired** the 290_000 ms pre-emptive reconnect setTimeout. Its race window between the synchronous .disconnect() and the onDisconnect handler running was the bisect-confirmed proximate cause of the H1 "Attempting to use a disconnected port object" Uncaught Errors. 2. **Added** PONG-based health probe: each ping increments missedPongs; if MAX_MISSED_PONGS (3) consecutive PINGs go without echo, reconnect via the same clean teardown path the onDisconnect handler uses. PONG receipt resets the counter. Liveness-based replacement for the time-based pre-emptive rotation. 3. **H1 fix** — wrap PING postMessage in try/catch. The port object can transition to disconnected synchronously (SW eviction, port glitch) between the interval-callback being queued and it running. The catch absorbs the throw and routes through reconnectPort() — no more uncaught throws bubble out to the offscreen console. 4. **Request-id'd protocol** — REQUEST_BUFFER carries the SW-generated requestId; BUFFER response echoes it. The offscreen now posts on the CURRENT keepalivePort (no more portAtRequest stale-port refuse-to- post). The SW matches BUFFER → request by id, so port replacement mid-encode no longer drops the response — the SW retries on the new port and the matching BUFFER routes correctly. 5. **reconnectPort(reason)** — new helper consolidating the teardown+disconnect+reconnect dance used by both the missed-PONG path and the synchronous-throw path. Idempotent w.r.t. the chained onDisconnect callback. Test updates: - H2 now sends REQUEST_BUFFER with a requestId (Option C contract). - H1.b refactored to test the externally-disconnected path (since the pre-emptive timeout path is gone): port._disconnected=true, fire ping, assert no throw + a fresh port appears. - Top-level snapshots of timer globals + afterEach restoration so a failing test doesn't leak overridden globals into the next test. Status: 48 GREEN, 4 RED (the remaining RED is all SW-side — addressed in next commit). All H1 + H1.b + H2 contracts now GREEN. Pinning contracts (D-12 port-serialization, D-13 segment-rotation, A3 webm- playback) untouched. tsc --noEmit exit 0; type-safety grep clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Implements the SW-side architectural refactor per .planning/debug/empty-archive-port-race.md "Fix Strategy: Option C": 1. **Request-id'd protocol** — getVideoBufferFromOffscreen generates a uuid (crypto.randomUUID with Math.random fallback) and sends {type:'REQUEST_BUFFER', requestId} on the live videoPort. The per-request listener pattern is GONE; replaced by a module-level pendingBufferRequests Map<requestId, PendingBufferRequest>. The onConnect-level message sink routes BUFFER -> resolve by id. 2. **Stale BUFFER routing** — BUFFER messages without a matching requestId in the Map are silently dropped (no cross-talk). BUFFER without a valid requestId at all is rejected with a warn (Option C protocol requires the id). 3. **Retry on port replacement** — every onConnect (post-bootstrap) scans pendingBufferRequests and re-issues REQUEST_BUFFER on the fresh port with the SAME requestId. The offscreen posts BUFFER on the current keepalivePort (see prior offscreen commit), the sink matches by id, and the request resolves. This retires the H2 silent-drop class architecturally — the BUFFER reaches the SW regardless of port-replacement timing. 4. **PING -> PONG echo** — the sink replies to every PING with PONG. Closes the offscreen's health-probe loop (it counts missed PONGs and reconnects when MAX_MISSED_PONGS exceeded — see prior offscreen commit). The PONG post is wrapped in try/catch to absorb the same port-closed-mid-response race the offscreen ping path handles. 5. **Outer hard-timeout bumped 2s -> 10s** — the legacy per-port BUFFER_FETCH_TIMEOUT_MS = 2000 was too tight to retry across a reconnect. The new outer budget covers EVERY retry across port replacements; the inner round-trip is still ~100-200 ms. 6. **decodeBufferSegments extracted** — pulled out of the legacy inline handler so the new onConnect sink can decode wire segments without duplicating the logic. Preserves WR-07 (empty wire segment filter) and base64ToBlob defensive catch behaviour. Closes the pre-existing implicit-undefined-return path the legacy flatMap catch had (tsc happy but semantically ambiguous). Status: 51 GREEN, 1 RED. The remaining RED (createArchive must throw on empty video, surfacing to operator) is addressed in the next commit. Pinning contracts (D-12 port-serialization, D-13 segment-rotation, A3 webm-playback) untouched. tsc --noEmit exit 0; type-safety grep clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Implements Option C step 3 per .planning/debug/empty-archive-port-race.md: "Continuous end-to-end vitest covering 600 s of port lifecycle (2 reconnects + simulated REQUEST_BUFFER round-trips). Becomes the new pinning contract for the port lifecycle." The UAT Test 3 BLOCKER surfaced because no test exercised the full operator timeline — 5+ minute recording with port-replacement windows crossing real SAVE_ARCHIVE round-trips. This file pins that contract end-to-end at the unit-test level. What's exercised: - Both SW (src/background/index.ts) and offscreen recorder (src/offscreen/recorder.ts) loaded into the SAME chrome stub, with paired port-pair factory (one connect() yields offPort + swPort that talk to each other through captured listeners). - 12 ping/pong cycles (~300 s simulated wall-clock). - 3 SAVE_ARCHIVE round-trips (one before reconnect, two after each of the two forced reconnects). - 2 EXTERNAL port disconnects (port._disconnected=true) — simulates the SW eviction / port glitch path that the H1.b test pins. - JSZip mocked at file scope (vi.mock) because Node 22+ JSZip can't read native Blobs — preserves integration shape (size accounting) without depending on JSZip's Node compatibility. Final assertions: 1. All 3 saveArchive calls return success:true. 2. EVERY BUFFER message that crossed the wire carried segments (no silent-loss path was reachable). 3. PONGs round-tripped (proves health-probe loop closes). Suite: 53 GREEN / 53 tests. tsc --noEmit exit 0; type-safety grep clean; npm run build exit 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Two doc updates closing the debug session per the resolved pattern this phase has established (cf. resolved/d12-blob-port-transfer-fails.md and resolved/webm-playback-freeze.md): 1. **Move debug session to resolved/** with the Resolution section filled in (root_cause, fix, verification, files_changed). Status flipped tdd_red_confirmed -> resolved. Original investigation notes + bisect results + Option C strategy spec all preserved in-place — the file is the full provenance trail. 2. **Amend 01-CONTEXT.md D-17** with the new port lifecycle commitments. Append-only (D-17 itself untouched) per the doc cascade rule established earlier this phase ("amendments append, do not replace, to preserve SPEC provenance"). The amendment narrates: - What was Claude's-discretion at Phase 1 plan time has been specified by Option C. - The 290 s pre-emptive setTimeout reconnect (Pitfall 4) is RETIRED. - The architectural commitments added: PING/PONG health probe, request-id'd REQUEST_BUFFER/BUFFER, SW retry on port replacement, outer 10 s hard-timeout, operator-visible EmptyVideoBufferError surface. - The 4 pinning contracts added (port-health-probe, request-id-protocol, port-lifecycle-continuous, plus the refactored port-reconnect-race). Suite remains 11 files / 53 tests, all GREEN. Quality gates intact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>- Add ts-ebml ^3.0.2 (parse half) and webm-muxer ^5.1.4 (write half) per CONTEXT.md amendment D-14-remux; both MIT, both verified SW-compatible in the d13 debug-session library survey. - tests/background/webm-remux-deps.test.ts pins two contracts: (a) named exports surface (Muxer + ArrayBufferTarget + Decoder). (b) both libraries import cleanly when window/document are absent on globalThis — guards the published dist against accidentally acquiring DOM globals on the hot path that would crash the Chrome service-worker runtime. - Note: webm-muxer 5.1.4 upstream-deprecated in favor of Mediabunny; the pinned version still meets the d13 architectural requirement (single-EBML output via addVideoChunkRaw). Migration to Mediabunny is out of scope for Plan 01-08 and would require a new ADR. - Baseline 53 GREEN + 2 new GREEN; tsc clean; 2 webm-playback duration RED still pending (drive to GREEN in Tasks 3-5).Drives all 5 RED tests in tests/background/webm-remux.test.ts to GREEN. - Parses each VideoSegment via ts-ebml Decoder; tracks current Cluster Timestamp; extracts each SimpleBlock's VP9 frame(s) + keyframe flag + segment-local timestamp via tools.ebmlBlock. - Re-emits all frames through a single webm-muxer Muxer<ArrayBufferTarget> configured with type:'webm', codec:'V_VP9', and adjusted monotonic timestamps (segmentBaseMs + cluster.Timestamp + block.timecode, microseconds for the muxer). - Picks track info (PixelWidth, PixelHeight, optional CodecPrivate) from first segment that exposes them; falls back to 1024x768 with a logged warning per Task 5's failure-mode (e). - Defensive: empty input -> empty Blob (Test 5); sort by timestamp ascending (mirrors retired mergeVideoSegments order discipline). - 434 LOC including extensive JSDoc per project style; 8 small named helpers, no nested mega-functions. - Empirically: 3-segment fixture -> 912 frames in 29.954 s, 1_643_057 bytes (single-EBML); ffprobe duration=29.94s, count_frames=912. - Logging via new Logger('Remux'); no console.* anywhere; no as any; no @ts-ignore. Full suite: 13 files / 60 GREEN + 2 RED (webm-playback duration assertions still failing against the stale fixture — Task 4 swaps the call site, Task 5 regenerates the fixture). tsc exit 0.- src/background/index.ts now imports remuxSegments from './webm-remux' and awaits it in createArchive instead of synchronously calling the retired file-concat mergeVideoSegments. - mergeVideoSegments function declaration deleted entirely; only a retirement comment remains naming Plan 01-08 D-14-remux as the superseding decision. - EmptyVideoBufferError throw paths preserved on (a) zero segments AND (b) zero-byte output. Error message free-text changed from "merged video blob is zero bytes" to "remuxed video blob is zero bytes"; pre-flight grep (W-01 fix from plan checker pass) confirmed no downstream consumer matches on the legacy string — request-id-protocol.test.ts asserts on error.code ('empty-video- buffer'), not the free-text message. - createArchive remains async (was already declared async); saveArchive already awaits createArchive so no upstream signature changes. - Stale comment in decodeBufferSegments referencing mergeVideoSegments updated to reflect the new remux pipeline (Rule 3: keep forward- references accurate). - CONTEXT.md amendment provenance verified intact via 4 grep checks (B-01 fix from plan checker, folded from retired Task 6): (a) D-14-remux disambiguated marker present (1 match) (b) original D-13 line preserved (1 match) (c) D-17-port-lifecycle amendment intact (1 match) (d) webm-remux.ts replaces citation present (1 match) No CONTEXT.md mutation by this task — verify-only step. - npm run build exit 0; main SW bundle 374.56 KB (108.44 KB gzipped, matches the d13 library survey's ~100 KB estimate for ts-ebml + webm-muxer combined). - Full suite: 13 files / 60 GREEN + 2 RED (webm-playback duration assertions waiting on Task 5 fixture regen). tsc exit 0.Adds tests/background/sw-bundle-import.test.ts that loads the built SW chunk under SW-simulated globals (Buffer/process/window/document stripped) via a spawned Node child process. Pins the orchestrator-side gap that caused Plan 01-08's SW init crash: the prior deps test only checked SOURCE packages under default Node globals, never the bundled output, so Vite/Rollup's CJS-interop bug (tree-shaking the `ebml` package while leaving a dangling `{tools:f}=Pc` destructure against an empty Pc) went undetected until operator empirical smoke. RED against HEADaabbd0c— failure surfaces the exact production error ("Cannot read properties of undefined (reading 'readVint')"), proving the test is a true regression gate, not a tautology. Also rewrites .planning/debug/01-08-sw-incompatibility.md to reflect the actual root cause (Vite/Rollup CJS interop) rather than the orchestrator's initial falsified hypothesis (new Function + Buffer globals — disproven by Node simulation showing the throw fires at module-init line 12:33809 before any CSP-eval or Buffer-ref code path executes). Full vitest: 60 passing + 3 RED (this gate + the 2 pre-existing Task 5 fixture-dependent duration tests). No regressions. Per feedback-pre-checkpoint-bundle-gates.md (auto-loaded memory): any future plan executor whose work surfaces a SW must run this test before any operator-empirical checkpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Vite's @rollup/plugin-commonjs failed to bridge ts-ebml's `require("ebml")` against ebml's mixed-main/module/browser package. Rollup tree-shook ebml.esm.js entirely, leaving `var Pc={}` as a dangling placeholder. ts-ebml/tools.js's destructure `{tools:f}=Pc` threw TypeError at SW top-level module init, blocking handler registration -> chrome://serviceworker-internals Status=STARTING forever. `resolve.alias: { ebml: 'ebml/lib/ebml.js' }` forces resolution to the CJS main entry whose assignment-style exports survive plugin-commonjs's namespace allocation. Empirically verified: bundle init progresses ~340 KB further; readVint error gone. Probes C1 (resolve.mainFields), C2 (treeshake.moduleSideEffects), C3 (C1+C2 combined), C4 (commonjsOptions.strictRequires) were all falsified before C-config landed. Resolves: .planning/debug/01-08-sw-incompatibility.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 01-09 Task 1 RED — pins 4 tests for D-15-display-surface contract: 1. getDisplayMedia called with strict {video:{displaySurface:'monitor', cursor:'always'},audio:false} (deep-equality, NOT objectContaining). 2. Non-monitor pick (browser/window) tears down stream + emits RECORDING_ERROR wrong-display-surface. 3. Monitor pick does NOT trip wrong-display-surface (over-fire guard). 4. classifyCaptureError routes 'wrong-display-surface' message prefix to 'wrong-display-surface' code. Task 2 will flip Tests 1, 2, 4 to GREEN by adding constraints + post-grant validation + extending CaptureErrorCode union. Deviation Rule 3: navigator getter-only in Vitest's node env required Object.defineProperty wrapper (installNavigatorStub helper) instead of direct assignment.Plan 01-09 Task 2 GREEN — flips Task 1 tests 1, 2, 4 to GREEN: 1. CaptureErrorCode union extended with 'wrong-display-surface'. 2. classifyCaptureError branch matches 'wrong-display-surface' prefix. 3. getDisplayMedia call carries {video:{displaySurface:'monitor', cursor:'always'},audio:false} (Plan 01-09 D-15-display-surface + Phase 5 cursor:'always' opportunistic lift). 4. Post-grant validation block reads track.getSettings().displaySurface; on non-monitor pick: tears down stream, nulls mediaStream, throws wrong-display-surface Error which routes through the existing classifyCaptureError + RECORDING_ERROR broadcast path. Type note: lib.dom.d.ts MediaTrackConstraints omits 'cursor' — used explicit type-widening cast (NOT 'as any') to add the field without suppressing other type checking. Tests: 4/4 GREEN; full suite 15 files / 68 tests / GREEN. tsc --noEmit exit 0. npm run build exit 0.Plan 01-09 Task 3 RED — 13 tests across 3 new files: tests/background/toolbar-action.test.ts (5 tests): A: chrome.action.onClicked.addListener registered at SW init B: onClicked while not recording triggers startVideoCapture C: onClicked while isRecording does NOT double-start D: setPopup('') in OFF mode, popup html path in REC mode E: popup init does NOT send REQUEST_PERMISSIONS + saveButton enabled (W-02 fix — without jsdom, uses node-env document stub) tests/background/badge-state-machine.test.ts (4 tests): A: REC state = text 'REC' + #00C853 green + Recording title B: OFF state = text '' + #D32F2F red + Not recording title (fired at SW init via initialize → setIdleMode) C: ERROR state = text 'ERR' + #F9A825 yellow + error title D: RECORDING_ERROR onMessage triggers setBadgeText('ERR') within microtask tests/background/onstartup-notification.test.ts (4 tests): A: chrome.runtime.onStartup.addListener registered at SW load B: onStartup fires exactly one mokosh-startup- notification with basic type + 'Mokosh ready' title + Click-instructed message C: notifications.onClicked with mokosh- id clears + triggers START_RECORDING D: RECORDING_ERROR onMessage triggers mokosh-recovery- notification Task 4 will flip all 13 to GREEN by adding the listeners + state machine + helpers in src/background/index.ts, popup SAVE-only, manifest update. Deviation Rule 3: jsdom not in node_modules; refactored Test E to use a node-env document stub instead of @vitest-environment jsdom pragma.Plan 01-09 Task 4 GREEN — flips all 13 Task 3 RED tests to GREEN: src/background/index.ts: • Badge palette + notification id prefix constants (SCREAMING_SNAKE). • setBadgeState(state) helper: 3-state machine REC/OFF/ERROR with deterministic setBadgeText + setBadgeBackgroundColor + setTitle. Each chrome call wrapped in try/catch (defense in depth). • setIdleMode / setRecordingMode / setErrorMode helpers — drive the setPopup dance: '' in OFF (so onClicked fires), html path in REC/ ERROR (so popup opens for SAVE). • startVideoCapture wires setRecordingMode on success, setErrorMode in catch. • chrome.action.onClicked.addListener — direct toolbar-to-picker flow (no popup needed for start). isRecording guard prevents double-start. • chrome.runtime.onStartup.addListener — fires once per browser session; creates mokosh-startup- notification inviting recording. • chrome.notifications.onClicked.addListener — T-1-09-01 spoofing mitigation via 'mokosh-' prefix gate; clears notification + invokes startVideoCapture (notification click is a valid activation gesture). • RECORDING_ERROR onMessage branch — setErrorMode + creates a mokosh-recovery- notification inviting the operator to restart. • initialize() calls setIdleMode at SW boot — ensures fresh OFF state on every (re-)spawn including Chrome's idle-eviction respawn. • All new listener registrations wrapped in try/catch so unit-test chrome stubs that don't define action/notifications/onStartup don't crash SW load (preserves the 5 pre-existing request-id-protocol + 1 port-lifecycle-continuous tests as GREEN). src/popup/index.ts: • Removed checkPermissions + requestPermissions functions entirely (no more REQUEST_PERMISSIONS round-trip on popup open). • popupState defaults isRecording=true, hasPermissions=true under SAVE-only charter — the popup ONLY opens when recording is active (REC/ERROR setPopup html path), so SAVE button is always enabled. • init() calls updateUI() directly (no async permission probe). • Empty-state copy updated: 'Откройте запись через иконку расширения' (Open recording via the extension icon — points operator back to the toolbar for starting a new session). • saveArchive() simplified: no permission re-check. manifest.json: • Added 'notifications' to permissions array (preserves all existing). • default_popup retained — popup still opens in REC/ERROR modes. smoke.sh (W-04 5-sub-step update): • SHARE_TARGET='Entire screen' (was 'Mokosh Smoke Test'). • Added 14-line locale-fallback comment block citing Chromium generated_resources.grd as authoritative source + 4 known locale strings + KEEP_PROFILE=1 fallback path. • <title> changed to 'Mokosh Smoke Test — monitor mode' to keep tab title distinct from the screen-source string. • <ol> instruction updated: picker auto-accepts entire screen, not the tab. Body intro paragraph also updated. • T+/wall timer overlay (commit923aaca) preserved — no behavioral change to polling/Downloads-snapshot/ffprobe-gate logic. Tests: 13/13 new GREEN; full suite 18 files / 81 tests / all GREEN. tsc --noEmit exit 0. npm run build exit 0; dist/manifest.json has 'notifications' permission. Tier-1 SW-bundle-import gate (Layer 1 + 2) remains GREEN.Plan 01-09 SUMMARY: • 17 new tests landed GREEN (4 displaySurface + 5 toolbar-action including W-02 popup-idle-race + 4 badge + 4 notification). • Baseline 64 + 17 new = 81 GREEN. Full suite 18 files / 81 tests. • Tier-1 SW-bundle-import gate (Layer 1 + 2) remains GREEN. • tsc clean; npm run build clean; dist/manifest.json carries notifications permission. • 4 deviation rules auto-fixed inline (navigator getter helper, jsdom-free W-02 Test E refactor, cursor type-widening cast, chrome.* listener try/catch for pre-existing test compatibility). • Task 5 (operator empirical checkpoint) deferred per plan.Two new cross-phase intel docs surfacing the visual + interaction language and the concrete asset deliverables list. Triggered by Plan 01-09's notification-icon failure: the original placeholder PNGs (icons/icon{16,48,128}.png at 79/123/306 bytes) are too small for chrome.notifications.create's imageUtil validation. Plan 01-09 closeout blocks on valid replacement icons. design-system.md captures: - Brand voice (quietly competent operator-tool; not playful) - Identity (Mokosh codename + "AI Call Recorder" display name) - Color tokens (semantic state + UI surfaces; WCAG AA validated) - Typography (system fonts only; type scale) - Iconography (solid filled, 24px grid, neutral mark + state via badge) - Spacing (4px base scale) - Motion (conservative; reduced-motion-aware) - Component conventions (toolbar action, notification, popup, welcome) - Accessibility & i18n (Russian-first, contrast floors) - Open design decisions (mark concept, pulsing badge, tagline) assets-spec.md captures: - Priority 0 (blocks Plan 01-09): icons 16/48/128 with min file sizes + dimension/format requirements + validation commands - Priority 1 (Plan 01-10): welcome page hero, optional 192px icon - Priority 2 (future phases): per-state icon variants, popup polish, runbook visuals - Three implementation paths: auto-generated placeholders (interim), design-first (commission), hire/commission User will delegate execution to designer team; specs are handoff-ready with binding technical floor + designer-judgment aesthetic direction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>icons/icon{16,48,128}.png at 574/1153/2615 bytes — all above Chrome imageUtil silent-rejection floors per assets-spec.md A-01/A-02/A-03. Auto-generated via ImageMagick (Path A pathway). Branded variants swap in cleanly when design team delivers (Path B/C). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Adds Tests E + F to tests/background/badge-state-machine.test.ts pinning the conditional-routing contract for RECORDING_ERROR onMessage: E (RED today): RECORDING_ERROR{error:'user-stopped-sharing'} must route through setIdleMode — badge OFF (text '', red #D32F2F), popup ''. The current handler routes ALL codes through setErrorMode, locking the operator out of restart (popup wins toolbar.onClicked forever). F (GREEN today, preserved after fix): RECORDING_ERROR with any other error code (representative: 'codec-unsupported') continues to route through setErrorMode — badge ERR + yellow #F9A825 + popup html. This is the defensive-fallback regression pin guarding against the patch over-rotating to IDLE for all codes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Patches the RECORDING_ERROR onMessage handler in src/background/index.ts (lines 725-744 pre-patch) with conditional routing on the incoming `message.error` payload: - 'user-stopped-sharing' → setIdleMode() (popup empties; badge OFF; isRecording flipped to false). Recovery notification suppressed — the operator stopped deliberately, surfacing one would be UX noise. The offscreen recorder's onUserStoppedSharing has already cleared the buffer (src/offscreen/recorder.ts:457 resetBuffer), so IDLE is the correct landing state. - all other codes → setErrorMode() + recovery notification, preserving the existing operator-facing surface for genuine capture failures (codec-unsupported, wrong-display-surface, capture-failed, etc.). Closes the operator-lockout regression observed in Plan 01-09 Task 5 empirical UAT: after Chrome's "Stop sharing" banner click, the badge stayed yellow and the popup pinned to SAVE-only, gating chrome.action.onClicked behind the popup forever. Operator had no restart path. With IDLE routing, the popup empties and the toolbar click fires startVideoCapture as designed. Tests: 83/83 GREEN (was 81; +2 from Tests E+F). tsc clean. Build exit 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Probes 1-11 against local Chrome 148.0.7778.167 + Puppeteer 25.0.2: - triggerExtensionAction works; popup-vs-onClicked contract confirmed - --headless=new supports MV3 + getDisplayMedia (Xvfb not required) - offscreen page reachable via background_page target type + .asPage() - BLOCKER: track.stop() does NOT fire 'ended' per W3C spec — Bug B harness must use track.dispatchEvent(new Event('ended')) instead 13-assertion implementation table + 7 pitfalls + 2-bundle build design. Wave 0 grep gate enforces tree-shake of __mokoshTest from production.Retires operator-as-assertion-library role from Plan 01-09 Task 5. Bug A (notification icon API rejection) and Bug B (state-machine routing of user-stopped-sharing) both escaped vitest unit coverage and cost ~4-6h of operator UAT cycles in Phase 1. Plan 01-11 ships a Puppeteer-driven Node harness with CDP attach to SW + offscreen contexts; the 14 assertions cover the Plan 01-08/01-09 functional contract end-to-end. Locked from research (RESEARCH §1-§11): - Puppeteer 25.0.2 + tsx + node:assert/strict (no vitest browser mode) - Two-bundle separation via vite.test.config.ts (mode 'test' + dist-test/) - Hook gating: import.meta.env.MODE === 'test' + dynamic import (Vite tree-shakes from production) - Bug B trigger: track.dispatchEvent(new Event('ended')) — NOT track.stop() (W3C spec + empirical probe7 — track.stop does NOT fire 'ended') - Tier-1 grep gate (tests/background/no-test-hooks-in-prod-bundle.test.ts) enforces zero __mokoshTest in production dist/ - Single browser, serial assertions, bail-on-first-failure (open question 4) Wave structure (4 waves): - Wave 0 (Task 1): puppeteer+tsx install, vite.test.config, build:test + test:uat scripts, Tier-1 grep gate committed GREEN. - Wave 1 (Task 2): gated SW + offscreen hooks at src/test-hooks/; production bundle remains hook-free. - Wave 2 (Task 3): harness scaffolding — tests/uat/lib/* + harness.test.ts with assertion 0 wired GREEN + assertions 1-13 stubbed RED. - Wave 3 (Tasks 4-7): wire 13 assertions in 4 logically-grouped bundles (1-4 toolbar/displaySurface; 5-7 SAVE+BugB+ERROR; 8-10 BugA+icons+manifest; 11-13 buffer continuity + ffprobe + zip). - Wave 4 (Tasks 8-9): amend Plan 01-09 Task 5 to redirect functional steps to npm run test:uat; operator confirms brand/design. Bug A + Bug B each have RED-on-regression canonical demos required in the respective task's commit body — proves the harness CAN catch the regression, not just passes under current conditions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Task 2 of Plan 01-11 (Puppeteer UAT harness). Test hook surface: - src/test-hooks/types.ts: canonical MokoshTestSurface — handlers (onClicked, onStartup, notificationOnClicked), notificationCount, lastNotificationOptions<true>, notificationIds, getCurrentStream, getSegmentCount. globalThis.__mokoshTest ambient declaration. - src/test-hooks/sw-hooks.ts: SW-side hook. Monkey-patches addListener on chrome.action.onClicked / chrome.runtime.onStartup / chrome .notifications.onClicked to capture handler refs while chaining to the original. Wraps chrome.notifications.create across all four overload shapes (id+options+cb, options+cb, id+options→Promise, options→Promise) to increment notificationCount, save lastNotificationOptions, push resolved id into notificationIds. - src/test-hooks/offscreen-hooks.ts: offscreen-side hook. Exports setCurrentStream + setSegmentCountGetter; the recorder calls both inside startRecording after the mediaStream + segments assignments. getCurrentStream getter closes over the cell so the harness reads the live MediaStream for displaySurface inspection + 'ended' dispatch (Bug B BLOCKER per RESEARCH §7). - tests/uat/lib/test-hook-contract.d.ts: manual harness-side mirror of MokoshTestSurface (decoupled from src/ to keep tests/ import-clean per RESEARCH §11 resolution 5; drift risk documented inline). Production-side wires (gated by __MOKOSH_UAT__ token): - src/background/index.ts top-of-module: `if (__MOKOSH_UAT__) { await import('../test-hooks/sw-hooks'); }`. MUST run before any chrome.* addListener call below — top-of-module placement satisfies this. - src/offscreen/recorder.ts top-of-module: symmetric gated dynamic import + module-scoped testHooks reference. - src/offscreen/recorder.ts inside startRecording (after mediaStream assignment): `if (__MOKOSH_UAT__) { testHooks?.setCurrentStream(stream); testHooks?.setSegmentCountGetter(() => segments.length); }` - src/offscreen/recorder.ts inside onUserStoppedSharing (after mediaStream = null): `if (__MOKOSH_UAT__) { testHooks?.setCurrentStream(null); }` — T-1-11-05 (Repudiation: stale stream ref) mitigation. Build-time token wiring: - vite.config.ts: declares `define: { __MOKOSH_UAT__: 'false' }` (prod default) + bumps `build.target: 'es2022'` so the top-level await in the gated dynamic imports compiles (MDN: Chrome 89 / Edge 89 / Firefox 89 / Safari 15 support TLA; MV3 floor Chrome 88 is effectively Chrome 89+ in field — comfortably inside the envelope). - vite.test.config.ts: overrides `define: { __MOKOSH_UAT__: 'true' }` so the test bundle has the hooks active. - vitest.config.ts: declares `define: { __MOKOSH_UAT__: 'false' }` for vitest's own source-loading runs. CRITICAL — without this, vitest would throw `ReferenceError: __MOKOSH_UAT__ is not defined` when loading src/background/index.ts; OR if we'd used `import.meta.env.MODE === 'test'` (RESEARCH §6's initial guidance), vitest's default MODE='test' would have ACTIVATED the hooks under unit tests + clobbered every existing vi.fn() chrome.notifications.create mock. The dedicated `__MOKOSH_UAT__` token sidesteps both failure modes cleanly — a refinement on RESEARCH §6 documented in the comment preambles of all three configs. - globals.d.ts: declares `__MOKOSH_UAT__: boolean` ambient so `npx tsc --noEmit` passes without per-file annotations. - tsconfig.json: include adds `globals.d.ts`. Notification options generic refinement: - chrome.notifications.NotificationOptions is declared with a `<true | false>` generic distinguishing "create" (all required — true) from "update" (all optional — false). Plan 01-11's production code always uses the create shape; types.ts + sw-hooks.ts pin to `NotificationOptions<true>` so the harness reads iconUrl etc. as definitely-present. Verification: - npx tsc --noEmit: exit 0 - npm run build: exit 0 - grep -rln '__mokoshTest\|simulateUserStop\|getSegmentCount\|setCurrentStream\|setSegmentCountGetter' dist/: ZERO matches (Tier-1 gate stays GREEN) - npm run build:test: exit 0; dist-test/ emits separate sw-hooks-*.js + offscreen-hooks-*.js chunks (the gated dynamic imports survive tree-shaking when __MOKOSH_UAT__ === true) - grep -rln '__mokoshTest' dist-test/: 2 matches (assets/sw-hooks-*.js + assets/offscreen-hooks-*.js) - SKIP_BUILD=1 npx vitest run: 89/89 GREEN (83 baseline + 6 Tier-1 hook-leak surfaces) - sw-bundle-import.test.ts: GREEN (the gated dynamic import does not break production module init — the `if (false)` branch is never reachable so the await + import are dead code in dist/) In-flight bugs auto-fixed (Rule 1 + Rule 3): - Rule 3: original RESEARCH §6 plan called for `import.meta.env.MODE === 'test'` as the gate; switched to `__MOKOSH_UAT__` define-token after observing vitest contamination (vitest defaults MODE='test' → hooks activated under unit tests → 8 existing tests broke with "Cannot read properties of undefined (reading 'calls')" because the hook wrapper replaced vi.fn() mocks). Documented in the comment preambles of all three configs as a refinement on RESEARCH §6. - Rule 3: esbuild rejected TLA against the default ES2020 target; bumped to es2022 (Chrome 89+ supports TLA per MDN — inside MV3 envelope). Recorded in vite.config.ts preamble. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Task 3 of Plan 01-11 (Puppeteer UAT harness). Harness file tree (tests/uat/): - harness.test.ts: tsx-runnable top-to-bottom harness entry point. Runs A0 inline (filesystem grep gate, abort-on-fail T-1-11-01), then launches Chrome + opens popup bridge + queries manifest, then iterates A1-A13 stubs. Each stub throws "NOT YET IMPLEMENTED — Plan 01-11 Task N wires this assertion". Exit code = 0 on full pass, 1 otherwise. Final line: "UAT harness: N/14 assertions passed". - lib/launch.ts: launchHarnessBrowser() — wraps puppeteer.launch with enableExtensions:[dist-test/], headless default (HEADLESS=0 override), --no-sandbox + --auto-select-desktop-capture-source flags. Polls browser.extensions() until the extension registers (empirically ~100ms but the first call right after launch returns Map(0)). Opens both a blank page (for triggerExtensionAction) AND the popup page (the bridge surface). Returns { browser, extension, extensionId, sw, downloadsDir, page, popup }. - lib/extension.ts: waitForOffscreenTarget + attachToOffscreen + countOffscreenTargets. Offscreen attach uses target.type() === 'background_page' + .asPage() (NOT .page() — RESEARCH §4 Pitfall 1). - lib/sw.ts: chrome.* state queries via the POPUP page handle (NOT the WebWorker handle — see architecture note below). getBadgeText, getPopup, getManifest, getIconSize, getIsRecording (side-channeled through badge text), fireOnStartup (via __mokoshTestQuery bridge), sendSyntheticRecordingError, getNotificationSnapshot (via bridge), keepalivePing (no-op message to wake SW for ~30s). - lib/offscreen.ts: getDisplaySurface, simulateUserStop (the dispatchEvent('ended') path per RESEARCH §7 BLOCKER — DO NOT REFACTOR to track.stop()), getSegmentCount. - lib/assertions.ts: runAssertion(idx, name, buffers, fn) wrapper — records pass/fail/duration; on failure dumps last 30 lines of SW + offscreen console buffers to stderr before rethrowing. assertEqual / assertMatch / assertTrue / assertGte / waitFor polling helper. - lib/zip.ts: jszip-based assertArchiveShape + extractEntryToFile for assertions 12 + 13. - README.md: runtime + local-debug + CI semantics + locale gotcha + dev-dep size note + assertion catalog table. - tsconfig.json: per-tree type-check config (mirrors root tsconfig.json compiler options but includes the harness tree explicitly). Architecture refinement (DEVIATION from RESEARCH §1 — Rule 1+3 inline fix): - RESEARCH §1 sketched `sw.evaluate(() => chrome.action.getBadgeText({}))` as the chrome.* query path. Empirical probes during Task 3 execution against Puppeteer 25.0.2 + Chrome 148 + --headless=true revealed two blockers: 1. Puppeteer's WebWorker.evaluate runs in an ISOLATED WORLD that carries SW globals (clients, registration, ...) but NOT the extension's full chrome.* API surface. Object.keys(chrome) inside sw.evaluate returns ["loadTimes","csi"] — the public webpage chrome, not the extension chrome. 2. Chrome 148's headless mode aggressively suspends MV3 service workers; subsequent swTarget.worker() calls return "Protocol error: No target with given id found". - WORKAROUND: open the popup page (chrome-extension://<id>/src/popup/ index.html) as a separate Puppeteer Page. The popup has full chrome.* access (it's an extension context with same privileges as the SW) AND stable Puppeteer lifetime. For SW-globalThis state (__mokoshTest in the SW isolate, NOT in the popup), bridge via chrome.runtime.sendMessage. The popup sends { type: '__mokoshTestQuery', op: 'snapshot' | 'fire-on-startup' | 'handler-types' }; the SW hook's onMessage handler responds. - Bridge implementation added to src/test-hooks/sw-hooks.ts — registers AFTER the production listeners so it never intercepts production messages (__mokoshTest* type is unambiguously test-only). Tier-1 grep gate (no-test-hooks-in-prod-bundle.test.ts) continues to enforce ZERO __mokoshTest occurrences in dist/ — the bridge handler is tree-shaken alongside the rest of the hook module via the __MOKOSH_UAT__ gate. Other configuration changes: - vitest.config.ts: exclude tests/uat/** from vitest discovery. The Puppeteer harness is invoked via `npm run test:uat` (not vitest); running it under vitest would try to launch real Chrome inside a vitest worker. The .test.ts suffix is retained for editor + naming-convention consistency with the rest of the tree. Verification: - npx tsc --noEmit (src/): exit 0 - npx tsc --noEmit -p tests/uat: exit 0 - npm run build: exit 0 - grep -rln '__mokoshTest|simulateUserStop|getSegmentCount|setCurrentStream|setSegmentCountGetter|__mokoshTestQuery|__mokoshKeepalive' dist/: ZERO matches - npm run build:test: exit 0; dist-test/ populated with the new bridge code - SKIP_BUILD=1 npx vitest run: 89/89 GREEN - SKIP_PROD_REBUILD=1 npx tsx tests/uat/harness.test.ts: → A0 [PASS]: production bundle has no test-hook leaks (19ms) → Browser launches; popup opens; manifest read succeeds → A1-A13 [FAIL]: NOT YET IMPLEMENTED — Plan 01-11 Task N wires this → "UAT harness: 1/14 assertions passed, 13 failed (first failure: A1)" → Exit code: 1 (expected — 13 RED stubs intentional) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Task 4 of Plan 01-11 attempted A1-A4 wiring. Empirical run reveals an architectural blocker that needs orchestrator-level decision. Current state after this commit (SKIP_PROD_REBUILD=1 npx tsx tests/uat/harness.test.ts): - A0 [PASS]: production bundle hook-leak grep gate (17ms) - A1 [FAIL]: SW bootstrap → setIdleMode — popup state never transitions to '' despite keepalive ping + 3s waitFor. chrome.action.getPopup({}) from the popup page consistently returns the manifest default (chrome-extension://<id>/src/popup/index.html), not the '' that setIdleMode's chrome.action.setPopup({popup:''}) should produce. - A2 [FAIL]: toolbar onClicked — badge never transitions to "REC" after page.triggerExtensionAction(extension); 8s timeout. Either the toolbar action isn't reaching the SW listener, OR getDisplayMedia's picker isn't resolving in headless mode (despite the auto-select flag). - A3 [FAIL]: offscreen target never appears (correlates with A2 — no recording started, no offscreen document spawned). - A4 [PASS]: trivially passes (offscreen count is 0 → 0, both before + after the click). Not a true assertion of behavior; would also pass if the whole extension were broken. - A5-A13: stubbed RED per plan. Architectural blocker (Rule 4 — needs orchestrator decision): - Puppeteer 25.0.2 + Chrome 148 + headless cannot reliably keep the MV3 SW alive long enough OR expose its real chrome.* state to a popup page query. The popup-bridge architecture (Task 3 commitdbd977c) works for synchronous bridge queries (snapshot, fire-on-startup) but does NOT reliably reflect chrome.action.setPopup / setBadgeText state changes initiated by the SW. Three plausible paths forward (need orchestrator pick): Option A — Content-script bridge: inject a content script that bridges chrome.* queries to a webpage's window.* RPC surface; harness uses page.evaluate against the content script instead of popup.evaluate. Pros: content scripts have stable lifetime tied to the page they're injected into. Cons: content scripts have DIFFERENT chrome.* surface (no chrome.action API surface — they can't read getBadgeText / getPopup at all). Likely DOESN'T solve the underlying problem. Option B — Headful with Xvfb on CI: relax the headless requirement; accept Xvfb dependency. Per Plan 01-11 RESEARCH §3, RESEARCH claimed headless works on Chrome 148 — empirical refutation here. Pros: SW lifetime is more stable in headful mode; setPopup propagation is reliable. Cons: introduces Xvfb dep that RESEARCH explicitly said wasn't needed; CI complication. Option C — Shrink harness scope to bridge-able assertions: A0 (grep gate), A8 (Bug A onStartup via bridge), A9 (icon sizes via popup fetch), A10 (manifest via popup), A13 (zip shape — operator runs SAVE_ARCHIVE manually + drops zip to a known path; harness reads it). Skip A1-A7, A11, A12 (the ones that require live SW state observation through chrome.action API). Pros: ships the bug-A-coverage portion of the harness today; keeps Plan 01-09's Task 5 operator-checkpoint partly automated. Cons: doesn't retire operator entirely; Plan 01-09 stays open on operator-empirical A1-A7. Option D — Switch to WebDriver BiDi (the Puppeteer 25 alternative backend): Puppeteer 25 supports BiDi via {protocol: 'webDriverBiDi'}. BiDi may handle extension SW evaluation differently (different isolation model). Speculative — no empirical evidence either way. What landed cleanly: - Tier-1 hook-leak grep gate (T-1-11-01) GREEN: dist/ has zero __mokoshTest / simulateUserStop / getSegmentCount / setCurrentStream / setSegmentCountGetter / __mokoshTestQuery / __mokoshKeepalive occurrences after npm run build. - Two-bundle infrastructure (dist/ vs dist-test/) operational. - Bridge handler in sw-hooks.ts works for snapshot + fire-on-startup + handler-types ops (verified by no-hang on keepalivePing call). - Existing 89-test vitest baseline preserved (no regression from any Wave 0/1/2/3 work). Verification: - npx tsc --noEmit (src/): exit 0 - npx tsc --noEmit -p tests/uat: exit 0 - npm run build: exit 0; dist/ hook-free - SKIP_BUILD=1 npx vitest run: 89/89 GREEN - SKIP_PROD_REBUILD=1 npx tsx tests/uat/harness.test.ts: 2/14 passed (A0 + A4-trivially), 12 FAIL — non-zero exit as expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 01-11 orchestrator commissioned a research+prototype investigation into whether full MV3 UAT automation is feasible with the architecture: extension-internal test page + chrome.runtime.sendMessage bridge + synthetic MediaStream (canvas-captureStream + getSettings override). EMPIRICAL VERDICT: feasible BUT plan 01-11 needs architectural revision. Architectural findings (with proof): 1. DYNAMIC IMPORT BLOCKED IN MV3 SW. Top-of-module `await import('../test-hooks/sw-hooks')` in src/background/index.ts silently kills the SW (chunk loads, await never resolves, no production listeners register, no console output). This is by design per Chromium docs (es_modules.md) + w3c/webextensions#212. The Plan 01-11 RESEARCH §6 architecture was wrong for the SW side. Workaround in this prototype: REMOVE the SW-side gated dynamic import. SW-side test hooks need a different design (see verdict). 2. OFFSCREEN-SIDE DYNAMIC IMPORT WORKS. Offscreen is a DOM document, not a SW, so top-level await + dynamic import behave normally. The offscreen-hooks.ts gated import succeeds; installFakeDisplayMedia is installed eagerly at module load. 3. EXTENSION-INTERNAL PAGE HAS FULL chrome.* SURFACE. Reachable via chrome-extension://<id>/tests/uat/prototype/extension-page-harness.html (added as rollup input in vite.test.config.ts). The page can call chrome.action.getBadgeText, chrome.action.getPopup, chrome.offscreen .createDocument, chrome.notifications.getAll, chrome.runtime .sendMessage — everything needed for A6. 4. NO 'tabs' PERMISSION → tab.url IS UNDEFINED. Production startVideoCapture's `chrome.tabs.query({active:true})` check (`if (!tab.id || !tab.url) throw`) fails because the manifest lacks the 'tabs' permission. Prototype workaround: bypass startVideoCapture by sending START_RECORDING directly to offscreen. The Bug B contract being tested is independent of how recording starts; it only depends on the RECORDING_ERROR routing path. 5. SYNTHETIC MEDIASTREAM WORKS. installFakeDisplayMedia builds a canvas-captureStream MediaStream + monkey-patches the video track's getSettings() to report displaySurface: 'monitor'. Production code's post-grant validation passes. getDisplayMedia returns the synthetic stream immediately — no picker, no headless flakiness. A6 prototype result (with Bug B fix in place — current HEAD state): [PASS] SETUP: badge becomes REC after start [PASS] A6.1: badge text is '' (NOT 'ERR') after user-stop [PASS] A6.2: popup is '' (NOT manifest default) after user-stop [PASS] A6.3: NO recovery notification fired (count delta === 0) [PASS] A6.4: isRecording=false (via badge proxy) A6 prototype result (with Bug B fix rewound to `if (false)`): [PASS] SETUP: badge becomes REC after start [FAIL] A6.1: badge text is '' (got "ERR") [FAIL] A6.2: popup is '' (got chrome-extension://.../popup/index.html) [FAIL] A6.3: notif delta = 0 (got 1) [PASS] A6.4: isRecording=false ← false-positive (badge='ERR' not 'REC') The Bug B regression rewind cycle proves the harness CAN catch regression: 4/5 checks turn RED on rewind, 5/5 turn GREEN with the fix restored. Files in this commit: - tests/uat/prototype/extension-page-harness.{html,ts} — the harness page (chrome-extension URL, exposes window.__mokoshHarness.assertA6) - tests/uat/prototype/a6.test.ts — Puppeteer driver (~270 lines) - tests/uat/prototype/probe_*.mjs — diagnostic probes used to isolate the SW dynamic-import blocker (probe_sw.mjs is the key one) - src/test-hooks/offscreen-hooks.ts — added installFakeDisplayMedia + dispatchEndedOnTrack + __mokoshOffscreenQuery bridge handler + auto- install at module load - vite.test.config.ts — added prototype harness page as rollup input; added modulePreload.polyfill=false (red herring; harmless) - src/background/index.ts — removed the broken SW-side gated dynamic import (this is the BLOCKER unblocker — production 01-11 plan needs to redesign SW-side test hooks before re-spawning) Bundle hygiene: prototype runs against dist-test/; production dist/ remains hook-free (Tier-1 grep gate still GREEN, verified via no-test-hooks-in-prod-bundle.test.ts in the unit test suite). Vitest baseline: 89/89 GREEN preserved. Runtime: ~7 seconds end-to-end (launch Chrome + open page + ensure offscreen + start recording + dispatch ended + settle + assert). See: research return for VERDICT + recommended next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>c647f61prototype to production paths; A6 GREEN eb2258a880Move the three load-bearing prototype files from `tests/uat/prototype/` to their production paths under `tests/uat/`, leaving the architectural narrative (research findings, BLOCKER citations, falsification table references) intact. No behavioral changes — A6 still PASSES 5/5 in ~7s end-to-end from the new paths. File moves (git mv preserves history): - tests/uat/prototype/extension-page-harness.html → tests/uat/extension-page-harness.html - tests/uat/prototype/extension-page-harness.ts → tests/uat/extension-page-harness.ts - tests/uat/prototype/a6.test.ts → tests/uat/a6.test.ts The `tests/uat/prototype/` directory is now empty (git does not track empty directories; will not appear in subsequent `git status`). Path-reference updates inside the moved files: - tests/uat/extension-page-harness.html: `<p>` line referencing the chrome-extension:// URL updated to drop `/prototype/`. - tests/uat/extension-page-harness.ts: file-header docstring rewritten to cite Plan 01-13 / Approach B / inheritance fromc647f61. The load-bearing architectural-finding comment block (MV3 SW dynamic- import block falsification, Approach-B chrome.* surface summary) is REWORDED but its semantic content + research citations are PRESERVED — every load-bearing fact survives the rename. - tests/uat/a6.test.ts: * File-header rewritten to position the file as Plan 01-13's standalone single-assertion entry point (preserves the future- proof rationale: this entry stays around forever for fast TDD iteration on A6 even after Wave 3 folds A6 into the orchestrator harness.test.ts). * REPO_ROOT resolvePath chain corrected from `..,..,..` to `..,..` — the file is now two directory levels above the repo root instead of three. Without this fix DIST_TEST_DIR would resolve to a path one level above the actual repo root and assertBundlePresent would throw. **VERIFIED by running the driver: build path resolves correctly.** * harnessUrl constant updated to drop `/prototype/` from the chrome-extension://<id>/tests/uat/extension-page-harness.html URL — must match the rollup emission path in dist-test/. * Stdout labels updated: 'PROTOTYPE A6 result' → 'A6 result', 'Plan 01-11 PROTOTYPE — A6 ... feasibility test' → 'Plan 01-13 — A6 (Bug B canonical) standalone driver'. Inside the docstrings the historical 'originally landed as 01-11 prototype' provenance is preserved per the plan's contract. vite.test.config.ts: - `rollupOptions.input` renamed `prototype_harness` → `extension_page_harness` pointing at the new production path. crxjs emits the harness HTML to `dist-test/tests/uat/extension-page-harness.html` (verified by `ls dist-test/tests/uat/`). - The `modulePreload: { polyfill: false }` line is PRESERVED — this is the CRITICAL SW FIX per 01-11-SUMMARY (disabling the polyfill is what makes the test bundle's offscreen-side dynamic import work without crashing in non-DOM contexts that incorrectly try to call document.querySelector). - File-header comment §4 and the inline `define.__MOKOSH_UAT__` comment are PRESERVED — load-bearing rationale for the dedicated build-time token (vs `import.meta.env.MODE === 'test'` which collides with vitest). Verification (all GREEN): - `npm run build:test` — exit 0; dist-test/ emits `tests/uat/extension-page-harness.html` and `assets/extension_page_harness-*.js`. - `npx tsx tests/uat/a6.test.ts` — exits 0 with "A6 result: PASS"; 5/5 checks GREEN (SETUP: badge becomes REC; A6.1 badge==''; A6.2 popup==''; A6.3 notif delta==0; A6.4 isRecording=false). End-to-end runtime ~7s headless on this workstation. - `npx tsc --noEmit` — exit 0 (root tsconfig + tests/uat/tsconfig.json). - `npx vitest run` — 92/92 GREEN; the moves do not touch any vitest- discovered files. - `npm run build` — exit 0; Tier-1 grep gate stays GREEN (the moves do not touch production code). Wave 2 (next): build out `tests/uat/lib/{launch,assertions,harness-page- driver}.ts` around the extension-page architecture; rewrite `tests/uat/a6.test.ts` to use the shared lib (still PASSES 5/5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Build out the Approach-B harness driver utilities atop the Wave 1 production paths. Three new files form the shared scaffold that Wave 3's 13 assertion drivers (A1-A5, A7-A13) and the eventual orchestrator (`tests/uat/harness.test.ts`) will all consume. The standalone A6 driver (`tests/uat/a6.test.ts`) is rewritten to use the new lib — behavior-preserving: A6 still PASSES 5/5 in ~7s. New files: - tests/uat/lib/launch.ts (~320 LoC) `launchHarnessBrowser({ headless?, downloadsDir? }) → HarnessHandles` Extracts the Chrome-launch + victim-page + harness-page + console- attach pattern from a6.test.ts into a single reusable helper. NEW vs prototype: CDP `Browser.setDownloadBehavior` wires Chrome's download path to a per-run `mkdtempSync` tmp dir so A5 (SAVE_ARCHIVE) can poll a known location without colliding with the operator's real downloads. Architectural commitments enforced (per 01-11-SUMMARY): no `--auto-select-desktop-capture- source` flag; victim about:blank brought to front for the production `chrome.tabs.query({active:true})` workaround; SW console attach best-effort with bounded poll; offscreen console attach opportunistic via `targetcreated` listener (offscreen target appears later, when the harness page calls chrome.offscreen.createDocument). - tests/uat/lib/assertions.ts (~210 LoC) Host-side assertion primitives: * `AssertionRecord`, `CheckRecord`, `ConsoleBuffers` types — mirror the page-side shape returned by `assertA*` methods. * `runAssertion(name, fn, buffers)` — try/catch wrapper that dumps the SW + offscreen console tails (last 100 lines each) to stderr on failure, then returns `{passed: false, error}` if `fn` throws. * `printAssertionResult(result)` — single source of truth for the formatted result print. Extracted from the inline `printResult` previously in the prototype's a6.test.ts so Wave 3's orchestrator can reuse it across all 14 assertions. * `assertEqual / assertGte / assertMatch / assertTrue` — structured failure messages atop node:assert/strict. * `waitFor(probe, predicate, timeoutMs, description)` — host- side polling primitive; mirrors the page-side waitFor semantics verbatim (they can't share a module: page-side is bundled into the harness HTML, host-side runs in Node). NO chrome.* helpers here — all chrome.* work happens inside the extension-internal harness page. This module is host-side ONLY by construction (no chrome global in Node anyway). - tests/uat/lib/harness-page-driver.ts (~170 LoC) One driver wrapper per assertion (A1..A13). Each wraps a single `page.evaluate(() => window.__mokoshHarness.assertXX())`. Centralizing this means adding/renaming an assertion = two-file edit (extension-page-harness.ts impl + this file) instead of touching every test-file caller. Wave 2 wires `driveA6` (proven fromc647f61). The 12 Wave-3 drivers (driveA1..A5, A7..A13) are stubbed as `throw new Error('NOT YET IMPLEMENTED — Wave 3<X> wires driveXX')` so the future orchestrator's `for (const drive of drivers)` loop fails cleanly on the first unimplemented one (bail-on-first- failure semantics). The `AssertionWithBytes` type is declared for A5/A12/A13 which return `bytesBase64` payloads (zip / webm bytes that the host side processes after the page-side assertion completes). Rewrite — `tests/uat/a6.test.ts`: - Drops ~80 LoC of Chrome-launch + console-attach + result-print plumbing now living in lib/launch.ts + lib/assertions.ts. - Now ~70 LoC total — pure orchestration of launchHarnessBrowser → runAssertion(driveA6) → printAssertionResult → browser.close() → exit code. - Behavior-preserving: A6 still 5/5 GREEN with the same diagnostic output (SETUP, A6.1-A6.4) and the same ~7s end-to-end runtime. Verification (all GREEN): - `npx tsc --noEmit` — exit 0 (root + tests/uat/tsconfig.json). - `npx tsx tests/uat/a6.test.ts` — exits 0 with "PASS"; 5 checks GREEN (SETUP, A6.1, A6.2, A6.3, A6.4). End-to-end runtime ~7s headless on this workstation. - `npm run build` — exit 0; Tier-1 grep gate GREEN (production bundle contains zero hook strings AND zero lib symbol names — the new lib files are test-only and not bundled into dist/). - `npm run build:test` — exit 0; dist-test/ still emits the extension-page-harness.html harness (lib files are host-side, not rollup inputs). - `npx vitest run` — 92/92 GREEN. Wave 3 ready: harness-page-driver.ts has driveA1..A5/A7..A13 stubs in place; extending requires only: 1. Add `assertAXX` method to window.__mokoshHarness in tests/uat/extension-page-harness.ts. 2. Replace the corresponding stub body in this file with the page.evaluate wrapper. 3. (Wave 3A) Create tests/uat/harness.test.ts orchestrator that iterates over [A0 grep gate, driveA1..A13] with bail-on-fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Scope: prerequisite step for Wave 3A's A3 assertion (displaySurface=monitor verification). The page→offscreen bridge gains a new op so the harness can query the active stream's `getSettings().displaySurface` without needing direct offscreen.evaluate access (impossible by-construction; the only cross-isolate path is chrome.runtime.sendMessage). Bridge op contract (`src/test-hooks/offscreen-hooks.ts`): - Protocol: { type: '__mokoshOffscreenQuery', op: 'get-display-surface' } - Response: { displaySurface: string|null } • null when no current stream (recording not active) • 'monitor' when installFakeDisplayMedia's monkey-patched getSettings() reports it (production code in src/offscreen/recorder.ts enforces this same value — tears down stream + throws 'wrong-display-surface' otherwise). - Failure: { ok: false, error: <message> } only on getSettings throw. Tier-1 grep gate extension (`tests/background/no-test-hooks-in-prod-bundle.test.ts`): - FORBIDDEN_HOOK_STRINGS: 8 → 9 entries. - Added: 'get-display-surface' (the literal bridge-op string; matches the production-bundle absence invariant — the offscreen-hooks module is tree-shaken in production builds by the Vite mode gate in src/offscreen/recorder.ts top-of-module). Verification: - npx tsc: clean - npm run build: clean (dist/ 4 chunks; no offscreen-hooks artifact) - npm run build:test: clean (dist-test/ adds offscreen-hooks-DfWtG71P.js, 2.38kB) - SKIP_BUILD=1 vitest run no-test-hooks-in-prod-bundle.test.ts → 10/10 GREEN (1 build-sanity + 9 forbidden-string checks; production bundle hook-free) - SKIP_BUILD=1 vitest run (full) → 93/93 GREEN (Wave 0+1+2 baseline 92 + 1 from the 9th grep-gate string) - npx tsx tests/uat/a6.test.ts → A6 5/5 GREEN (lib-driven path preserved; bridge op addition does not interfere) Wave 3A continuation: assertA1/A2/A3/A4 land in the next commit which wires the harness-page surface + driver wrappers + harness.test.ts orchestrator. This commit is the bridge prerequisite — keeping the bridge-op extension atomic + the grep-gate extension atomic so the 'production bundle hook-free' invariant is provable BEFORE the page-side surface lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Wave 3A landed. `npm run test:uat` now exercises 5/14 assertions end-to-end (A0 + A1 + A2 + A3 + A4); bails at A5 NOT YET IMPLEMENTED (Wave 3B scope). A6 still PASSES 5/5 through the standalone `npx tsx tests/uat/a6.test.ts` entry — the orchestrator-level A6 won't reach in Wave 3A because the sequential loop bails at A5; once Wave 3B wires driveA5 the loop will fall through to A6 (which uses the proven Wave-2 driveA6 driver — no rework needed there). Files changed: - `tests/uat/extension-page-harness.ts` — extends `window.__mokoshHarness` from `{ assertA6 }` to `{ assertA1, assertA2, assertA3, assertA4, assertA6 }`. Per-assertion contracts: • A1 — chrome.action.getBadgeText({}) === '' + getPopup({}) === '' + isRecording=false (badge !== 'REC' proxy per state-machine atomic pairing). 3 CheckRecords. • A2 — ensureOffscreen + START_RECORDING direct-to-offscreen (workaround for the `tabs` manifest permission gap per 01-11-SUMMARY + plan resolved-questions row 2) + manual setBadgeText('REC') + setPopup(POPUP_HTML_PATH) + waitFor badge==='REC'. The bypassed chrome.action.onClicked → startVideoCapture path is unit-tested in tests/background/badge-state-machine.test.ts; A2 verifies the contract that matters (recording reaches the REC state-machine row). 2 CheckRecords. • A3 — offscreen bridge query 'get-display-surface' (new in this plan via the prior commit's offscreen-hooks extension) → asserts === 'monitor'. 1 CheckRecord. • A4 — getPopup remains 'src/popup/index.html' + hasDocument()===true (no duplicate offscreen). Essentially a no-op verification — regression protection against future refactors that might unpin the popup during recording or spawn extra offscreens on stray events. 2 CheckRecords. • IMPORTANT: chrome.action.getPopup() returns the FULL absolute chrome-extension://<id>/... URL (not the manifest-relative path). A2.2 + A4.1 assert via .endsWith('src/popup/index.html') to stay extension-id independent. Empirical finding from first orchestrator run; documented inline. - `tests/uat/lib/harness-page-driver.ts` — wires `driveA1/A2/A3/A4` (replaces the 4 NOT YET IMPLEMENTED Wave-3A stubs fromeb64521). Each wraps a single page.evaluate(() => window.__mokoshHarness.assertXX()) call per the contract laid down by driveA6. A5+A7..A13 remain stubbed for Waves 3B+3C+3D. - `tests/uat/harness.test.ts` (NEW) — top-level UAT orchestrator driving all 14 assertions sequentially against a single Chrome + single harness page. A0 (Tier-1 grep gate) runs pre-flight before any Chrome launch — mirrors tests/background/no-test-hooks-in-prod-bundle.test.ts forbidden- string inventory (9 entries; belt-and-suspenders per feedback-pre-checkpoint-bundle-gates.md memory). Bail-on-first- failure with [SKIP] markers for unreached assertions + structured diagnostic dump (full SW + offscreen console tail) on each failure. SKIP_PROD_REBUILD=1 escape hatch skips the A0-side `npm run build` for developer iteration. Verification (all GREEN): - npx tsc --noEmit: clean (root) - npx tsc --noEmit -p tests/uat: clean (UAT subtree) - npm run build: clean; production bundle hook-free (9-string grep gate in vitest unit gate) - npm run build:test: clean; dist-test/assets/extension_page_harness-*.js grew from 3.87kB → 7.67kB (A1+A2+A3+A4 added) - SKIP_BUILD=1 npx vitest run: 93/93 GREEN (Wave 0+1+2 baseline 92 + 1 from the 9th grep-gate string from the prior commit; this commit adds zero new vitest tests — the A1-A4 contracts are verified at UAT-harness time only) - npx tsx tests/uat/a6.test.ts (standalone): 5/5 GREEN; exit 0 (Wave-2 A6 baseline preserved through orchestrator-adjacent harness page surface extension) - npm run test:uat (full operator entry): 5/14 GREEN (A0 + A1 + A2 + A3 + A4); bails at A5 NOT YET IMPLEMENTED (Wave 3B scope, expected). Total wall clock ~25s (~5s build + ~5s prod-rebuild for A0 + ~15s assertion sequence). Operator empirical-verification deferred to orchestrator (per feedback-pre-checkpoint-bundle-gates.md — the orchestrator runs SW CSP-safety + Node-globals + DOM-globals grep on the built bundle before surfacing any checkpoint). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Wave 3B lands the A5 (SAVE_ARCHIVE → zip on disk) and A7 (genuine RECORDING_ERROR → ERR + recovery notification) assertions, completing 8/14 of the orchestrator's GREEN floor (A0+A1+A2+A3+A4+A5+A6+A7). Bails at A8 (Wave 3C scope). Changes per file: tests/uat/extension-page-harness.ts - assertA5: 11s settle (>= SEGMENT_DURATION_MS so first rotation lands a segment) + send SAVE_ARCHIVE + assert resp.success=true. Page-side only checks SW handler ack; host-side driver verifies disk-side outcome (zip presence + size floor). - assertA7: setupFreshRecording helper (A6 tears down; A7 needs REC state) → snapshot notif count → send RECORDING_ERROR with a non-Bug-B error code ('codec-unsupported') → 200ms settle → assert badge='ERR' + popup endsWith popup.html + notif delta=1 + set-membership for 'mokosh-recovery-*' prefix. - setupFreshRecording: shared helper for A7 + future assertions that need a fresh REC state after a teardown. tests/uat/lib/harness-page-driver.ts - driveA5: page.evaluate(assertA5) THEN host-side fs polling for *.zip in handles.downloadsDir. The CDP Browser.setDownloadBehavior override renames the file to download.zip (data: URL filename gap), so we accept any *.zip suffix. Merges page-side check + host-side checks into a single AssertionRecord. Signature now takes downloadsDir as a second arg. - driveA7: standard page.evaluate wrapper (no host-side work). tests/uat/harness.test.ts - Wraps driveA5 in a closure that captures handles.downloadsDir. - Reordered: launchHarnessBrowser MUST run before driver list so the closure can read handles without a TDZ trap. tests/uat/lib/launch.ts - Victim page switched from about:blank to a file:// URL backed by a tmp HTML file in downloadsDir. About:blank breaks A5 because chrome.tabs.captureVisibleTab needs <all_urls> permission which matches http/https/file/ftp but NOT about: or data: URLs. The stub HTML satisfies <all_urls> + provides a real .url for the production saveArchive's chrome.tabs.query. src/test-hooks/offscreen-hooks.ts (test-only — tree-shaken from prod) - installFakeDisplayMedia: mintStream() helper called per fakeGetDisplayMedia invocation; each call mints a FRESH MediaStream from the persistent canvas. Real getDisplayMedia returns a new stream per call — fake now matches. Required for A7's setupFreshRecording where the previous recording's stream tracks were stopped by A6's onUserStoppedSharing teardown. - Added 33ms setInterval-driven drawFrame() alongside the existing requestAnimationFrame loop. RAF can throttle in headless Chrome on offscreen documents (page-visibility heuristics produce 0 fps), which yields zero-byte MediaRecorder segments that crash ts-ebml's VINT decode in webm-remux.extractFramesFromSegment with "Unrepresentable length: Infinity". The setInterval is redundant when RAF fires at full rate; it's a safety net for the headless-MV3 corner. Bug B regression-catch demo (success_criteria #3 — MANDATORY per plan): Step 1 — apply local regression patch (NOT committed): src/background/index.ts:792 setIdleMode() → setErrorMode() Step 2 — npm run build:test && npm run test:uat RED snippet: A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: FAIL [PASS] SETUP: badge becomes REC after start [FAIL] A6.1: badge text is '' (NOT 'ERR') after user-stop expected: "" actual: "ERR" [FAIL] A6.2: popup is '' (NOT manifest default) after user-stop expected: "" actual: "chrome-extension://<id>/src/popup/index.html" [PASS] A6.3: NO recovery notification fired (count delta === 0) [PASS] A6.4: isRecording=false (via badge proxy) UAT harness: 6/14 assertions passed (bailed: A6 failed; see above) Step 3 — revert local patch (git checkout -- src/background/index.ts). Step 4 — npm run build:test && npm run test:uat GREEN snippet: A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: PASS [PASS] SETUP: badge becomes REC after start [PASS] A6.1: badge text is '' (NOT 'ERR') after user-stop [PASS] A6.2: popup is '' (NOT manifest default) after user-stop [PASS] A6.3: NO recovery notification fired (count delta === 0) [PASS] A6.4: isRecording=false (via badge proxy) UAT harness: 8/14 assertions passed (bailed: A8 failed — NOT YET IMPLEMENTED — Wave 3C wires driveA8) The harness CORRECTLY catches the Bug B regression — the canonical debug 01-09-recovery-flow scenario (operator-initiated stop routed through setErrorMode locks the operator out of restart because popup stays pinned to SAVE-only mode). Bug B is now CI-callable end-to-end. vitest 93/93 GREEN throughout (unit-test layer unaffected). Tier-1 grep gate GREEN (9 forbidden hook strings: 0 occurrences in dist/). npm run build exit 0; npx tsc --noEmit exit 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 01-13 Task 6 (Wave 3C). Wires the final three Wave-3 assertions before A11+A12+A13 (Wave 3D — 35s segments / ffprobe / zip shape): - A8 (Bug A canonical regression rewind) — invokes chrome.notifications.create from the harness page with the SAME options the production SW onStartup handler uses (iconUrl resolved via chrome.runtime.getURL('icons/icon128.png')). Exercises Chrome's imageUtil icon validation — the exact code path Bug A regressed on (a881bf0). 4 checks: non-empty assignedId, id-honoring, getAll delta=1, prefix set-membership. The SW handler invocation itself remains covered by tests/background/onstartup-notification.test.ts (unit tier); A8 covers the end-to-end imageUtil-acceptance gate (e2e tier). Per T-1-13-06 threat-model row: unit + e2e are intentional defense in depth covering both halves of the Bug A contract. - A9 (icon file sizes meet imageUtil floors) — fetches icons/icon{16,48, 128}.png via chrome.runtime.getURL and asserts blob.size against the 200/500/1024-byte silent-rejection floors per assets-spec.md. Cheap pre-check for the Bug A class: a future icon swap that drops below the floor would silently break the notification flow; A9 catches it BEFORE the SW even tries to create. - A10 (manifest shape contract) — chrome.runtime.getManifest() asserts: permissions includes 'notifications' (without it, chrome.notifications.create is unreachable), icons['16/48/128'] defined + non-empty, action.default_icon['16/48/128'] same. 7 checks total. Catches manifest-edit regressions that would silently break A8. Bug A canonical RED-on-regression demo cycle ============================================ Regression trigger: head -c 50 /tmp/icon128.png.backup > icons/icon128.png (truncates the 2615-byte PNG to 50 bytes — preserves PNG magic so manifest loads, but Chrome's imageUtil silent-rejects the create). RED — A8 standalone driver with truncated icon128.png (50 bytes): A8 — BUG A canonical: chrome.notifications.create accepts startup-icon (imageUtil contract): FAIL Top-level error: notifications.create rejected: Unable to download all specified images. Diagnostics: - Step 1: snapshot notif count + ids BEFORE create - Step 1 result: 0 active; ids=[] - Step 2: chrome.notifications.create(id='mokosh-startup-1779124969677', iconUrl='chrome-extension://<ext-id>/icons/icon128.png') - THREW: notifications.create rejected: Unable to download all specified images. GREEN — A8 standalone driver after restoring icon128.png (2615 bytes): A8 — BUG A canonical: chrome.notifications.create accepts startup-icon (imageUtil contract): PASS Checks: [PASS] A8.1: create callback resolves with non-empty assignedId (imageUtil acceptance) expected: "non-empty string" actual: "mokosh-startup-1779124999809" [PASS] A8.2: assignedId matches input id (chrome.notifications honors caller-supplied id) expected: "mokosh-startup-1779124999809" actual: "mokosh-startup-1779124999809" [PASS] A8.3: notification count delta === 1 (exactly one new startup notification) expected: 1 actual: 1 [PASS] A8.4: at least one notification id startsWith 'mokosh-startup-' (set membership) expected: true actual: true The RED→GREEN cycle proves the harness empirically catches Bug A regression class (imageUtil silent rejection on undersized iconUrl PNG). The "Unable to download all specified images." rejection is Chrome's internal error surface for the same imageUtil validation that Bug A originally regressed on (fix ata881bf0). Note: under the full orchestrator order, the same truncation surfaces FIRST at A7 (recovery notification, which shares NOTIFICATION_ICON_PATH) — orchestrator bail-on-first-failure means A8 isn't reached in the full run. The isolated A8 demo above (via an ephemeral local driver script, NOT committed) confirmed A8 catches the same regression independently. Baseline preserved ================== - vitest: 93/93 GREEN (SKIP_BUILD=1 to dodge the pre-existing ~5s-default test timeout in no-test-hooks-in-prod-bundle.test.ts; with a fresh dist/ in place all 9 hook-string sub-tests PASS). - tsc: clean (no diagnostics). - npm run build: exit 0; production bundle unchanged (no SW/offscreen src edits — only tests/ + dist-test/). - npm run test:uat: 11/14 GREEN (A0+A1+A2+A3+A4+A5+A6+A7+A8+A9+A10); bails at A11 (Wave 3D wires that). Files touched ============= - tests/uat/extension-page-harness.ts: +assertA8 +assertA9 +assertA10 with 4 + 3 + 7 checks respectively; +createNotificationPromise + getActiveNotificationIds + STARTUP_NOTIF_PREFIX + A8_GETALL_SETTLE_MS + A9_ICON_SPEC helpers. window.__mokoshHarness extends 7 → 10 methods. - tests/uat/lib/harness-page-driver.ts: replaces driveA8/driveA9/driveA10 NYI stubs with page.evaluate wrappers. - tests/uat/harness.test.ts: updates Wave-3C-current comment block to reflect A8+A9+A10 wired (expected diagnostic 11/14, bail at A11). Approach rationale (per plan resolved-questions §A8) ==================================================== The plan resolved A8's "no SW-side handler-capture hook" challenge with an explicit SIMPLER WORKAROUND: invoke chrome.notifications.create DIRECTLY from the harness page with the same production options. This sidesteps the MV3-SW-dynamic-import block (01-11-SUMMARY) while still exercising Chrome's imageUtil validation — the exact code path Bug A broke. Approach considered but rejected per the plan: a SW-side static eager-import test hook + a __mokoshTriggerStartup message handler would have required adding a new production code path (even gated by __MOKOSH_UAT__) and a new FORBIDDEN_HOOK_STRINGS entry. The page-direct approach adds ZERO production surface and ZERO new forbidden strings — strictly better. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Lands the final three UAT-harness assertions. All 14 assertions (A0..A13) now GREEN against the current bundle; `npm run test:uat` exits 0 in ~70s wall-clock (35s of which is A11's mandatory continuity wait). Assertions wired: - A11 — 35s buffer continuity → segments.length >= 3. Tears down any prior recording (STOP_RECORDING → START_RECORDING so the recorder's `resetBuffer` at start clears segments). Waits 35_000ms wall-clock with intermittent SW keepalive PINGs every 20s (belt-and-suspenders over the offscreen recorder's own keepalive port). Queries the new `get-segment-count` bridge op. Asserts count >= 3 (per D-13: SEGMENT_DURATION_MS=10s × MAX_SEGMENTS=3). - A12 — SAVE_ARCHIVE produces zip; webm passes ffprobe. Page side dispatches SAVE_ARCHIVE (recording from A11 still alive). Host side polls `downloadsDir` for the new/updated zip (overwrite-aware mtime delta — the CDP-routed downloads pattern OVERWRITES `download.zip` rather than numbering it, empirically verified during initial RED). Extracts `video/last_30sec.webm` via JSZip to a tmpfile. Runs `/usr/bin/ffprobe -v error -f matroska <path>`; asserts exit 0 + clean stderr. Three skip-gates: (i) ffprobe binary absent → SKIPPED; (ii) webm < 10_240B (synthetic-stream-limitation signature — canvas captureStream in `--headless=new` offscreen produces 0-frame WebM with only EBML/Track headers) → SKIPPED with explicit diagnostic pointing operators to `tests/offscreen/webm-playback.test.ts` as the primary defense for the codec/remux contract; (iii) happy path → strict ffprobe gate (will fire RED on remux/codec regressions when operators run HEADLESS=0 with a real screen-share grant). A12's role as "belt + suspenders" is documented inline + framed by Plan 01-13 Task 7 behavior block. - A13 — Zip structure + meta.json shape. Second SAVE_ARCHIVE (verifies idempotency over A12's first save). JSZip parse via the `assertArchiveShape` helper (extended in this wave to read `extensionVersion` — the actual production SessionMetadata field name per src/shared/types.ts:103, vs. the earlier 01-11 prototype's incorrect `version` assumption). Six checks: SW dispatch ack, zip arrival, webm entry present, webm size > 1024B, meta.json entry present, meta.json.extensionVersion matches chrome.runtime.getManifest().version (captured once at orchestrator startup via the new page-side getManifestVersion helper). Bridge op + recorder wire: - Adds `get-segment-count` op to the offscreen-hooks `__mokoshOffscreenQuery` chrome.runtime.onMessage handler — returns `{count: number}` via the existing segmentCountGetter closure (segments.length captured at recorder.ts:284 inside startRecording; the getter binding survives multiple START/STOP cycles via the module-level let segments array). - Adds `get-segment-count` to FORBIDDEN_HOOK_STRINGS in BOTH gate files: `tests/background/no-test-hooks-in-prod-bundle.test.ts` (Tier-1 unit gate; 9 → 10 entries; vitest 93 → 94 GREEN) and `tests/uat/harness.test.ts:assertA0_GrepGate` (UAT-level mirror). Production bundle remains hook-free (0 occurrences in dist/ after `npm run build` — verified). Harness surface: - `tests/uat/extension-page-harness.ts` extends `window.__mokoshHarness` from 10 → 13 assertion methods + 1 helper: `assertA11, assertA12, assertA13, getManifestVersion`. Adds `teardownAndStartFreshRecording` helper for A11's clean-slate contract. - `tests/uat/lib/harness-page-driver.ts` retires the Wave-3 stub marker (no more NYI throws). Adds `driveA11` (standard wrapper), `driveA12` + `driveA13` (heavyweight host-side drivers with fs polling + JSZip + ffprobe). Adds `pollForNewOrUpdatedZip` which detects both new files AND overwrites via mtime delta — fixes the `download.zip` overwrite blindness that turned A12 RED on first run (driveA5's name-only filter wasn't reused). - `tests/uat/lib/zip.ts` updates `assertArchiveShape` to read `extensionVersion` (the production field name per src/shared/types.ts:103); adds the A13_MIN_VIDEO_BYTES=1024 floor constant. - `tests/uat/harness.test.ts` orchestrator wires the three new drivers + the per-run manifest-version capture for A13. Baseline: - `npx tsc --noEmit`: exit 0. - `npm run build`: exit 0; production bundle clean of all 10 hook strings (verified by grep). - `npm run build:test`: exit 0; test bundle ships `get-segment-count`. - `npx vitest run`: 94/94 GREEN (was 93; +1 from the new gate string). - `npm run test:uat`: 14/14 GREEN; wall-clock ~70s (35s A11 wait + 2× ~13s save settles + ~10s production rebuild + overhead). A11 RED-on-regression demo (documented per acceptance-criteria "at least 1 of 3"): Edit src/offscreen/recorder.ts:52: `SEGMENT_DURATION_MS = 10_000` → `SEGMENT_DURATION_MS = 30_000`. Rebuild dist-test. Re-run UAT. A11 FAILS (only 1 segment rotates in 35s, vs floor of 3). Revert the edit; A11 PASSES. The harness empirically catches regressions that lengthen the rotation cadence beyond the 30s ring window — the canonical D-13 contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 01-13 Task 9 operator UAT closure. Operator 2026-05-19 empirical session: SAVE click downloaded zip but recording stayed live (badge=REC, sharing banner persisted, subsequent toolbar press re-opened SAVE-only popup). Operator pressed 4×, got 2 zips + confusion. Root cause: src/background/index.ts saveArchive() returns success after chrome.downloads.download without signaling offscreen to stop or transitioning the SW state machine — SPEC `Тз расширение фаза1.md` "one click MUST produce a self-contained archive" was over-extended to "always-on" framing by the implementation. Fix contract (RED today; GREEN after src/background/index.ts patch): A: setBadgeText({text:''}) called post-save (setIdleMode side effect) B: setPopup({popup:''}) called post-save (re-enables chrome.action.onClicked restart path per MV3 contract) C: chrome.runtime.sendMessage({type:'STOP_RECORDING'}) dispatched (offscreen recorder.ts:848 STOP_RECORDING case already wired — no offscreen-side change needed) D: NO mokosh-recovery-* notification fires (deliberate stop ≠ error; mirrors Bug B `user-stopped-sharing` suppression branch from .planning/debug/resolved/01-09-recovery-flow.md) Tests A/B/C RED (assertion errors `expected 0 >= 1`); Test D GREEN today as the regression guard against fix over-rotating to setErrorMode. Test architecture mirrors tests/background/request-id-protocol.test.ts: synthetic BUFFER response delivered via port.onMessage listeners to drive saveArchive's request-id'd buffer fetch to completion. Empty-segments BUFFER causes createArchive → EmptyVideoBufferError → catch branch; the fix's STOP+IDLE dispatch MUST happen on both success and empty-buffer paths (operator UI contract: SAVE click = stop, success or empty alike). Debug record: .planning/debug/01-09-save-stops-recording.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Operator UAT closure for Plan 01-13 Task 9. Patches saveArchive() in src/background/index.ts with a `finally` block that dispatches STOP_RECORDING to offscreen (mirrors the existing START_RECORDING control-plane channel via chrome.runtime.sendMessage), flips isRecording=false, and calls setIdleMode() — applied to BOTH the success and empty-buffer-error paths. Operator UX contract: SAVE click ALWAYS stops the session, regardless of internal success/empty-buffer outcome. The badge clears, the popup empties (re-enabling chrome.action.onClicked for restart), and Chrome's sharing banner closes via the offscreen recorder's stopRecording() (which nulls mediaStream + stops all tracks + clears the rotation timer — line 527 of src/offscreen/recorder.ts, already wired since Plan 01-05). Trade-off documented inline: empty-buffer path still surfaces a recovery notification (the catch branch emits RECORDING_ERROR{ error:'empty-video-buffer'} → SW's own onMessage handler runs setErrorMode + creates a mokosh-recovery-* notif). The finally block then setIdleMode()'s, so the FINAL visible state is OFF/empty-popup — clean restart path. The notification stays visible briefly so the operator sees that something went wrong, then clicks it to start a new session. Test count: 94 GREEN (baseline) → 98 GREEN (+4 from tests/background/save-archive-stops-recording.test.ts). Files modified: - src/background/index.ts (saveArchive + finally block; no PortMessage/Message type changes — STOP_RECORDING already in MessageType per src/shared/types.ts:14, offscreen handler at recorder.ts:848 already wired) Toolchain: - npx tsc --noEmit: exit 0 (no type errors) - npm run build: exit 0 (dist/ clean rebuild) Debug record: .planning/debug/01-09-save-stops-recording.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Final designer reply received 2026-05-19 unblocks Plan 01-12: R2 substitution — replace Newsreader with Lora (OFL, Cyreal foundry, full Cyrillic-Latin parity, variable wght 400-700). All 9 brand decisions now resolved; R2 displaces Newsreader from `--mks-font-display`. Plan structure: 7 waves, 10 tasks. - Wave 0 (TDD scaffolds): 6 RED unit tests — tokens-adopted, fonts-present, icons-present, no-remote-fonts, manifest-i18n, locale-parity. Each RED until its corresponding artifact wave lands. - Wave 1: Self-host OFL font bundle (Lora variable normal + italic, Plex Sans ×4, Plex Mono ×2) at src/shared/fonts/ via pyftsubset (Latin + Cyrillic basic subset); land src/shared/tokens.css canonical (Google Fonts @import → 7 local @font-face rules; Newsreader → Lora per R2; .mks-word class added per RESEARCH §8 + lockup SVG line 21). - Wave 2: Rasterize Loom mark to icons/icon{16,48,128}.png via rsvg-convert; overwrite Bug A placeholders; 8-bit RGBA at all sizes. - Wave 3: Land _locales/{en,ru}/messages.json (12 keys: 8 Brief §02 operator strings + 4 supporting keys); manifest.json → __MSG_extName__ + __MSG_extDesc__ + default_locale 'en' + action.default_title. extName='Mokosh — Session Capture' per D-07 user override; extDesc per D-08 brand-decisions-v1.md wording. - Wave 4: src/popup/ + src/background/ adopt tokens.css (loom palette) + chrome.i18n.getMessage at every operator-facing copy site; replace hex literals with var(--mks-*) references; BADGE_REC_COLOR madder '#b2543d' (= --mks-madder-600 per D-04 + RESEARCH §10 Open Q A7). - Wave 5: Welcome page conditional migration (if 01-10 landed, swap welcome-tokens.css → @import canonical tokens.css; migrate copy.ts shim to chrome.i18n.getMessage fallback); add __VITE_DEV__ define per RESEARCH §12 D-09 spirit; scripts/README.md smoke-isolation note. - Wave 6: UAT harness A18-A22 (font reachability via document.styleSheets walk + fetch + byteLength; icon-not-placeholder via fingerprint diff; manifest:name === 'Mokosh — Session Capture'; --mks-font-display resolves to Lora via getComputedStyle; welcome tokens loaded conditional on 01-10). Tier-1 forbidden-strings UNCHANGED at 10. - Wave 7: Operator empirical brand-fit checkpoint (last Phase 1 gate); SUMMARY + STATE.md + ROADMAP.md sync. ROADMAP.md Phase 1 plan list extended from 7 → 13 entries (gap noted in 01-13 SUMMARY's known-limitations now closed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Per operator UX iteration (2026-05-19), the Amendment 2 save-stops-recording fix (commits cd83eb0+4f4c3e2+2b6c24b+89f3337) is REVERSED. SAVE_ARCHIVE creates a new zip but does NOT stop the recorder — matches SPEC's continuous-capture / always-on safety-net framing. This commit renames the test file via `git mv` (history preserved) and inverts tests A..C to assert the new contract: - A: no NEW setBadgeText({text:''}) call (badge stays REC) - B: no setPopup({popup:''}) call (popup stays pinned to popup.html) - C: no STOP_RECORDING dispatch via chrome.runtime.sendMessage Test D (no recovery notification) preserved unchanged as regression guard. RED expected — src/background/index.ts still has the Amendment 2 `finally` block dispatching STOP_RECORDING + setIdleMode. Next commit removes that block to drive GREEN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 01-09 Amendment 3 (2026-05-19) end-to-end lock. Inverted A14 to match the reversed charter (SAVE creates zip, recording continues). Page-side (tests/uat/extension-page-harness.ts): - assertA14: assert badge==='REC' (was ''), popup endsWith 'src/popup/index.html' (was ''), no-new-recovery-notif (unchanged). - A14 name + check labels updated to reflect continuous-recording semantic. - New constant A14_POPUP_HTML_SUFFIX for the popup endsWith check (ext-id-agnostic via suffix match). - A13 docstring + diag strings refreshed: setupFreshRecording is now defensive (orthogonal to A12 ordering) rather than a workaround for the prior auto-stop. 11s settle preserved (same wall-clock cost). Host-side (tests/uat/lib/harness-page-driver.ts): - driveA14 docstring refreshed to mention Amendment 3 + the inverted contract; mechanical wrapper unchanged. Verification: - npm run test:uat: 15/15 GREEN - A14 actual output: badge='REC' popup='chrome-extension://<ext-id>/src/popup/index.html' recoveryDelta=0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>c60b887HANDOFF.json) cecefc61f9Plan 01-14 ships W3C Screen Capture monitorTypeSurfaces: 'include' (Chrome 119+) on the offscreen getDisplayMedia call, plus an A23 harness regression assertion that verifies the constraint reaches the call site via the existing offscreen-hooks bridge. Scope: 1 source line + A23 wiring + Tier-1 grep gate inventory update (lockstep extension of unit-gate + UAT A0 FORBIDDEN_HOOK_STRINGS). Autonomous, single executor; no operator empirical checkpoint (UAT 16/16 harness coverage suffices per feedback-pre-checkpoint-bundle-gates.md). Canonical sources: - Plan 01-10 RESEARCH section 5 ('monitorTypeSurfaces: include' recommendation) - Plan 01-10 RESEARCH section Pitfall-5 ('Misinterpreting displaySurface as a hard constraint' — monitorTypeSurfaces is the picker-UI complement to D-15's post-grant validation) - W3C Screen Capture spec section 6.1 DisplayMediaStreamOptions - developer.chrome.com/docs/web-platform/screen-sharing-controls Decisions honored: - D-01 (whole-desktop only via getDisplayMedia; reject window/tab) — the new constraint is the picker-UI realization of D-01's intent. - D-15 (post-grant displaySurface validation) — UNCHANGED; remains the enforcement (this plan is belt-and-suspenders at the picker UI level). Ceremony note: this plan replaces the prior AMENDMENT-A.md improvisation path retired per 01-11-SUMMARY Architectural Notes. Canonical GSD ceremony (plan -> checker -> executor -> SUMMARY). Validations: - gsd-sdk frontmatter.validate -> valid: true (8/8 required fields). - gsd-sdk verify.plan-structure -> valid: true (1 task; hasFiles/hasAction /hasVerify/hasDone all true). - ROADMAP.md Phase 1 plans list extended with 01-14 entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>[per Plan 01-14; closes B-01-14-01 via Step 1b lockstep] - src/offscreen/recorder.ts: add monitorTypeSurfaces:'include' as top-level DisplayMediaStreamOptions sibling of video: (W3C Screen Capture spec §6.1; Chrome >= 119; removes tab/window panes from the operator's picker per Plan 01-10 RESEARCH §5 + §Pitfall-5 recommendation). Typed widening cast extended in lockstep to keep the explicit-typing contract (no `as any`). D-15 post-grant validation block at recorder.ts:294 UNCHANGED — belt (picker narrowing) + suspenders (post-grant tear-down) chain preserved. - tests/offscreen/display-surface-constraint.test.ts: lockstep update of the strict-deep-equality assertion at lines 223-226 with the same key ordering as the source change (video -> monitorTypeSurfaces -> audio). toHaveBeenCalledWith contract preserved (NO expect.objectContaining — the test author's "catches future drops of ANY field" discipline is honored). This edit + the source change land in the SAME commit so the 98/98 baseline never crosses a commit boundary in RED state. - src/test-hooks/offscreen-hooks.ts: capture last constraints object in module-scoped `lastGetDisplayMediaConstraints` cell (was `_constraints` received-but-unused; renamed to `constraints`); add `get-last-getDisplayMedia-constraints` bridge op to the __mokoshOffscreenQuery dispatcher between get-display-surface and get-segment-count. Defensive try/catch mirrors the existing dispatcher pattern; the cell is module-internal so the MokoshTestSurface cross-cast in types.ts requires NO change (decision documented inline in offscreen-hooks.ts). - tests/uat/extension-page-harness.ts: add `assertA23` mirroring `assertA3` (bridge query → 2-check AssertionResult: non-null constraints + value). Extend the `Window.__mokoshHarness` declaration + runtime export + status bar text + console.log to reference A23. - tests/uat/lib/harness-page-driver.ts: export `driveA23(page)` mirroring the `driveA14` page.evaluate wrapper shape. Standard read-only driver. - tests/uat/harness.test.ts: extend FORBIDDEN_HOOK_STRINGS (line 85) with `lastGetDisplayMediaConstraints` and `get-last-getDisplayMedia-constraints`. Import driveA23. Append `{ name: 'A23', drive: driveA23 }` to the drivers array after the A14 entry. Update header comment + orchestrator stdout to reflect A14 + A23 chain. The `Total = drivers.length + 1` arithmetic adapts automatically: 14 + 1 = 15 → 15 + 1 = 16. - tests/background/no-test-hooks-in-prod-bundle.test.ts: lockstep extension of FORBIDDEN_HOOK_STRINGS (line 105) with the same 2 strings. Header comment updated to "Total: 12 surface strings." (was 10). Confirms production `dist/` has ZERO occurrences after `npm run build` via the `__MOKOSH_UAT__` dead-branch tree-shake (T-01-14-04 mitigation). D-01 (whole-desktop only via getDisplayMedia; reject window/tab surfaces) is the design intent that monitorTypeSurfaces:'include' realizes at the picker- UI level. D-15 post-grant validation (recorder.ts:294-307) remains the actual enforcement against managed-policy/DevTools/older-Chrome overrides. Verification chain (per Plan 01-14 §verify; clean post-commit): - `npx tsc --noEmit` exit 0 - `npm run build` exit 0; dist/ produced, monitorTypeSurfaces ships in the offscreen chunk as the operator-facing picker hint - `npm run build:test` exit 0; dist-test/ produced with the harness hooks intact (gated) - `npm test` 100/100 GREEN (was 98/98; +2 via the 2 new FORBIDDEN_HOOK_STRINGS parametrized tests — both PASS, production bundle hook-free) - `npm run test:uat` 16/16 GREEN (15 → 16 via A23). A23 reads constraints `{video: {...}, monitorTypeSurfaces: 'include', audio: false}` from the fakeGetDisplayMedia capture cell — round-trips through the full call site. - Production bundle spot-check: `grep -rc 'lastGetDisplayMediaConstraints\|get-last-getDisplayMedia-constraints' dist/ | grep -v ':0$'` → empty (all `:0` filtered) → ZERO leakage. References: - W3C Screen Capture §6.1 DisplayMediaStreamOptions: https://www.w3.org/TR/screen-capture/#dom-displaymediastreamoptions-monitortypesurfaces - Chrome screen-sharing-controls (Chrome 119+): https://developer.chrome.com/docs/web-platform/screen-sharing-controls - Plan 01-10 RESEARCH §5 + §Pitfall-5 (recommendation provenance): .planning/phases/01-stabilize-video-pipeline/01-10-RESEARCH.md - Architectural-note (replaces retired AMENDMENT-A.md improvisation per 01-11-SUMMARY): canonical GSD ceremony — plan → checker (B-01-14-01) → executor → SUMMARY (this commit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Self-hosted WOFF2 bundle lands at src/shared/fonts/ per D-05 typography pairing with R2 Newsreader→Lora substitution (designer reply 2026-05-19). Bundle composition (8 WOFF2 files; ~236 KB total): - Lora-VariableFont.woff2 (49 KB) — display family, normal style, wght axis 400-700; Cyreal foundry (cyrealtype/Lora-Cyrillic main branch) - Lora-Italic-VariableFont.woff2 (53 KB) — display family italic, wght 400-700; separate variable file per upstream layout (A5 verified at execute time: Lora-Cyrillic ships italic as its own variable file). - IBMPlexSans-{Regular,Medium,SemiBold,Bold}.woff2 (24/25/25/23 KB) — UI body family with Latin + Cyrillic basic - IBMPlexMono-{Regular,Medium}.woff2 (15 KB each) — diagnostic / timer family Companion artifacts: - LICENSE-Lora.txt — verbatim OFL.txt + Lora Project Authors copyright - LICENSE-IBM-Plex.txt — verbatim LICENSE.txt + IBM Corp. copyright - README.md — substantive (160 lines): bundle table, R2 rationale, subset coverage (Cyrillic basic + supplements + №), regeneration recipe with one-off curl commands, MV3 CSP self-host rationale. scripts/subset-fonts.sh (130 lines): - One-off subsetting recipe; takes a scratch dir of upstream TTFs. - UNICODES range: U+0020-007E + U+00A0-00FF + Cyrillic basic (U+0400-045F) + Ukrainian (Ґґ) + Kazakh (Ұұ) + № sign. - Common pyftsubset flags shared across faces; per-face subset_face helper. Documents source URLs in usage block. Bundle is sufficient for the 12 i18n keys (Wave 3) + welcome hero (Plan 01-10 conditional) per the Brief §02 Russian copy specified in .planning/intel/brand-decisions-v1.md. Verification: tests/build/fonts-present.test.ts is 9/10 GREEN (1 RED remaining is the tokens.css existence check, which is Wave 1 Task 2's job). Existing 100/100 vitest baseline preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>src/shared/tokens.css lands as the canonical token system — engineering working copy of .planning/intel/design-incoming/system/bundle/mokosh-handoff/ tokens.css with three surgical edits per Plan 01-12: 1. Handoff's PREVIEW-ONLY Google Fonts @import (line 12) REMOVED + replaced with 8 local @font-face rules pointing at ./fonts/*.woff2 (Lora normal + italic variable; Plex Sans Regular/Medium/SemiBold/Bold; Plex Mono Regular/Medium). MV3 CSP self-host enforced (style-src 'self' + font-src 'self'). 2. --mks-font-display VALUE substituted from "Newsreader" to "Lora" per R2 designer reply 2026-05-19 (Cyrillic coverage; brand-decisions-v1- followup-display-font.md). The Lora foundry note is preserved in the Type-section comment. ZERO Newsreader references remain anywhere in the file (verified by grep). 3. .mks-word class added at end-of-file with the {font-family,font-size, font-weight,letter-spacing,fill} declarations from the lockup SVG's class="mks-word" usage. Required by mokosh-lockup.svg line 21 per RESEARCH §8. src/shared/brand/ engineering working copies of: - mokosh-mark.svg (Loom 2×2 weave intersection at 32×32 viewBox) - mokosh-lockup.svg (mark + Mokosh wordmark at 240×56 viewBox) The intel/ design-incoming/ copies remain unchanged as the original handoff source-of-truth. Verification: - googleapis count: 0 (MV3 CSP self-host invariant) - Newsreader count: 0 (R2 substitution complete) - @font-face count: 8 (Lora normal + italic + Plex Sans ×4 + Plex Mono ×2) - .mks-word: 1 definition (referenced by mokosh-lockup.svg) - Lora references: 13 (font-family stack + @font-face + comments) - tests/build/fonts-present.test.ts: 10/10 GREEN - tests/build/tokens-adopted.test.ts case (a): GREEN (tokens.css exists) - tests/build/tokens-adopted.test.ts cases (b)(c): still RED (Wave 4 work) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>src/popup/style.css: - Adds @import "../shared/tokens.css" at top - All hex literals removed; every color reads from var(--mks-*) per D-04 loom palette: --mks-surface body bg; --mks-rec/--mks-madder-700 for SAVE button (default/hover); --mks-amber-600 for saving; --mks-moss-600 for done; --mks-error/--mks-success/--mks-warning for status messages; --mks-fg-disabled for disabled button - Font families read from --mks-font-ui (IBM Plex Sans stack) - Spacing/radius/shadows all token-driven src/popup/index.html: - <span class="button-text"> emptied (populated by JS via i18n) - <p class="info-text" data-mks-key="popupInfoText"> attribute-marked for populateMksKeys() init-time population - <title> kept as literal English (chrome doesn't substitute __MSG_*__ in HTML body per RESEARCH Pitfall 3) src/popup/index.ts: - New `i18n(key, fallback)` helper: chrome.i18n.getMessage with explicit `|| <fallback>` for unit-test contexts without chrome.i18n stub - New `populateMksKeys()` helper: walks [data-mks-key] elements at init and sets each textContent from i18n - updateUI() reads popupSaveCta/popupSaving/popupSaveDoneShort at each state branch (idle/saving/done) with Russian fallbacks - saveArchive() success branch reads popupSaveDone - Empty-state path reads popupEmptyState src/background/index.ts: - BADGE_REC_COLOR: '#00C853' → '#b2543d' (= --mks-madder-600 per D-04; RESEARCH §10 Open Question A7 default-action) - BADGE_OFF_COLOR + BADGE_ERROR_COLOR retained as engineering choices (no loom-palette token for material-red/amber-700 equivalents) - BADGE_REC_TITLE/BADGE_OFF_TITLE/BADGE_ERROR_TITLE renamed to ..._FALLBACK and only referenced at the chrome.i18n.getMessage call sites inside setBadgeState (i18nMessage('tooltipRecPrefix' etc.)) - New `i18nMessage(key, fallback)` helper mirroring popup's i18n() - Recovery notification: title=i18nMessage('extName',...); message= i18nMessage('notifRecovery',...) - Startup notification: title=i18nMessage('extName',...); message= i18nMessage('notifStartup',...) - NOTIF_EXTNAME_FALLBACK/NOTIF_STARTUP_FALLBACK/NOTIF_RECOVERY_FALLBACK module-level constants for the |||| chain (degrade gracefully in test contexts without chrome.i18n stub) - NO `await import(...)` added (MV3 SW dynamic-import constraint per 01-11-SUMMARY preserved) Test-contract updates (3 tests; assertion-shape only — no semantic regression): - tests/background/badge-state-machine.test.ts: greenCalls→recColorCalls regex updated from /^#00[Cc]853$/ to /^#b2543d$/i lockstep with BADGE_REC_COLOR change; title-substring assertion widened to /Recording|recording/i to cover both EN locale + fallback - tests/background/onstartup-notification.test.ts: title equality ('Mokosh ready') replaced with /Mokosh/i substring assertion (survives both the 'Mokosh' fallback + 'Mokosh — Session Capture' resolved EN); message regex widened to /recording|recor|click/i - tests/background/toolbar-action.test.ts: DocumentStub gains querySelectorAll: () => [] so the new populateMksKeys() init path doesn't throw under the popup's no-DOM unit-test environment Verification: - tests/build/tokens-adopted.test.ts: 4/4 GREEN (was 2 RED + 2 GREEN) - tests/build/no-remote-fonts.test.ts: 4/4 GREEN after fresh build (Vite emits the WOFF2 files as content-hashed dist/assets/*.woff2; tokens.css references resolve through the asset pipeline; no remote-font URLs anywhere in dist/) - Full vitest sweep: 147/147 GREEN (was 145/147) - npx tsc --noEmit: clean - Tier-1 grep gate: 13/13 GREEN (no new test-mode symbols) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 01-10 (welcome tab) has NOT yet landed at execute-plan time (verified: ls src/welcome/welcome.html returns absent). Per Wave 5 branch 2B, src/welcome/* file modifications are DEFERRED — when Plan 01-10 lands, its executor will use src/shared/tokens.css directly (skipping the placeholder welcome-tokens.css step entirely; the canonical tokens.css is already import-ready from src/shared/). Unconditional changes in this wave: 1. vite.config.ts gains __VITE_DEV__ define-token (RESEARCH §12 + D-09 spirit-satisfaction). Defaults to false; activates iff env var VITE_DEV=1 is set. Reserved for any future inline smoke-mode check. Currently smoke.sh lives entirely outside Vite's input set so the gate is a defensive no-op: define: { __MOKOSH_UAT__: 'false', __VITE_DEV__: JSON.stringify(...) } 2. vite.test.config.ts inherits __VITE_DEV__ via mergeConfig (the test config only overrides __MOKOSH_UAT__: 'true'; __VITE_DEV__ from base flows through untouched). 3. scripts/README.md (NEW, ~50 lines): documents the smoke-isolation invariant — dev-only scripts in scripts/ are NOT bundled by `npm run build`; the production dist/ contains zero smoke artifacts (verified by RESEARCH §12 grep gate). Provides usage example for VITE_DEV env override + cross-references RESEARCH §12 and brand-decisions-v1.md D-09. Index lists subset-fonts.sh, rasterize-icons.sh, and smoke.sh (if present). Note on Plan 01-10 deferral: when Plan 01-10 executes after this plan closes, the welcome page src/welcome/welcome.css can either @import '../shared/tokens.css' directly OR a thin welcome-tokens.css re-export — both paths are supported by the canonical tokens.css landed in Wave 1. Plan 01-10's executor must adopt chrome.i18n.getMessage for any welcome copy strings using the 16-key matrix in _locales/ (welcomeHeroRu + welcomeHeroEn already defined; additional keys added per Plan 01-10's own artifact list). Verification: - vitest baseline 147/147 GREEN (no change from Wave 4 close) - npm run build clean (no warnings; __VITE_DEV__ propagates through define static replacement) - scripts/README.md exists with the smoke-isolation paragraph Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Wave 7 pre-checkpoint bundle gates per feedback-pre-checkpoint-bundle-gates.md revealed a pre-existing benign concern in the SW production bundle: `vite-plugin-node-polyfills` (configured for Buffer in vite.config.ts) bundles the upstream `setimmediate` package which contains a fallback `new Function("" + I)` evaluated when setImmediate is called with a non-function argument. Production source code does NOT call setImmediate(string); the construct is dead at the runtime call-graph level but Rollup conservatively preserves it (behind a runtime typeof check, not a static dead branch). Verified pre-existing across Phase 1 history via `git checkout main -- src/background/index.ts vite.config.ts && npm run build` — same `new Function` count. Plan 01-12 made NO changes to the polyfill configuration; this is logged for future tightening (Phase 5 hardening or a dedicated MV3 CSP audit plan), NOT for fix in this plan per the deviation-rule SCOPE BOUNDARY. All other pre-checkpoint bundle gates PASS: - Tier-1 forbidden-strings: 13/13 GREEN (no new test-mode symbols) - SW-bundle-import: 15/15 GREEN - Node-globals (Buffer.*) in SW chunk: 0 - DOM-globals direct SW calls: none - Manifest validation: PASS (__MSG_*__ + default_locale='en' + 16 i18n keys per locale; en+ru parity verified) - Tokens.css MV3 CSP self-host: 0 googleapis / 0 https://fonts in dist/ - Icons rasterized: 8-bit RGBA at 406/784/1952 B - vitest: 147/147 GREEN - npm run test:uat: 21/21 GREEN (A1..A14 regression-free + A18..A22 new + A23 from 01-14) - npx tsc --noEmit: clean - npm run build + npm run build:test: clean Surfacing Wave 7 operator brand-fit checkpoint to orchestrator next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Three tests in tests/background/onboarding.test.ts pinning the Plan 01-10 D-17-onboarding contract: Test A (RED): first install + empty storage opens exactly ONE welcome tab whose URL contains 'src/welcome/welcome.html', sets chrome.storage.local.set({'onboarding-completed':true, 'installed-at':<number>}), AND calls chrome.storage.local.get with EXACT key 'onboarding-completed' (storage-schema cross-version-compat pin; preserves I-02 lesson from prior draft). Test B (vacuous-GREEN, becomes load-bearing post-Task-3): reason='update' → chrome.tabs.create NOT called. Test C (vacuous-GREEN, becomes load-bearing post-Task-3): flag already true → chrome.tabs.create NOT called. Tests B and C pass vacuously until Task 3 lands openWelcomeIfFirstInstall; they remain load-bearing AFTER Task 3 as no-tab-open guards for the update/already-onboarded branches. Test A flips RED → GREEN at Task 3. Stub scaffold inherits buildBgStub from onstartup-notification.test.ts; extended with chrome.tabs.create + chrome.storage.local.{get,set} + chrome.runtime.onInstalled._callbacks (addListener.mockImplementation pattern to capture the SW's registered listener). DEVIATION NOTE: plan's <verify> expected `3 failed` but only Test A (positive contract) goes RED pre-Task-3; Tests B+C are negative-path guards that pass trivially when the helper is absent. This is standard TDD (positive test fails RED; negative tests stay GREEN through GREEN→ REFACTOR). No code change needed — Task 3's GREEN gate is "all 3 GREEN". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 01-10 Wave 1: welcome page bundle staged with canonical Plan 01-12 tokens.css @import + chrome.i18n for D-08 tagline (Plan 01-12 path-B contract). Files created: - src/welcome/copy.ts (Russian non-tagline COPY map per D-03 Sober voice; WELCOME_HERO_RU_FALLBACK + WELCOME_HERO_EN_FALLBACK exported for the welcome.ts `|| <en-const>` fallback chain). - src/welcome/welcome.html (lang='ru'; data-mokosh-key + data-mokosh- i18n-key attribute conventions; SINGLE stylesheet link; D-02 Hero + body + footer structure; 10 keyed attrs total; <title> populated via populateCopy). - src/welcome/welcome.css (FIRST LINE `@import '../shared/tokens.css';`; ZERO hex literals in source; 65 var(--mks-*) refs; D-02 layout + --mks-welcome-max-w=720px; --mks-rec madder for recording-related accents per D-04 Loom palette). - src/welcome/welcome.ts (vanilla DOM; populateCopy + populateI18n filter-pipeline form per project rule "no `continue`"; no `as any`; Logger from src/shared; document.readyState guard; no event handlers per D-16-toolbar informational charter). Files modified: - vite.config.ts: rollupOptions.input gains `welcome: 'src/welcome/welcome.html'`; __VITE_DEV__ + __MOKOSH_UAT__ defines untouched (Plan 01-12 Wave 5 baseline preserved verbatim). - vite.test.config.ts: mirror entry in dist-test/; mergeConfig pattern untouched. - manifest.json: web_accessible_resources block added after host_permissions, before background; storage permission preserved in permissions array; default_locale='en' + __MSG_*__ placeholders from Plan 01-12 Wave 3 preserved verbatim. NO src/welcome/welcome-tokens.css file is created — Plan 01-12 must_have #9 path-B contract: Plan 01-12 landed FIRST (b909c37→ 865d394; SUMMARY 2026-05-20); canonical src/shared/tokens.css is import-ready (Lora @font-face + IBM Plex Sans + D-04 Loom palette + --mks-rec= var(--mks-madder-600) = #b2543d); welcome.css @imports it directly. No placeholder transition needed. Verify (all GREEN): - grep -F "@import '../shared/tokens.css'" src/welcome/welcome.css: exit 0 - grep -E '#[0-9a-fA-F]{3,8}' src/welcome/welcome.css: exit 1 (zero hex) - grep -c 'var(--mks-' src/welcome/welcome.css: 65 (>= 5 required) - grep -oE 'data-mokosh-(i18n-)?key=' welcome.html | wc -l: 10 (>= 7) - npm run build: clean; dist/src/welcome/welcome.html present; dist/assets/welcome-D9oNz95l.css carries inlined tokens.css content (--mks-rec: var(--mks-madder-600); --mks-madder-600: #b2543d). - npm run build:test: clean; dist-test/src/welcome/welcome.html present; dist-test/assets/welcome-wB0e_R_n.js bundled. - npx tsc --noEmit: clean. - dist/manifest.json preserves "default_locale": "en" + __MSG_extName__ + web_accessible_resources block present (Vite/crxjs propagated). - Vitest baseline preserved: Task 1's 3-test file unchanged (1 RED + 2 vacuous-GREEN; Task 3 flips Test A to GREEN). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 01-10 Wave 2: SW handler extension flips Task 1's 3 RED onboarding tests GREEN. src/background/index.ts changes: 1. Three top-level constants added near the badge/notification block: - ONBOARDING_FLAG = 'onboarding-completed' - ONBOARDING_INSTALLED_AT = 'installed-at' - WELCOME_PATH = 'src/welcome/welcome.html' SCREAMING_SNAKE per project naming standard for true constants. 2. openWelcomeIfFirstInstall helper added below ensureOffscreen (interfaces §1 placement). JSDoc cites D-17-onboarding (CONTEXT.md line 537+; SUFFIX disambiguates from D-17-port-lifecycle per CONTEXT.md lines 540-545). Body: - Early return on details.reason !== 'install' (subsequent installs / updates / chrome_update / shared_module_update do NOT open a welcome tab — Test B's contract). - chrome.storage.local.get(ONBOARDING_FLAG) read with the EXACT single-key string (storage-schema cross-version-compat pin; Test A.3's contract). - Early return if stored[ONBOARDING_FLAG] === true — Test C's contract (already-onboarded suppression). - chrome.tabs.create + chrome.storage.local.set with both the flag and Date.now() installed-at — Test A.1 + A.2's contract. - Defense-in-depth try/catch wraps the whole body; any thrown chrome.* call is logged via logger.warn but does not propagate (D-16-toolbar start path remains independent). 3. onInstalled listener extended: fire-and-forget call to openWelcomeIfFirstInstall(details) AFTER initialize(); .catch() boundary so rejected promises cannot escape the synchronous listener. The existing IDB cleanup + initialize() call sequence stays unchanged. Architectural compliance: - NO `await import(...)` added (01-11-SUMMARY architectural constraint preserved; the three matches in lines 14-28 are documentation comments about Plan 01-11's falsification). - NO `as any` (chrome.runtime.InstalledDetails ambient typing covers the parameter). - NO `continue` (if-else early-return only). - No new dependencies. Verify (all GREEN): - npx vitest run tests/background/onboarding.test.ts: 3 passed (Test A flipped RED → GREEN; B + C continue passing as load-bearing guards). - Full vitest baseline 147 → 150 (137 ex-build-gated + 13 in build- gated = 150 GREEN total). - npx tsc --noEmit: clean. - npm run build: clean; openWelcomeIfFirstInstall + D-17-onboarding references survive into dist/assets/index.ts-*.js. - Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 entries; gate GREEN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 01-10 Wave 3: extends the UAT harness with three new page-side assertions covering the onboarding contract + the canonical-tokens design-swap-readiness invariant. UAT baseline 21 → 24 GREEN. tests/uat/extension-page-harness.ts (page-side): - assertA15 — chrome.storage.local 'onboarding-completed' === true + 'installed-at' is number. Verifies SW's openWelcomeIfFirstInstall side-effects. - assertA16 — 2s settle window; chrome.tabs.query welcome-tab count delta === 0. Verifies flag-gating across SW respawns. - assertA17 — 7 sub-checks covering: welcome.html parse + .welcome-hero + >=7 mokosh-keyed attrs + welcome.css canonical @import literal OR inlined --mks-* evidence + (zero hex OR canonical resolved) + >=5 var(--mks-*) refs + bundled JS preserves populate plumbing + getComputedStyle --mks-rec → rgb(178, 84, 61) (canonical D-04 Loom). - window.__mokoshHarness surface extended with the three new methods; type declaration + assignment + page-ready status text updated. tests/uat/lib/harness-page-driver.ts (host-side): - driveA15, driveA16, driveA17 — standard page.evaluate wrappers matching driveA14 / driveA18..A22 idiom. driveA16 dominates the new wall-clock budget (~2.1s for the settle window). tests/uat/harness.test.ts (orchestrator): - Drivers array interleaves A15/A16/A17 AFTER A14 + BEFORE A18. A22's skip-gate no longer triggers (Plan 01-10 lands welcome.html; A22 now exercises the substantive token-usage path). - FORBIDDEN_HOOK_STRINGS unchanged at 12 entries (A15-A17 use only chrome.tabs.query / chrome.storage.local.get / fetch / DOMParser / getComputedStyle — all production-API surfaces). DEVIATION (Rule 1 — auto-fix bug in plan-supplied check): The plan's A17.6 spec used literal substring checks 'COPY[' and 'chrome.i18n.getMessage(' which fail against minified production output. Vite/Rollup terser renames `COPY` → `f` (local variable mangling) and welcome.ts's source uses optional chaining `chrome?.i18n?.getMessage?.(` which doesn't match the verbatim literal. Replaced with two minification-survivable witnesses: 1. 'welcome.page.title' — literal Object.freeze key (terser preserves object-literal keys verbatim). 2. 'i18n' + 'getMessage' + 'welcomeHero' substring conjunction — chrome global + property access + fallback key literal; all three survive minification regardless of optional-chaining insertion or rename. Both witnesses prove the populate plumbing survives the build (the ground-truth contract A17.6 enforces). The relaxed contract is semantically equivalent — neither substring is load-bearing on its own; both witness the same underlying invariant. Verify (all GREEN): - npm run test:uat: 24/24 assertions passed (A0 grep gate + A1..A14 + A15..A17 + A18..A22 + A23). - npx tsc --noEmit: clean. - npm run build:test: clean; dist-test/assets/welcome-wB0e_R_n.js bundled; harness page bundle includes new asserts. - SKIP_BUILD=1 npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts: 13/13 GREEN (Tier-1 grep gate; FORBIDDEN_HOOK_STRINGS at 12). - Full vitest baseline preserved: 137 ex-grep-gate + 13 grep-gate = 150 GREEN (Plan 01-10 target). A17.7 canonical proof: getComputedStyle.color = 'rgb(178, 84, 61)' — the @import '../shared/tokens.css' directive resolves through to the canonical D-04 Loom palette --mks-madder-600 = #b2543d at runtime, as the empirical proof Plan 01-12 must_have #9 path-B contract demands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Operator UAT 2026-05-20 rejected the build because the OS notification fired on `chrome.runtime.onStartup` ("Recording started. I'm watching the last 30 seconds.") implied recording had auto-started when in fact recording was not running. Per Phase 1 always-on charter recording does NOT auto-start; the notification is the gesture surface that invites the operator to start one (notifications.onClicked → startVideoCapture, src/background/index.ts:1038). Root cause: a single i18n key `notifStartup` conflated the pre-recording CTA-with-gesture path (the only path actually wired today) and a future post-manual-start confirmation path. The key's own `.description` field acknowledged the conflation. Operator-facing text leaned toward the confirmation phrasing. Fix (key split, no behavior change): - `notifStartupCta` — EN: "Mokosh ready. Click to start a recording." / RU: "Mokosh готов. Нажмите, чтобы начать запись." — wired into the onStartup handler. - `notifRecordingStarted` — preserves the original text ("Recording started. I'm watching the last 30 seconds." / "Запись запущена…") for a future post-manual-start confirmation flow. - Fallback constant renamed `NOTIF_STARTUP_FALLBACK` → `NOTIF_STARTUP_CTA_FALLBACK`; value updated to match the new CTA text. - Inline test comment in tests/background/onstartup-notification.test.ts refreshed to reference the new key + fallback. Assertion regex /recording|recor|click/i covers both fallback + resolved locale variants, no logic change. Notification behavior preserved: same id prefix `mokosh-startup-`, same priority, same icon, same onClicked → startVideoCapture wiring. No new test-mode symbols (FORBIDDEN_HOOK_STRINGS inventory stays at 12). Files modified: - _locales/en/messages.json - _locales/ru/messages.json - src/background/index.ts - tests/background/onstartup-notification.test.ts Verification: - npx vitest run --exclude tests/build/** --exclude tests/background/no-test-hooks-in-prod-bundle.test.ts: 104/104 GREEN - npx vitest run tests/i18n/ tests/background/onstartup-notification.test.ts: 18/18 GREEN (locale-parity 4/4 + onstartup-notification 14/14) - npx tsc --noEmit clean on src/background/index.ts The 2 build-dependent vitest gates (tests/build/no-remote-fonts.test.ts + tests/background/no-test-hooks-in-prod-bundle.test.ts) and npm run test:uat are deferred to orchestrator-level re-verification after the parallel Plan 01-10 mark-bundling fix also lands (operator-UAT re-spawn coordinated by orchestrator). Debug record: .planning/debug/resolved/01-09-startup-notification-misleading-text.md Operator UAT rejection event: 2026-05-20 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The legacy chrome.tabs.query({ active: true, currentWindow: true }) + "No active tab found" validation inside startVideoCapture were load- bearing in the pre-D-01 chrome.tabCapture era but became functionally dead after Plan 01-09's D-01 conversion to getDisplayMedia-in-offscreen. The only post-D-01 consumer was a log line at index.ts:521. The dead validation caused an activeTab-permission-scope asymmetry between callers: chrome.action.onClicked grants activeTab on the click gesture (so tab.url was readable → toolbar path worked silently) but chrome.notifications.onClicked does NOT grant activeTab and the extension has no `tabs` permission, so notifications.onClicked → startVideoCapture threw "No active tab found" before reaching ensureOffscreen. Operator 2026-05-20 UAT against the new notifStartupCta CTA copy ("Mokosh ready. Click to start a recording.", commit4bba679) surfaced the silent notification failure. Surgical fix: remove the dead tab query + validation + tab-dependent log (src/background/index.ts:514-521); replace with a tab-independent log that documents WHY (cites D-01 + this debug session). captureScreenshot + saveArchive retain their genuine tab dependencies (tab.windowId for chrome.tabs.captureVisibleTab; tab.id for content-script sendMessage). Tests: tests/background/start-video-capture-no-tab.test.ts (NEW) pins the contract with 3 cases (tabs.query → []; → [{id}] url-less; → [{id,url,windowId}] regression guard for toolbar path). Gates: vitest 153/153 GREEN (was 150/150 baseline; +3); test:uat 24/24 GREEN; tsc clean; build clean. Pre-checkpoint bundle gates per feedback-pre-checkpoint-bundle-gates.md: SW chunk hook-string Tier-1 grep 0 matches; eval/Node-global/DOM-global matches unchanged from baseline (all vendor-library feature-detect, guarded; no new imports). Debug record: .planning/debug/resolved/01-09-notification-start-no-active-tab.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 01-10 (welcome tab) full SUMMARY landing closure of Phase 1's final functional plan. Welcome tab landed end-to-end across 4 waves + Wave 4 operator empirical UAT cycle 2 ack "All good" 2026-05-20. Architecture: - chrome.runtime.onInstalled('install') + chrome.storage.local flag-gating (onboarding-completed:true + installed-at:<Date.now()>) + chrome.tabs.create + fire-and-forget .catch defense-in-depth. - Plan 01-12 must_have #9 path-B contract honored: welcome.css opens with `@import '../shared/tokens.css';` (canonical Lora display + IBM Plex Sans UI + D-04 Loom palette); NO placeholder welcome-tokens.css file. - chrome.i18n.getMessage for welcomeHeroRu + welcomeHeroEn with `|| <en-const>` fallback (Plan 01-12 fallback pattern preserved). - Vite `?url` import + auto-WAR idiom bundles canonical mokosh-mark.svg as inline data URL in welcome chunk (closure-cycle debug 01-10-welcome-page-missing-mark). - Three-pipeline DOM population: populateMark walks [data-mokosh-slot='mark']; populateCopy walks [data-mokosh-key] from in-file COPY map; populateI18n walks [data-mokosh-i18n-key] from chrome.i18n.getMessage. - D-16-toolbar charter preserved: welcome page is informational + read-only; NO REQUEST_PERMISSIONS / chrome.runtime.sendMessage start path. CTA copy directs operator at toolbar icon. Wave structure (4 plan-wave commits): -89e1e09Wave 0 RED onboarding tests (3 tests A/B/C) -49f087fWave 1 welcome bundle + Vite entries + manifest web_accessible_resources -8f329d8Wave 2 openWelcomeIfFirstInstall + onInstalled wiring -b112cb7Wave 3 harness A15+A16+A17 Cycle-1 operator UAT rejection 2026-05-20 ~08:56 surfaced two concerns + 5 inter-cycle debug fixes (in commit order): -4bba679fix(01-09): notifStartup text split (notifStartupCta for onStartup; notifRecordingStarted reserved for manual-start) -d48a715fix(01-10): welcome page mark — bundle canonical mokosh-mark.svg via Vite ?url + populateMark + A17.8 sub-check (Plan 01-12 must_have #9 path-A swap-in gap closed) -0854baffix(01-10): vitest build-test it() timeout bump to 30s for slower welcome-page build -a2dfc8cfix(01-09): startVideoCapture — remove stale active-tab dependency (D-01 cleanup gap; +3 RED→GREEN tests at start-video-capture-no-tab.test.ts) -d21ed17fix(01-12): brand polish — replace stale 'AI Call Recorder' refs with Mokosh (4 files; .planning/intel/* preserved as audit trail) Harness deltas: - A15-A17 added atb112cb7(24/24 UAT GREEN; A17 with 7 sub-checks incl. A17.7 --mks-rec getComputedStyle probe resolving to rgb(178,84,61) canonical). - A17.8 mark-bundling sub-check added atd48a715(verifies welcome chunk JS contains inlined data:image/svg+xml URL with canonical viewBox='0 0 32 32' preserved). - FORBIDDEN_HOOK_STRINGS unchanged at 12 (A15-A17 use chrome.tabs .query + chrome.storage.local.get + fetch + DOMParser + getComputedStyle production APIs exclusively). Test deltas: - vitest 147 → 153 GREEN (+6: 3 onboarding tests Wave 0 + 3 start-video-capture-no-tab tests closure-cycle debuga2dfc8c). - UAT harness 21/21 → 24/24 GREEN (A0-A14 + A15-A17 + A18-A22 + A23). Pre-checkpoint bundle gates per saved memory feedback-pre-checkpoint-bundle-gates.md: Tier-1 hook-string grep + SW CSP-safety + Node-globals + DOM-globals + manifest validation + en↔ru parity — all PASS. setimmediate polyfill new Function in SW chunk confirmed pre-existing (logged at .planning/phases/01-stabilize-video-pipeline/deferred-items.md for Phase 5 hardening per Plan 01-12 Wave 7 disclosure). Operator brand-fit cycle-2 ack received verbatim "All good" + cycle-2 follow-up brand-rename ack viad21ed17. Phase 1's final functional plan delivered; Phase 1 final-closure unblocked pending REQUIREMENTS / ROADMAP / STATE marker flip + optional /gsd-verify-work 1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>gsd-verifier goal-backward audit (2026-05-20) returned GREEN verdict on Phase 1 (Stabilize Video Pipeline + whole-desktop capture + as-automatic- as-platform-allows recording start): - 17/17 must-haves verified: 11 REQs/charters + 6 cross-cutting gates - 14/14 plans complete (01-01..01-09 + 01-11 spike-pivot + 01-12 + 01-13 + 01-14 + 01-10) - 5 operator empirical acks: Plan 01-07 (Chrome playback 2026-05-15) + Plan 01-13 (harness 2026-05-19) + Plan 01-12 (brand-fit 2026-05-20) + Plan 01-10 cycle-2 ("All good" 2026-05-20) + Plan 01-10 brand-rename follow-up (2026-05-20) - Test gates: vitest 153/153 GREEN; UAT harness 24/24 GREEN; Tier-1 grep gate 12 FORBIDDEN_HOOK_STRINGS; pre-checkpoint bundle gates PASS - 7 P0 audit defects: 6 closed in-Phase-1-scope; P0 #6 (data-sensitive masking) properly deferred to Phase 2 Marker flips landed: - STATE.md status reflects Phase 1 COMPLETE; completed_phases 0 → 1 - ROADMAP.md Phase 1 row [ ] → [x] with closure-arc summary - REQUIREMENTS.md REQ-video-ring-buffer In-progress → Complete 2026-05-20 - VERIFICATION.md committed (orchestrator-bundle pattern per verifier protocol) Forward-looking deferred (NOT gaps): - Phase 2: REQ-rrweb-dom-buffer + REQ-user-event-log + REQ-password-confidentiality (audit P0 #6) - Phase 5 hardening: getDisplayMedia cursor visibility; setimmediate polyfill new Function pre-existing; tabs permission gap; dark-surface logo contrast; 2 ffprobe/ffmpeg test flakes Phase 2 (Stabilize DOM + event-capture privacy) kickoff pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Per operator charter shift 2026-05-20: "we don't care about privacy hardening. At least here." Archive flow is internal-only (no external transmission), which reframes the password-masking P0-5 defect from privacy-regulation gravity to operator-hygiene polish. Re-phasing applied across 4 planning artifacts: ROADMAP.md: - Original Phase 2 ("Stabilize DOM + event-capture privacy") REMOVED entirely (summary list + Phase Details section + Progress table row). - DOM + event-log VERIFICATION (REQ-rrweb-dom-buffer + REQ-user-event-log) ABSORBED by new Phase 3 (SPEC §10 smoke verification). - Phase numbering: old 3 → new 2 (export), old 4 → new 3 (smoke), old 5 → new 4 (optional harden). Dependency chains updated accordingly. - Overview blurb + journey narrative + success criteria refreshed. - Phase 3 (smoke) explicitly NOT-in-scope: P0-5 password masking dropped. REQUIREMENTS.md traceability: - REQ-rrweb-dom-buffer: Phase 2 → Phase 3 (verification scope; UAT harness A24+ extension planned). - REQ-user-event-log: Phase 2 → Phase 3 (same context). - REQ-password-confidentiality: Phase 2 → Out of Scope (v1) — DEFERRED per charter shift. - REQ-popup-ui, REQ-screenshot-on-export, REQ-archive-layout, REQ-meta-json-schema, REQ-archive-export-latency: Phase 3 → Phase 2 (renumbered; substantively shipped via Plans 01-08 + 01-09 + 01-12; residual gaps in Phase 2). - Coverage: 10 mapped + 1 out-of-scope (was 11 mapped). PROJECT.md: - CON-sensitive-data-masking: DEFERRED 2026-05-20 (preserves audit trail via strikethrough; rationale documented). - DEC-004 amendment: rrweb 5000-event cap retained; masking deferred. Cites rrweb 2.0.0-alpha.4 maskInputSelector→maskInputFn API change. STATE.md: - frontmatter total_phases: 5 → 4. - stopped_at narrative captures the re-phasing event. CLI bug note: this re-phasing was attempted via `gsd-sdk query phase.remove 2` + canonical `/gsd-remove-phase 2` Skill invocation, but BOTH paths produced corrupted output (cascading rename via reverse-iteration loop at phase.cjs:670-679 collapsed all subsequent phases to "Phase 2", plus a mysterious "2026"→"2002" date corruption). Recovery applied as manual edits in this commit. CLI bug logged as upstream GSD-framework concern; not a Mokosh-side issue. Plan: next is `/gsd-discuss-phase 2` (new Phase 2 = export pipeline; narrowed scope per re-phasing — ~2-3 plans expected since Plans 01-08 + 01-09 + 01-10 + 01-12 already shipped most surface). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 02-01 Task 1 RED gate. Three failing tests pin D-P2-01 (offscreen-minted Blob URL pipeline) ahead of Plan 02-02 implementation: 1. chrome.downloads.download is called with a blob: URL and NOT a data:application/zip;base64, URL (closes audit P0-6). 2. A 6 MB archive completes through downloadArchive in under 5 s AND emits a blob: URL (REQ-archive-export-latency; vi-mocked remuxSegments short-circuits the muxer for the 6 MB stress path). 3. URL.revokeObjectURL is scheduled with the minted URL after chrome.downloads.onChanged fires 'complete' (lifecycle hygiene). RED evidence (vitest 4.1.6 against current HEAD): × Test 1: chrome.downloads.download was called with url='data:application/zip;base64,UEsDBAoAAAAAAL1qtFw...' — D-P2-01 forbids data:application/zip;base64, prefix. × Test 2: chrome.downloads.download was called with url='data:application/zip;base64,...' at the 6 MB scale — D-P2-01 requires blob: prefix. × Test 3: URL.revokeObjectURL was never called after chrome.downloads.onChanged 'complete' fired (chrome.downloads.onChanged._callbacks.length === 0 at probe time). Implementation notes: - vitest default env is 'node' (vitest.config.ts); Node 24 ships URL.createObjectURL + URL.revokeObjectURL + performance as globals, so no jsdom override is required. - FileReader is NOT in Node 24; added a minimal FileReader polyfill (delegates to Blob.arrayBuffer()) so JSZip's Blob ingestion works. - Test 2 mocks remuxSegments via vi.doMock to bypass muxer monotonic- timestamp constraints for the synthetic 6 MB payload. - Tests 1 + 3 drive the SW with the canonical 3-slice raw-3ebml-concat fixture (same byte offsets as tests/background/webm-remux.test.ts). - T-02-01-01 mitigation: grep -c '\.skip' returns 0. Baseline: 155 GREEN preserved (no regressions); this plan adds 3 NEW RED tests. Plan 02-02 flips them GREEN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 02-01 Task 2 RED gate. Five failing tests pin D-P2-02 (meta.json url→urls migration) and the F2 plan-checker-iter-1 resolution (empty- tracker → urls:[], no sentinel fallback) ahead of Plan 02-03. Tests: 1. SessionMetadata interface in src/shared/types.ts has 'urls: string[]' and no 'url:' field. Source-text scan (typecheck disabled in vitest.config.ts so tsc-failure pin would be a no-op). 2. createArchive emits meta.json with Array urls and no url field. 3. meta.urls deduplicates repeated URLs (first-seen-first order). 4. meta.urls filters chrome:// + about:; includes chrome-extension://. 5. Empty tracker → meta.urls === [] (NOT undefined/null/[origin]). RED evidence (vitest 4.1.6 against current HEAD): × Test 1: SessionMetadata interface body does not contain a 'urls: string[]' field (and still contains 'url:'). × Test 2: meta.urls is not an Array. Got: undefined. × Tests 3+4+5: src/background/tab-url-tracker.ts does not exist — Plan 02-03 GREEN gate. Each expect.fail emits the precise contract for the GREEN flip (export name getTabUrlsSeen(), dedup Set semantics, first-seen-first order, URL filter spec, empty-array empty-tracker resolution). Module seam (Plan 02-03 implements): src/background/tab-url-tracker.ts export function getTabUrlsSeen(): string[] Fed by chrome.tabs.onUpdated + chrome.tabs.onActivated (per DEC-011 Amendment 1 'tabs' permission grant). Baseline: 155 GREEN preserved (no regressions); this plan now has 8 NEW RED tests total (Task 1: 3 + Task 2: 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 02-01 Task 3 RED gate. Eight strict-validation tests pin D-P2-03 (strict 8-field meta.json schema) plus F2 plan-checker-iter-1 resolution (empty urls[] permitted for whole-desktop-no-tab sessions). Tests (3 RED-today + 5 GREEN-today regression guards; ALL 8 GREEN after Plan 02-03): 1. RED — Object.keys(meta).length === 8. 2. GREEN — timestamp matches ISO-8601 Z-suffix regex. 3. RED — urls is Array of valid URLs (empty permitted per F2). 4. GREEN — extensionVersion matches semver. 5. GREEN — totalEvents is non-negative integer. 6. GREEN — videoBufferSeconds === 30 (CON-video-window). 7. GREEN — logDurationMinutes === 10 (CON-event-log-window). 8. RED — no extra fields beyond EXPECTED_KEYS. RED evidence (vitest 4.1.6 against current HEAD): × Test 1: meta.json has 7 fields; D-P2-03 requires exactly 8. Current keys: [timestamp, url, userAgent, extensionVersion, videoBufferSeconds, logDurationMinutes, totalEvents]. × Test 3: meta.urls is not an Array. Got: undefined. × Test 8: meta.json contains extra (unexpected) fields: ["url"]. PLANNER-RESOLVED TENSIONS (documented in file header): - D-P2-03 'non-empty urls[]' vs CONTEXT.md permissive empty-array: resolved in favor of the permissive clause (F2 — empty is the canonical representation of whole-desktop-no-tab sessions). - 8th field name 'schemaVersion': tentative planner pick to mark the D-P2-02 url→urls breaking-change cutover. - Plan's 'ALL 8 fail' claim vs reality: 5 of 8 already pass under the current 7-field shape (timestamp, semver, totalEvents, videoBufferSeconds, logDurationMinutes). These stay GREEN as regression guards after Plan 02-03 lands. EXPECTED_KEYS constant: ['timestamp', 'urls', 'userAgent', 'extensionVersion', 'videoBufferSeconds', 'logDurationMinutes', 'totalEvents', 'schemaVersion'] Plan 02-03 implementer MUST add `schemaVersion` (recommended value: '2') to satisfy Tests 1 + 8 simultaneously. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 02-01 Wave 0 RED gate closed. Three failing test files (16 it() blocks total: 11 RED + 5 GREEN regression guards) pin the locked decisions for Phase 2 ahead of Plans 02-02 + 02-03 implementation: - blob-url-download.test.ts (3 RED) — D-P2-01 offscreen Blob URL pipeline (closes audit P0-6: base64 data: URL → blob: URL). - meta-json-urls-schema.test.ts (5 RED) — D-P2-02 meta.url → meta.urls migration + F2 empty-tracker → urls:[] resolution. - strict-meta-json-validation.test.ts (3 RED + 5 GREEN) — D-P2-03 strict 8-field schema validation with EXPECTED_KEYS pin including planner-suggested `schemaVersion` 8th field. Test count delta: 155 GREEN → 159 GREEN + 11 RED (+4 GREEN regression guards, +11 RED test contracts). Vitest reporter: Test Files 4 failed | 27 passed (31) Tests 12 failed | 159 passed (171) (12 failed = 3 + 5 + 3 RED from this plan + 1 pre-existing flaky ffprobe test in webm-remux.test.ts — out of scope; documented in SUMMARY.md Deferred Issues.) Tier-1 grep gate: 13/13 GREEN preserved (this plan touches no production code). Planner-resolved tensions carried forward in SUMMARY.md: - D-P2-03 'non-empty urls[]' vs CONTEXT.md permissive empty-array → F2 resolved in favor of permissive (Test 3 of Task 3 relaxed). - 8th field name `schemaVersion` → tentative planner pick; Plan 02-03 implementer commits to schemaVersion: '2' const. - tab-url-tracker module seam → planner-suggested name `src/background/tab-url-tracker.ts` with getTabUrlsSeen() export. - Plan claim 'ALL 8 fail' reconciled honestly: 3 RED + 5 GREEN regression guards (timestamp/semver/totalEvents/buffer-seconds/ duration-minutes already match current 7-field shape). Plan suggestions reconciled with reality: - vitest env: 'node' not 'jsdom' (Node 24 has URL/Blob/performance globals; jsdom not in devDeps). FileReader polyfill inline. - Task 2 Test 1 source-text scan instead of tsc-compile-failure (vitest.config.ts typecheck:{enabled:false}). Per worktree-mode constraint: STATE.md, ROADMAP.md, REQUIREMENTS.md NOT modified. The orchestrator owns those writes after all worktree agents in Wave 0 complete. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>- onPortMessage gains CREATE_DOWNLOAD_URL + REVOKE_DOWNLOAD_URL branches. - handleCreateDownloadUrl helper decodes SW-supplied base64 archive bytes via base64ToBlob, mints a blob:URL via URL.createObjectURL, and posts DOWNLOAD_URL{requestId,url} back on the keepalivePort. On any failure (empty payload, decode throw, mint throw) responds with url:'' so the SW's outer timeout / typed error path fires cleanly. - mintedDownloadUrls Set tracks minted URLs purely as a diagnostic signal; unknown-URL revokes get a warn but still execute (WHATWG spec: revoke on unknown URL is a no-op). - base64ToBlob added to the existing src/shared/binary import. - No changes to bootstrap/connectPort/ping/segment-rotation/__MOKOSH_UAT__ test hooks. Concurrent mints are allowed (URL minting is stateless per-Blob); only encodeAndSendBuffer needs its existing in-flight guard. Architectural rationale (D-P2-01): SW lacks URL.createObjectURL per DEC-006; offscreen has it. Reusing the existing keepalivePort (D-17) avoids two connect-overhead penalties per save flow.Production changes (src/background/index.ts): - pendingDownloadUrlResolvers Map<requestId, resolver> routes DOWNLOAD_URL responses back to the in-flight downloadArchive Promise; mirrors the pendingBufferRequests pattern from the BUFFER round-trip so port replacement mid-mint does not lose the response. - pendingRevokes Map<downloadId, url> tracks (downloadId → minted blob:URL) for the chrome.downloads.onChanged revoke dispatch. - onConnect port message sink extended with DOWNLOAD_URL routing branch (alongside existing PING/BUFFER routing). - downloadArchive rewritten: encode archive via blobToBase64 → post CREATE_DOWNLOAD_URL on videoPort → await DOWNLOAD_URL response (race against 5s BLOB_URL_MINT_TIMEOUT_MS) → reject empty / non-blob: URLs (T-02-02-03 mitigation) → call chrome.downloads.download → register (downloadId, url) in pendingRevokes. NO data:URL fallback — typed errors route through saveArchive's catch to RECORDING_ERROR. - chrome.downloads.onChanged listener registered at module init: on terminal state ('complete' / 'interrupted'), posts REVOKE_DOWNLOAD_URL to videoPort and clears the pendingRevokes entry. Deviation (Rule 3 — auto-fix blocking issue): - Plan 02-01's test helpers in blob-url-download.test.ts + meta-json-urls-schema.test.ts + strict-meta-json-validation.test.ts modeled only the REQUEST_BUFFER → BUFFER round-trip, not the new CREATE_DOWNLOAD_URL → DOWNLOAD_URL round-trip Plan 02-02 introduces. Without the test-side mint simulation, the SW's downloadArchive times out at the offscreen mint step → chrome.downloads.download never called → ALL existing meta.json tests timeout. - Each helper extended with a tryFireDownloadUrl block that decodes the CREATE_DOWNLOAD_URL.dataBase64, mints a Node-native blob:URL via URL.createObjectURL, captures the archive bytes for downstream JSZip extraction (capturedArchiveBytes), and replies DOWNLOAD_URL. Test 3 (revoke lifecycle) additionally shims port.postMessage to call URL.revokeObjectURL on receipt of REVOKE_DOWNLOAD_URL — the test-side equivalent of src/offscreen/recorder.ts handleCreateDownloadUrl. - Pre-existing Plan-02-02-era TODO comments in both test files explicitly anticipated this extension ("Plan 02-03 implementer will likely need a different helper, e.g. spy on URL.createObjectURL"). Verification (full §verification block from plan): - npx tsc --noEmit: clean - npm run build: clean - npx vitest run tests/background/blob-url-download.test.ts: 3/3 GREEN (was 3 RED) - npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts: 13/13 GREEN - npm test full suite: 163 passed / 8 failed (was 159 passed / 12 failed); net delta +4 GREEN = 3 RED→GREEN flips + 1 ffprobe-flaky pass. 8 remaining RED are exactly the Plan 02-03 territory (5 meta-json-urls-schema + 3 strict-meta-json-validation RED tests). - grep -c "data:application/zip;base64," src/background/index.ts: 0 (gone) - grep -c "blob:" src/background/index.ts: 8 (new pipeline) - grep -c "chrome.downloads.onChanged" src/background/index.ts: 5 (listener wired) - dist/ post-build: 0 "data:application/zip;base64," matches; 1 file with "chrome.downloads.onChanged" (the SW chunk).- Add src/background/tab-url-tracker.ts: initTabUrlTracker, getTabUrlsSeen, snapshotOpenTabs, clearTabUrlsSeen. - Filter: positive-allow regex ^(https?|chrome-extension):// — INCLUDE https + http + chrome-extension://; default-deny chrome://, about:, devtools://, file://, blob:, data: (per CONTEXT.md `<specifics>` URL filter clause). - Dedup: Set membership gate + first-seen-ordered array; getTabUrlsSeen returns a slice so callers cannot mutate internal state. - snapshotOpenTabs: defensive chrome.tabs.query({}) enumeration for SAVE- time augmentation (DEC-011 Amendment 1 capability). Captures tabs the operator opened but never activated. - Module guards: initialized flag prevents double-listener registration; all chrome.tabs.* listener calls wrapped in defensive try/catch matching the src/background/index.ts:bootstrap pattern. - Tier-1 grep-gate preserved (13 entries): NO `_resetForTesting` / `_observeForTesting` ergonomic test hooks exported (would have leaked into production bundles per tests/background/no-test-hooks-in-prod- bundle.test.ts). Tests drive chrome.tabs.onUpdated callbacks directly via the chrome stub — Plan 02-01 SUMMARY anticipated this option. [Rule 3 - Blocking] tests/background/meta-json-urls-schema.test.ts Tests 3+4 extended to wire chrome.tabs.onUpdated callbacks directly (replaces the optional `_resetForTesting` / `_observeForTesting` skeletons). Test 5 simplified (empty-tracker assertion needs no observation seeding on a freshly-reset module graph). Test 5 F2 contract preserved verbatim. Verification: - npx tsc --noEmit → clean - npx vitest run tests/background/meta-json-urls-schema.test.ts → 3/5 GREEN (Tests 3+4+5 the tracker-contract trio flipped; Tests 1+2 still RED as they pin the SessionMetadata + createArchive amendment — Task 2 territory)- src/shared/types.ts SessionMetadata: REPLACE `url: string` with `urls: string[]`; ADD `schemaVersion: string` as the first field. Total 8 fields. Field-emission order follows source-declaration order (TypeScript object-literal insertion order; JSON.stringify emits in insertion order per ECMA-262). Docstring cites D-P2-02 + D-P2-03 + Plan 02-01 Task 3 planner-resolved 8th field decision + F2 empty-array permission. - src/background/index.ts: * Import { initTabUrlTracker, snapshotOpenTabs, getTabUrlsSeen } from './tab-url-tracker'. * Register initTabUrlTracker() at module top-level alongside chrome.downloads.onChanged (Plan 02-02 precedent for D-P2-* feature registration). Defensive try/catch matches the surrounding chrome.* listener pattern; tracker module has its own initialized flag for idempotency. * createArchive: snapshotOpenTabs() before reading getTabUrlsSeen() (DEC-011 Amendment 1 capability — captures tabs opened but never activated). Empty urls[] emitted faithfully per F2 (no fake extension-origin sentinel; logger.warn for diagnostic visibility on whole-desktop-no-tab sessions). * metadata literal: schemaVersion: '2' first, urls (not url), 8 fields total. ECMA-262 insertion-order guarantee + JSON.stringify deliver the canonical wire shape. - Always-on charter preserved: createArchive does NOT call clearTabUrlsSeen() — tracker continues accumulating across saves (Plan 01-09 Amendment 3 invariant). Verification: - npx tsc --noEmit → clean. - npm run build → clean (dist/assets/index.ts-8LkXuqac.js 378.82 kB, ~+2 kB vs pre-Task-2 baseline for the new tab-url-tracker module). - npx vitest run → 171/171 GREEN (was 163 GREEN / 8 RED; +8 GREEN net). - Tier-1 grep gate: 13/13 GREEN unchanged. Closes 8 RED tests: - tests/background/meta-json-urls-schema.test.ts Tests 1+2 (Tests 3+4+5 flipped in Task 1). - tests/build/strict-meta-json-validation.test.ts Tests 1+3+8 (Tests 2, 4, 5, 6, 7 remain GREEN regression guards).- Rewrite REQ-meta-json-schema block (lines ~106-119) to reflect the Plan 02-03 D-P2-02 + D-P2-03 cutover: * 8 fields exact (was 7); `url: string` REMOVED; `urls: string[]` + `schemaVersion: '2'` ADDED. * Acceptance criteria: schemaVersion === '2'; ISO-8601 timestamp; urls entries match URL_SCHEME_ALLOW regex (https + http + chrome-extension://); urls deduplicated + first-seen-ordered; semver extensionVersion; non-negative integer totalEvents; exactly 8 keys. * F2 explicitly carried in the urls acceptance bullet: empty array IS permitted (whole-desktop-no-tab session is a meaningful operator state); non-empty arrays validate each entry against the filter regex. * Binding note preserves the original CON-meta-json-schema 7-field shape as SPEC provenance while documenting that this REQ supersedes it for the Phase 2 cutover. - Traceability table entry updated: Phase 3 (originally) → **Phase 2** → Phase 2 (implementation landed via Plan 02-03; harness validation deferred to Plan 02-04). - Footer dated 2026-05-20 with the REQ-meta-json-schema amendment citation; prior Plan 01-10 closure entry demoted to "Earlier update". Verification gates per plan: - grep -c "schemaVersion" .planning/REQUIREMENTS.md → 3 (≥2 required ✓) - grep -c "urls.*string\[\]" .planning/REQUIREMENTS.md → 2 (≥1 required ✓)Wave 3 closure task 3 — extends the UAT harness with 3 new assertions (A26 + A27 + A28) for empirical verification of the D-P2-02/D-P2-03 contracts + REQ-archive-layout end-to-end through a real Chrome instance. Page side (tests/uat/extension-page-harness.ts): - assertA26() — stub returning the assertion name; host-side does all inspection (JSZip is host-only via tests/uat/lib/zip.ts). - assertA27() — STRICT mode (post DEC-011 Amendment 1): owns its setupFreshRecording + opens 2 tabs (example.com + iana.org) + activates each (chrome.tabs.update active:true) + 11s settle + SAVE + tab cleanup in finally with try/catch (T-02-04-04 mitigation). Returns A27.1 (SAVE ack) + tabAUrl + tabBUrl for the host driver. - assertA28() — stub returning the assertion name; host-side enumerates zip entries. - __mokoshHarness surface extended from 25 → 28 methods. Host side (tests/uat/lib/harness-page-driver.ts): - driveA26 — chains off A25's zip via findLatestZip helper; loads via JSZip, parses meta.json, asserts 6 checks: entry present, exactly 8 fields, schemaVersion='2', urls is non-empty Array, legacy url field undefined, every URL matches /^(https?|chrome-extension):\\/\\//. - driveA27 — snapshot pre-existing zips; runs page-side; polls 8s for new-or-updated zip with stable-size protocol; loads + parses meta.json; asserts 8 STRICT checks per DEC-011 Amendment 1: SAVE ack, meta.urls is Array, length>=2, contains tabAUrl, contains tabBUrl, every entry non-empty string, no extension-origin sentinels (F2), no chrome-internal URLs. - driveA28 — chains off A27's zip; enumerates non-directory entries via filter pipeline (per CLAUDE.md no-continue style); asserts 3 checks: exactly 5 entries, set-equal to the canonical 5 paths, no extras. - findLatestZip helper added for A26/A28 chaining (mtime-sort wins). - JSZip imported at top (mirrors tests/uat/lib/zip.ts pattern). Orchestrator (tests/uat/harness.test.ts): - Imports driveA26/A27/A28 + wraps each with handles.downloadsDir. - Drivers array extends from 25 → 28 (running total 29/29 with A0). - Architecture banner updated to mention A26+A27+A28. FORBIDDEN_HOOK_STRINGS impact: NONE. A26/A28 are host-side JSZip ops; A27 uses chrome.tabs.create + chrome.tabs.update + chrome.tabs.remove (production APIs; `tabs` permission granted via DEC-011 Amendment 1 landed in Plan 02-03). Tier-1 inventory stays at 12. Verification (pre-commit): - npx tsc --noEmit: clean. - npm run build: exit 0; dist/ populated. - 4 new manifest gates (Tier-1 + SW-bundle-import) verified in followup. Closes Plan 02-04 Task 3 (Wave 3 functional contract). Pre-checkpoint bundle gates + operator empirical UAT cycle follow in Task 4.Phase 2 closure tracking: - STATE.md: status: ready_to_plan (Phase 3 prep awaits); Current Position flipped Phase 2 → COMPLETE; progress 14/18 → 18/18; percent 50% reflects 2/4 phases complete - ROADMAP.md: Phase 2 plan-count + status updated by gsd-sdk phase.complete - REQUIREMENTS.md: 5 Phase 2 REQs flipped to Complete with Phase 2 closure notes: * REQ-screenshot-on-export — A28 archive layout verification * REQ-popup-ui — SAVE-only state machine verified by A24 + A25 * REQ-archive-layout — A28 set-equality on jszip-parsed archive * REQ-meta-json-schema — D-P2-02 + D-P2-03 8-field shape verified by A26 + A27 + tests/build/strict-meta-json-validation.test.ts (8 tests) + tests/background/meta-json-urls-schema.test.ts (5 tests) * REQ-archive-export-latency — D-P2-01 Blob URL pipeline closes audit P0-6; A25 empirical <5s verification - REQ-manifest-permissions: amended to reflect DEC-011 Amendment 1 (added `tabs` permission for Phase 2 D-P2-02 meta.urls feature) + corrected `tabCapture` → `desktopCapture` per D-01 historical evolution Phase 2 outcome: 4/4 plans landed; UAT harness 24→29 GREEN; vitest 153→171 GREEN; bundle gates 6/6 PASS; verifier verdict PASSED (5/5; T5 override per user delegation + saved memory feedback-trust-harness-over-manual-uat.md). Audit closures: P0-6 (base64 data-URL cap → Blob URL pipeline) + P1 #10 (meta.url:string → urls:string[] schema). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Phase 3 RESEARCH.md addresses the 4 user-scoped research questions: Q1 puppeteer.Page.metrics() reliable for SW context? → NO (page-realm only; SW is a separate Puppeteer target). Scaffolding viable per D-P3-04 with explicit diagnostic copy; not authoritative for §10 #9. (Verified via pptr.dev/api/puppeteer.page.metrics + Puppeteer issue #7536.) Q2 rrweb 2.0.0-alpha.4 testing patterns? → Structural EventType enum grep (FullSnapshot=2 + IncrementalSnapshot=3 + Meta=4) on rrweb/session.json from latest archive. Matches "records without errors" charter literally; simpler than rrweb's own assertDomSnapshot MHTML diff. (Verified via node_modules/@rrweb/types/dist/index.d.ts grep.) Q3 rrweb v2 stable release status / alpha.4 safety? → Stable v2 has NOT shipped; npm `latest` tag still points at 2.0.0-alpha.4 (2023). Newest alpha is 2.0.0-alpha.20 (2026-02-03) with breaking NodeType import-site change. alpha.4 pin is safe for Phase 3 verification (9 closed plans + 29/29 UAT GREEN). Phase 4 upgrade research correctly deferred per D-P3-03. (Verified via `npm view rrweb dist-tags`.) Q4 New chrome.* patterns for §10? → None required. Existing 29-assertion harness already covers all §10 surfaces: A24 (blob: URL via chrome.downloads.onCreated), A28 (screenshot.png set-equality), A26 + A27 (meta.json + multi-tab urls strict). Operator chrome://memory-internals remains §10 #9 canonical per D-P3-04. Plan structure (D-P3-01: 5 atomic plans): - 03-01: rrweb DOM verification (assertA29 structural; probe HTML form+table+modal) - 03-02: event-log verification (assertA30; Puppeteer page.click/type + grep) - 03-03: §10 #8 PARTIAL via password-filter sentinel-absence (D-P3-02) - 03-04: §10 #9 best-effort + optional Page.metrics scaffolding (D-P3-04) - 03-05: §10 #1-#9 sweep VERIFICATION.md aggregator (Phase 2 frontmatter template) Tier-1 FORBIDDEN_HOOK_STRINGS expected to stay at 12 entries (A29+ ride production surfaces only). No new dependencies. Approach B template from Plan 02-04 is direct precedent.Page-side (tests/uat/extension-page-harness.ts): - assertA29 dispatches probe-page DOM mutation (input value + modal toggle), settles 500ms for rrweb IncrementalSnapshot to enqueue, setupFreshRecording, 11s segment-settle, SAVE_ARCHIVE; pushes A29.1 SAVE ack check. Module-local constants: A29_SAVE_ARCHIVE_TIMEOUT_MS=15s, A29_SEGMENT_SETTLE_MS=11s, A29_MUTATION_SETTLE_MS=500ms. - declare global interface + window.__mokoshHarness object literal extended with assertA29 (single-method-per-assertion contract). - statusEl + console banner updated A28 → A29 + cite Plan 03-01. Host-side (tests/uat/lib/harness-page-driver.ts): - Add `import { EventType } from '@rrweb/types';`. - driveA29 — 3-phase orchestration mirroring driveA26: Phase 1 page.evaluate harness.assertA29(); Phase 2 findLatestZip; Phase 3 JSZip.loadAsync rrweb/session.json + EventType grep. Appends A29.0a (rrweb/session.json present) + A29.2..A29.5 (events.length>0 + Meta + FullSnapshot + IncrementalSnapshot). Orchestrator (tests/uat/harness.test.ts): - driveA29 imported after driveA28. - driveA29Wrapped const captures handles.downloadsDir. - drivers array push A29 entry with banner citing Plan 03-01 + Pitfall 1. - Architecture banner string updated A28 → A29. Empirical verification (HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat): - UAT harness: 30/30 GREEN (29 prior + A29 NEW). - A29 events.length=4; event types observed: 2, 3, 4 (FullSnapshot, IncrementalSnapshot, Meta — all three required types present). - Pitfall 1 mitigation empirically verified — the pre-SAVE DOM mutation produced the IncrementalSnapshot. - vitest 171/171 GREEN preserved (full suite). - Tier-1 FORBIDDEN_HOOK_STRINGS unit gate 13/13 GREEN (12 strings × 0 hits each) — A29 rides production rrweb wiring + GET_RRWEB_EVENTS bridge + sendMessageWithTimeout helper; NO new __MOKOSH_UAT__ symbols. - npx tsc --noEmit exit 0.- driveA30 host-side (tests/uat/lib/harness-page-driver.ts): - import type { UserEvent } from '../../../src/shared/types' (5-type tuple grep). - A30_EXPECTED_TYPES = ['click','input','navigation','js_error','network_error'] (canonical CON-event-log-schema 5-tuple). - 3-phase pattern (page.evaluate stub → findLatestZip → JSZip logs/events.json) per Plan 02-04 driveA26 analog. - 6 host-side checks: A30.0a (entry present) + A30.2..A30.6 (5 type presence). Filter-pipeline form; no `continue`. - Orchestrator wiring (tests/uat/harness.test.ts): - driveA30 import + driveA30Wrapped const + drivers-array entry with Plan 03-02 banner; Architecture banner updated A29 -> A29, A30. - assertA30 architectural rewrite (deviation Rule 3 — blocking fix): The plan's original strategy "dispatch synthetic events ON the harness page (chrome-extension://) so the production listeners on that page fire" was empirically wrong on two counts: 1. Chrome MV3 `<all_urls>` match-pattern (Chrome match-pattern docs) permits schemes http/https/file/ftp/urn only — NOT chrome-extension. The harness page has NO content script attached; the SW SAVE_ARCHIVE handler reported "Could not establish connection. Receiving end does not exist." when the active tab was the harness page (verified empirically 2026-05-20T17:36:25Z trace). 2. Even if (1) had been satisfied, page.evaluate-side fetch() runs in the MAIN world while the content-script's window.fetch wrapper at src/content/index.ts:167 patches only the content-script's ISOLATED-world window. Page-world fetches NEVER reach the production network_error wrapper. Fix: A30 now creates a fresh https://example.com probe tab via chrome.tabs.create (mirrors A27's pattern; DEC-011 Amendment 1 `tabs` perm; `scripting` perm already in manifest); uses chrome.scripting.executeScript with default `world: 'ISOLATED'` to inject all 5 triggers directly in the content-script's realm; SAVEs while the probe tab is active (SW harvests events.json from a tab whose content script IS attached); cleans up the probe tab in finally (T-02-04-04 silent-ignore parity). All 5 UserEvent types now land empirically: type counts: click=1,input=1,navigation=1,js_error=1, network_error=1; userEvents.length=5. - UAT 30 → 31 GREEN; vitest 171/171 preserved; Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (A30 rides production chrome.tabs + chrome.scripting + GET_RRWEB_EVENTS round-trip — no new test-only symbols).- 7-check A30 contract empirically verified end-to-end across all 5 UserEvent.type literal values (click, input, navigation, js_error, network_error); userEvents.length=5; type counts all = 1. - UAT 30 -> 31 GREEN; vitest 171/171 preserved; Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (13/13 unit-gate sub-tests). - 2 deviations documented: - Rule 3 — Blocking — chrome-extension:// URLs not covered by `<all_urls>` (MV3 match-pattern spec); page-world fetch never reaches the ISOLATED-world window.fetch wrapper. Fixed by opening a fresh https://example.com probe tab + chrome.scripting.execute Script(world:'ISOLATED'). Rides production surfaces only; FORBIDDEN_HOOK_STRINGS impact = 0. - Rule 1 — Bug — history.pushState destroys Puppeteer CDP execution context. Fixed by popstate dispatch (functionally equivalent for the production wiring at src/content/index.ts:111). - One latent A29 issue surfaced (A29 "passed" via iana.org leftover data, not the harness page) — flagged for Plan 03-05 deferred-items + Phase 4 hardening; not in scope for Plan 03-02. - cs-injection-world pattern reusable for Plan 03-03 (password sentinel) and any future page-world-event-log verification.- Append driveA31 to tests/uat/lib/harness-page-driver.ts after driveA30: - Reuses UserEvent type (Plan 03-02 import already present). - 3-phase pattern: page.evaluate → findLatestZip → JSZip logs/events.json parse + filter-pipeline grep for sentinel absence + control-sentinel presence. - 3 host-side checks: A31.2 (eventsContainingSentinel.length === 0), A31.3 (eventsTargetingPassword.length === 0), A31.4 (eventsContainingControl.length >= 1; defense-in-depth proves the listener is alive so A31.2/A31.3 absences mean the filter fired rather than a tautological "no events at all" pass). - Standard guard checks A31.0 (zip present) + A31.0a (events.json entry exists) + A31.0b (JSON.parse success) gate before A31.2..A31.4 per Plan 02-04 / Plan 03-01 / Plan 03-02 driveA26/A29/A30 precedent. - Filter-pipeline form preserved (no `continue`) per CLAUDE.md Control Flow §. - Wire orchestrator in tests/uat/harness.test.ts: - Add `driveA31,` to import block after `driveA30,`. - Add `driveA31Wrapped` const after `driveA30Wrapped`. - Add `{ name: 'A31', drive: driveA31Wrapped }` entry to drivers array after the A30 entry with explanatory banner comment citing the cs-injection-world precedent + the defense-in-depth A31.4 control check. - Append `, A31` to the orchestrator banner string. Acceptance grep gates (post-commit): - grep -c 'driveA31' tests/uat/lib/harness-page-driver.ts returns 2 - grep -c 'driveA31' tests/uat/harness.test.ts returns 6 - grep -c 'secret-do-not-log-123' tests/uat/lib/harness-page-driver.ts returns 1 - tsc --noEmit exit 0 A29 flake disclosure (per Plan 03-02 SUMMARY "Issues Encountered"): - During Plan 03-03 empirical verification of A31, the pre-existing A29 flakiness documented in 03-02-SUMMARY.md surfaced: A29 chains off incidental zip-mtime ordering against prior assertions' zips, so when A29's own (empty chrome-extension:// SAVE) zip mtime ties with a prior real-content zip, findLatestZip non-deterministically returns the prior zip with rrweb events from iana.org/example.com. - 3 base runs (HEAD=de398347, no Plan 03-03 changes): 2/3 PASS, 1/3 FAIL — confirms PRE-EXISTING flake, NOT a Plan 03-03 regression. - Per CLAUDE.md SCOPE BOUNDARY ("Only auto-fix issues DIRECTLY caused by the current task's changes") + Plan 03-02 SUMMARY's explicit recommendation ("Plan 03-05's VERIFICATION.md aggregator + a Phase 4 hardening pass can pick it up"): A29 flake is OUT OF SCOPE for Plan 03-03. Documented in SUMMARY as deferred item.Plan-checker iter-1 found 2 BLOCKERS + 4 WARNINGS. Iter-2 revision applies surgical fixes to 4 plans + VALIDATION: BLOCKER 1 (Plan 04-06 Task 4): wrong SW chunk glob `dist/assets/index*-bg.js` matched zero files → Gates 2/3/4 silently PASSED. Replaced with canonical `dist/assets/index.ts-*.js` (verified empirically: index.ts-8LkXuqac.js on disk; RESEARCH Q1). Added glob-existence pre-gate `ls | wc -l >= 1` to fail-loudly on future Vite chunk-naming shift. BLOCKER 2 (Plan 04-04 Task 1): spike called non-existent __mokoshHarness.dispatchSaveArchive (verified: harness surface is assertA1..A31 + getManifestVersion only). Applied Option B — spike + driveA33 now dispatch SAVE_ARCHIVE via chrome.runtime.sendMessage inline in page.evaluate (matches 9 existing assertA* methods: A5/A11/A12/A13/A26/A28/A29/A30/A31). No new harness helper introduced. WARNING 1 (Plan 04-02 Task 2): verify omitted UAT harness run. Added `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat 2>&1 | grep -c 'UAT harness: 33/33 assertions passed'` to verify command (stdout format confirmed at tests/uat/harness.test.ts:537). WARNING 4 (Plan 04-07 Task 1): weak operator-ack gate (placeholder would pass). Added `grep -cE 'approved|All good|APPROVED|approved by|operator ack|all good' 04-VERIFICATION.md` to verify command. Covers both canonical Plan 04-06 resume-signal ("approved" lowercase) AND prior-art Plan 01-10 cycle-2 ack ("All good" titlecase). WARNINGS 2 + 3 left as-is (truly advisory: scope-sanity threshold + conservative dependency without file overlap). 04-VALIDATION.md per-task map rows updated for the 5 revised task entries (04-02 T2 + 04-04 T1 + 04-04 T2 + 04-06 T4 + 04-07 T1). Frontmatter adds `revised: 2026-05-21` + iter-2 notes block. 3 plans unchanged on disk (04-01, 04-03, 04-05). Empirical confirmations used in revision: - Harness surface: grep extension-page-harness.ts:4018 confirms __mokoshHarness.{assertA1..A31, getManifestVersion}; no dispatchSaveArchive - SW chunk filename: ls dist/assets/ shows index.ts-8LkXuqac.js; no index*-bg.js matches - SAVE_ARCHIVE precedent count: 9 existing assertA* methods use the chrome.runtime.sendMessage pattern - UAT harness stdout format: harness.test.ts:537 emits canonical "UAT harness: N/N assertions passed" Ready for plan-checker iter-3 re-verification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Three new test files at tests/content/ (NEW directory mirroring src/content/) pin the canonical Plan 04-01 contracts; 7 of 9 tests are RED today and flip GREEN once src/content/index.ts gains the three surgical edits in Task 2. * tests/content/fetch-interception.test.ts (4 tests; A+C pass today via the identity String(string)===string coincidence, B+D RED — they fetch a `new Request(url)` and assert target === request.url under the canonical `args[0] instanceof Request ? args[0].url : String(args[0])` narrow). * tests/content/navigation-tracking.test.ts (3 tests; all 3 RED — popstate + hashchange + history.pushState wrap all read meta.previousUrl which is permanently 'unknown' under today's `history.state?.url || 'unknown'` emit; GREEN after module-level `let previousUrl` lands). * tests/content/rrweb-timestamps.test.ts (2 tests; both RED — Test A asserts rrweb-emit normalizes timestamps to Date.now()-class >1e12 instead of the rrweb-internal page-load-relative small int; Test B regresses cleanupOldEvents arithmetic correctness when both sides are Unix-epoch). Scaffold mirrors tests/background/start-video-capture-no-tab.test.ts (Plan 01-09): vi.resetModules() in beforeEach, minimal chrome.* + window/document/ history/Request stubs installed on globalThis before `await import('../../src/content/index')`. rrweb is mocked via vi.mock so the content-script's `import { record } from 'rrweb'` short-circuits to a no-op factory (avoids the rrweb-lib ESM-in-CJS transform crash). userEvents and rrwebEvents are read back through the canonical GET_RRWEB_EVENTS chrome. runtime.onMessage path the production archive pipeline uses. Also folds in the .planning/config.json `use_worktrees: false` flip the orchestrator staged before respawning this executor in foreground mode. Plan: 04-01 Wave 0 Files: - tests/content/fetch-interception.test.ts - tests/content/navigation-tracking.test.ts - tests/content/rrweb-timestamps.test.ts - .planning/config.json (worktree mode disabled) Verification (RED gate): - npm test -- tests/content/ --run → 7 failed | 2 passed (9) - grep -c "instanceof Request" tests/content/fetch-interception.test.ts → 5 - grep -c "previousUrl" tests/content/navigation-tracking.test.ts → 24 - grep -cE "Date\.now\(\)" tests/content/rrweb-timestamps.test.ts → 9 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Coherent 5-edit Wave 1 GREEN landing per Plan 04-02 Task 2; RED gate from Task 1 (`tests/build/no-new-function-in-sw-chunk.test.ts` 1-hit assertion) flips GREEN with 0 hits of `new Function` in any SW chunk (`dist/assets/index.ts-*.js` glob). ## Threat T-04-02-01 mitigation (Elevation of Privilege — `new Function` literal) Three layered mechanisms cooperate to drop the CSP-unsafe `new Function` literal from the SW chunk while preserving JSZip's zip-assembly correctness end-to-end (REVISION iter-2 WARNING 1 empirically pinned at UAT harness 33/33): 1. **Runtime polyfill prelude** at top-of-module of `src/background/index.ts` (BEFORE the first `import`): an inline `queueMicrotask`-based polyfill installs `globalThis.setImmediate` at SW boot. JSZip's pre-bundled `dist/jszip.min.js` IIFE guards its internal setimmediate polyfill behind `if(!s.setImmediate){...}`, so the upstream offending body never executes at runtime once our prelude has installed the safe fast-path. 2. **`vite-plugin-node-polyfills` `exclude: ['setimmediate']`** in vite.config.ts: prevents the plugin from injecting its node-stdlib-browser-aliased setimmediate polyfill into the chunk. NOTE: this alone is insufficient because JSZip's `dist/jszip.min.js` ships its OWN bundled-in setimmediate (via the package.json `"browser"` field that maps `./lib/index` → `./dist/jszip.min.js`); the plugin's `exclude` only filters the plugin's own contributions. 3. **`resolve.alias.setimmediate`** redirects bare-specifier `setimmediate` requires to `src/shared/setimmediate-stub.ts` (a 22-LOC TS module that installs the same `queueMicrotask`-based polyfill via side-effect import). This catches any future direct `import 'setimmediate'` consumer that bypasses the prelude. 4. **`stripSetimmediateNewFunction()` Rollup post-transform plugin** in vite.config.ts: surgically replaces the single occurrence of `(I=new Function(""+I))` with `(I=function(){})` in any output chunk that contains the JSZip-bundled setimmediate IIFE. The replacement is observably equivalent in our codepath (the parent `typeof I!="function"&&` guard means the body never runs when I is already a function — which is the only form JSZip ever uses — AND the runtime prelude makes the entire IIFE body unreachable regardless). Without this plugin, JSZip's pre-bundled distribution embeds the upstream setimmediate package's `setImmediate.js` verbatim inside its internal CJS module registry (slot 54), unreachable by Vite's resolve.alias or the polyfill plugin's exclude. ## Architecture decision log **Option α (force JSZip unbundled `lib/index.js` via `resolve.alias.jszip`) was attempted and reverted 2026-05-21** (between commits630d40cand this). Empirically broke UAT harness A30+ because the unbundled entry's transitive readable-stream-browser browser-field mapping did not propagate correctly through Vite's resolver — the async zip-write pipeline silently produced an empty events.json. The post-transform plugin (Option β) is the minimum-surface fix that preserves JSZip's runtime behavior verbatim while satisfying the textual `new Function` count = 0 invariant. ## Verification **Build / static gates:** - `npm run build` exits 0; SW chunk `dist/assets/index.ts-DfBxWCT9.js` (378.92 kB) contains 0 occurrences of `new Function` (was 1 in pre-fix `index.ts-8LkXuqac.js`). - `npx tsc --noEmit` exits 0. - `grep -rn 'permissions.request' src/` returns 0 hits (Plan 04-02 ROADMAP SC #4 regression pin GREEN). - `node generate-icons.cjs` exits 0; old `generate-icons.js` no longer exists (rename via `git mv` preserves history). - `grep -c "exclude: \\['setimmediate'\\]" vite.config.ts` returns 1. - `grep -c "queueMicrotask" src/background/index.ts` returns ≥1. - `grep -c "Resolved in Phase 4 Plan 04-02" .planning/phases/01-stabilize-video-pipeline/deferred-items.md` returns ≥1. **Test gates:** - Focused: `npm test -- tests/build/no-new-function-in-sw-chunk.test.ts tests/build/dead-code-grep.test.ts --run` → 3/3 GREEN (Task 1's RED gate flipped GREEN). - Full vitest: 183/183 GREEN on the clean run (180 baseline + 3 net new from Plan 04-02 Task 1's two new files). Pre-existing intermittent flakes per 04-01-SUMMARY Issues Encountered (blob-url-download / webm-remux / webm-playback ffmpeg dry-run) persist across SUMMARY runs and are owned by Plan 04-03. **Pre-checkpoint bundle gates (per saved memory feedback-pre-checkpoint-bundle-gates.md):** 1. Tier-1 FORBIDDEN_HOOK_STRINGS: 13/13 tests GREEN; inventory unchanged at 12 strings (Plan 04-02 added no harness hooks). 2. SW CSP-safety grep: `grep -rn 'new Function\\|eval(' dist/assets/` returns 0 hits — polarity flipped from the pre-existing 1 documented exception (the setimmediate literal). T-04-02-01 mitigation pin lands. 3. Node-globals: `Buffer.copy / .isView / .length / .push / .shift / .slice / .write` in SW chunk (pre-existing JSZip internals; unchanged from 04-01-SUMMARY). 4. DOM-globals: `document.createElement / .createTextNode / .documentElement / .F` + `window.Math / .console / .localStorage / .process` (pre-existing JSZip text encoder fallback paths; unchanged from 04-01-SUMMARY). 5. manifest.json: present, MV3, `name: __MSG_extName__` (chrome.i18n intact). **Empirical UAT harness (REVISION iter-2 WARNING 1):** - `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` → 33/33 assertions passed (verbatim `UAT harness: 33/33 assertions passed` in stdout). Confirms JSZip's full SAVE → zip pipeline (A24-A32 inclusive, exercising the in-memory MediaRecorder segments + base64 port wire + remux + zip assembly + chrome.downloads + events.json + meta.json + screenshot) operates correctly under the new bundle. The setimmediate polyfill replacement preserves zip-write behavior end-to-end at the empirical layer. ## Files - **vite.config.ts**: imports `node:url` (fileURLToPath/URL) + `Plugin` type from vite; adds `nodePolyfills.exclude: ['setimmediate']`; adds `resolve.alias.setimmediate` → `src/shared/setimmediate-stub.ts`; adds `stripSetimmediateNewFunction()` Rollup post-transform plugin with full rationale comment. - **src/background/index.ts**: 17-line top-of-module prelude inserted BEFORE the first `import { Logger } ...` line. Inline `queueMicrotask`-based setimmediate polyfill with typed widening cast (no `as any` per CLAUDE.md). Reversible by `git revert`. - **src/shared/setimmediate-stub.ts** (NEW): 50-LOC TS module providing the same `queueMicrotask`-based polyfill via side-effect import. Documented as the resolve.alias target. - **generate-icons.js → generate-icons.cjs**: `git mv` preserving history. Node 14+ treats `.cjs` as CJS regardless of `package.json` "type": "module" per https://nodejs.org/api/packages.html#determining-module-system. No code change; `require('fs')` + `require('path')` resolve cleanly. No other references to the old `.js` path elsewhere in the codebase outside the `.planning/` audit trail. - **.planning/phases/01-stabilize-video-pipeline/deferred-items.md**: appended "Resolved in Phase 4 Plan 04-02" closure block citing this commit; details the 4-mechanism layered mitigation; documents the Option α attempt + reversion. References: - .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md §Q1 - .planning/phases/04-harden-clean-up-optional/04-PATTERNS.md §vite.config.ts + §src/background/index.ts - Plan 04-02 threat model T-04-02-01 (Elevation of Privilege) + T-04-02-02 (DoS — JSZip fallback compatibility; verified by UAT 33/33) - node_modules/jszip/lib/utils.js:7 (upstream `require("setimmediate")`) - node_modules/setimmediate/setImmediate.js (upstream polyfill source) - Plan 01-12 Wave 7 deferred-items.md disclosure (Phase 5 → Phase 4 target) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Plan 04-02 closes three independent build-hygiene fixes consolidated into one plan because they share the build-gate-grep test-scaffold pattern: 1. **setimmediate polyfill replacement** — layered 4-mechanism CSP-hardening eliminates the `new Function` literal from the SW chunk (grep -c flips 1→0 across all three SW chunks). Runtime guard + nodePolyfills exclude + resolve.alias + Rollup post-transform plugin. Option α (force JSZip unbundled lib/index.js) attempted + reverted because it broke readable-stream-browser propagation causing UAT A30+ regressions; Option β (post-transform plugin) preserves JSZip's pre-bundled distribution verbatim while excising the offending literal. 2. **ROADMAP SC #3** (generate-icons ESM/CJS) — `git mv generate-icons.js generate-icons.cjs` resolves the `require('fs')` under `package.json type: module` via Node's `.cjs`-as-CJS rule. 3. **ROADMAP SC #4** (dead-code grep) — `tests/build/dead-code-grep.test.ts` regression-pins `permissions.request` absence in `src/`. Plus closure of Plan 01-12 Wave 7's setimmediate deferred-items entry. Task commits: -630d40ctest(04-02): Wave 0 RED — no-new-function + dead-code-grep -f251297feat(04-02): Wave 1 GREEN — setimmediate replacement + CJS rename + closure Verification: - vitest 180/180 → 183/183 GREEN on clean run (+3 net new tests) - UAT harness 33/33 GREEN preserved (REVISION iter-2 WARNING 1 empirical pin) - Pre-checkpoint bundle gates 5/5 PASS; SW CSP-safety polarity flipped 1→0 - tsc-clean preserved; npm run build exit 0; node generate-icons.cjs exit 0 STATE.md: Plan 3/7 (Plan 04-02 complete); 25/30 total plans; 83% progress. ROADMAP.md: Phase 4 progress 2/7 plans complete (04-01 + 04-02). deferred-items.md: Plan 01-12 Wave 7 setimmediate entry CLOSED end-to-end. SUMMARY at `.planning/phases/04-harden-clean-up-optional/04-02-SUMMARY.md`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>SPIKE OUTCOME: FAILED (offscreen DIED across 5-min SW idle + worker.close()) Per Plan 04-04 spike-first contract, Wave 0 empirically investigated whether the offscreen document's RAM-only `segments: Blob[] = []` at src/offscreen/recorder.ts:91 survives a 5-min SW idle followed by Puppeteer CDP-driven `worker.close()`. RESEARCH Q2 hypothesis (MEDIUM confidence): yes, the offscreen has its own lifecycle anchored by active MediaRecorder. Spike result REFUTES that hypothesis. Empirical measurement (HEADLESS=1; one full run; reproducible via the committed spike script): - assertA2 priming: PASSED (badge=REC; offscreen + MediaRecorder live) - 5-min idle: elapsed cleanly (308.7s total wall-clock) - stopServiceWorker: succeeded (worker.close() returned) - SAVE_ARCHIVE ack: {success: true} (SW respawned + processed message) - video/last_30sec.webm size: 8505 bytes (well below 100 KB floor) - meta.urls: only chrome-extension://* origins; real-page URLs LOST - rrweb/session.json: [] - logs/events.json: [] - ffprobe on extracted webm: 'End of file' + 'Duplicate element' errors (corrupt/truncated; not a valid 30s segment cluster sequence) Interpretation: offscreen-document lifecycle is NOT independent of the SW under Puppeteer CDP-driven worker.close() conditions. The 8505 bytes are likely stale/partial header bytes from a re-initialized empty offscreen context after SW respawn, not a surviving 30s buffer. The plan's Task 2 GATING CONDITION (videoSize > 100_000) is NOT satisfied; Task 2 is BLOCKED. Per saved memory `feedback-gsd-ceremony-for-fixes.md`: architectural changes (moving segments from offscreen RAM to IndexedDB per RESEARCH Q2 sub-question b Option C) MUST route through proper plan-fix ceremony, NOT improvised inline inside Plan 04-04. Plan 04-04 SUMMARY flags the failure mode + cites exact remediation path. ROADMAP SC #1 remains OPEN pending the persistence- layer plan-fix. Task 1 persisting artifacts (this commit): - tests/uat/lib/harness-page-driver.ts: + Browser type import (puppeteer) + stopServiceWorker(browser, extensionId) helper (verbatim from Chrome devrel canonical pattern — Puppeteer >=22.1.0; project pin ^25 OK) + findLatestZip exported (was module-internal) so the spike script can reuse the canonical mtime-sort selection logic without duplication - tests/uat/spike-a33-sw-persistence.ts (NEW): + One-shot empirical investigation script; reusable for future SW- lifecycle regression testing (e.g., verifying the eventual IndexedDB persistence layer actually closes ROADMAP SC #1) + Step 1 reuses __mokoshHarness.assertA2 (canonical fresh-recording prime; not the non-existent dispatchSaveArchive that REVISION iter-2 explicitly forbids) + Step 5 dispatches SAVE_ARCHIVE via chrome.runtime.sendMessage inline from harness-page realm (Option B per plan-checker BLOCKER 2; matches A5/A11/A12/A13/A26/A28/A29/A30/A31 pattern) Verification (Task 1 acceptance criteria): - npx tsc --noEmit: exits 0 - HEADLESS=1 tsx tests/uat/spike-a33-sw-persistence.ts: ran to completion (no Puppeteer throw); SPIKE RESULT line emitted with explicit videoSize=8505 bytes; SAVE_ARCHIVE ack received - grep -c 'dispatchSaveArchive' tests/uat/spike-a33-sw-persistence.ts: 0 - grep -c "type: 'SAVE_ARCHIVE'" tests/uat/spike-a33-sw-persistence.ts: 1 - Total spike wall-clock: 308.7s (~5min idle + ~8s orchestration) References: - Plan 04-04 PLAN.md spike contract (lines 64-72) - 04-RESEARCH.md Q2 sub-question (b) — Chrome MV3 offscreen lifecycle - https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer - Saved memory: feedback-gsd-ceremony-for-fixes.md (no inline architectural fixes; route through plan-fix ceremony)Plan-04-04 debug session-2 root cause: the offscreen-console capture in tests/uat/lib/launch.ts:registerOffscreenConsoleAttach matched zero offscreen targets across 4 spike runs, creating a critical observability gap that prevented disambiguation of Plan 04-04 Wave 0 spike failure mode. Empirical investigation (tests/uat/spike-diagnose-offscreen-target.ts, NEW): when chrome.offscreen.createDocument fires, Puppeteer's `targetcreated` event fires with `type='other'` and `url=''` BEFORE the CDP target metadata stabilizes. The previous filter (whether `background_page` or `page`) never matched at event time. By the time the metadata stabilizes (visible via `browser.targets()`), the target's type is `'background_page'` (not `'page'` — MV2's background_page type IS still used by Chrome's CDP for invisible extension documents, despite MV3 abolishing classic background pages). Fix: - Match the offscreen target by URL pattern (load-bearing criterion; type field is intentionally unchecked because it's unreliable at targetcreated time). - Bind to BOTH `targetcreated` AND `targetchanged` events (the latter fires when the URL stabilizes after navigation). - Add a `browser.targets()` enumeration race-free safety net for cases where the offscreen target exists at registration time. Verification: tests/uat/spike-diagnose-offscreen-target.ts now emits `(launch: offscreen console attached — url=chrome-extension://.../src/offscreen/index.html)` followed by `[off:log] [OS:Recorder] Recording started ...` (zero such lines in any prior spike run). Test-infra correctness fix; ZERO production source changes. FORBIDDEN_HOOK_STRINGS inventory unchanged at 12 entries. No new test-only `__MOKOSH_UAT__` symbols. References: - .planning/debug/sw-offscreen-persistence-investigation-session-2.md (session-2 debug note documenting empirical root cause) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>f3baa3a(0 BLOCKER + 3 cosmetic-advisories) 48c70535ff- tests/welcome/inline-svg.test.ts (NEW; 3 tests, node-env source-contract): - Test A: mokosh-mark.svg carries stroke="currentColor" + viewBox="0 0 32 32" (currently RED — SVG still has stroke="#181b2a"). - Test B: welcome.ts uses ?raw import + DOMParser + replaceChildren and does NOT use innerHTML (MV3 CSP discipline / T-04-06-01). Currently RED — welcome.ts still ?url + <img>. - Test C: globals.d.ts declares the *.svg?raw ambient module. Currently RED — only *.svg?url + *.webm?url declared. - tests/build/cursor-visibility.test.ts (NEW; 1 test, node-env file-grep): - GREEN-on-arrival regression pin for the cursor: 'always' literal at src/offscreen/recorder.ts:285 (shipped opportunistically Plan 01-09). - Mirrors the canonical tests/i18n/manifest-i18n.test.ts scaffold (readFileSync + expect(text).toContain(...)) — vitest is environment:'node' and the project ships no DOM-emulation library, so the inline-svg test pins source TEXT only; the live-DOM injection + currentColor cascade is verified by the host-side harness assertion A35 (Task 3).Closes the iter-2 BLOCKER 1 resolution end-to-end: the inline-SVG strategy now has HONEST automated coverage at two layers — source contract (Task 1 unit tests + the narrowed A17.8 source-bundling grep) and live-DOM cascade (the NEW host-side A35 harness assertion that opens welcome.html as a real Puppeteer tab). - tests/uat/extension-page-harness.ts (A17.8 NARROWED HONESTLY): swap the data:image/svg+xml URL-grep + .svg filename-grep target for a raw-source grep — A17.8 now asserts the welcome chunk JS contains the raw SVG signature `stroke="currentColor"` AND the canonical `viewBox="0 0 32 32"` (the `?raw` import's output). The explanatory comment block now DISAVOWS the live-DOM claim and points at the NEW A35 driver for the runtime injection + cascade proof. A17.8 is honest source-bundling only. - tests/uat/lib/harness-page-driver.ts (NEW host-side driveA35): appended LAST per the iter-2 ADV-2C concern (any driver-pollution worry is moot since nothing reads A35's return value, AND welcomePage.close() in finally guarantees no tab leak). driveA35 opens chrome-extension://<id>/src/welcome/welcome.html in a fresh browser.newPage() tab, waits for the `.welcome-hero__mark svg` selector at DOMContentLoaded, then runs a single page.evaluate() that reads four signals: A35.1 inline <svg> present, A35.2 stroke=currentColor, A35.3 getComputedStyle().stroke resolves to a non-default colour (the real cascade proof), A35.4 no legacy <img> in the slot. Host-side pattern mirrors driveA32/A33/A34. - tests/uat/harness.test.ts (orchestrator wiring): + driveA35 added to the import block from './lib/harness-page-driver'. + driveA35Wrapped closure capturing handles.browser + handles.extensionId (alongside driveA33Wrapped/driveA34Wrapped). + { name: 'A35', drive: driveA35Wrapped } appended as the LAST entry of the `drivers` array. Total auto-increments via `drivers.length + 1` (line 580) — no hardcoded count to bump. + Architecture banner string (line 283) refreshed with A33, A34, A35 inline (ADV-2A cosmetic advisory — banner was already stale pre-04-06; A33+A34 added at the same time). - .planning/phases/01-stabilize-video-pipeline/01-07-SUMMARY.md (back-patch, DEFECT 2 resolution): Flipped 5 lines (22, 47, 82, 135, 205) that carried the now-stale "deferred to Phase 5" framing for cursor visibility — the `cursor: 'always'` constraint was opportunistically shipped in Plan 01-09 (recorder.ts:285) and is verified by Plan 04-06 Task 1 (tests/build/cursor-visibility.test.ts). Each flip is surgical (single line / single bullet, with explicit "back-patched in Phase 4 Plan 04-06" citation). Historical commit-description lines 40, 89, 109, 110 are LEFT unchanged — they describe what the Phase-1-closure commits literally did at the time, not forward-looking deferrals. - .planning/phases/04-harden-clean-up-optional/deferred-items.md (correction, BLOCKER 2 resolution): Corrected the misdiagnosed entry from commit6a989e8. The prior entry named tests/build/strict-meta-json-validation.test.ts as failing on a clean tree — that diagnosis was WRONG (the test is 8/8 GREEN in isolation). The real root cause is the pre-existing 04-CONTEXT #9 + #10 parallel-vitest / ffprobe-timeout flake family (lands non-deterministically on whichever ffprobe / race test loses the worker race; observed instance this session was tests/background/webm-remux.test.ts > ffprobe -count_frames, which is also 5/5 GREEN in isolation). True clean baseline is 184/184 GREEN; 188/188 after Plan 04-06's +4 new tests. Gates run: - npx tsc --noEmit exit 0. - npm run build:test exit 0; dist-test/assets/welcome-CMygHJ_J.js carries the raw SVG source. - HEADLESS=1 SKIP_PROD_REBUILD=0 SKIP_LONG_UAT=1 npm run test:uat: 36/36 UAT assertions GREEN (was 35/35; +A35). A17.8 PASS: currentColorStroke=true, canonicalViewBox=true. A35 live-DOM probe: svgPresent=true strokeAttr=currentColor computedStroke="rgb(250, 247, 241)" (linen-50, the --mks-fg-inverse value flowing through the cascade — the currentColor strategy WORKS in real Chrome) imgPresent=false. - All Task 3 acceptance greps PASS: driveA35 count in harness-page-driver.ts=5, in harness.test.ts=6; name:'A35'=1; getComputedStyle=6; stroke="currentColor" in extension-page-harness.ts=4; data:image/svg+xml=0 (grep target and comment refs both removed). References: - 04-06-PLAN.md iter-2 BLOCKER 1 + BLOCKER 2 resolutions. - .planning/phases/04-harden-clean-up-optional/04-UI-SPEC.md §"Implementation amendment" (Option A currentColor + inline-SVG).Per the orchestrator checkpoint protocol + the saved-memory feedback "trust harness over manual UAT", Task 4's dark-mode aesthetic judgment uses Puppeteer-produced screenshots (NOT a manual Chrome session). This script: 1. Loads dist/ via puppeteer.launch enableExtensions. 2. Resolves the runtime extension ID via the canonical browser.extensions() Map (mirrors tests/uat/lib/launch.ts resolveExtensionIdWithPolling). 3. Opens chrome-extension://<id>/src/welcome/welcome.html. 4. Captures the .welcome-hero bounding-box region in LIGHT surface (default OS appearance — the regression-baseline shot, matching the Plan 01-10 cycle-2 operator ack 2026-05-20). 5. Sets [data-theme="dark"] on <html> (Mokosh's tokens.css cascade uses the explicit .dark / [data-theme="dark"] selector at line 234; emulateMediaFeatures alone does NOT trigger it because tokens.css has no @media (prefers-color-scheme: dark) block — a fact verified live this session). emulateMediaFeatures is also set, forward-compatible with any future @media block. 6. Re-screenshot the hero region — the DARK-surface aesthetic shot. Output paths (canonical per the 04-06-PLAN Task 4 contract): - /tmp/04-06-welcome-hero-light.png - /tmp/04-06-welcome-hero-dark.png Run results (this session): - LIGHT: computed stroke = rgb(250, 247, 241) — linen-50; the --mks-fg-inverse value on the LIGHT cascade flowing through .welcome-hero__mark to the inline <svg>'s currentColor. - DARK: computed stroke = rgb(24, 27, 42) — ink-900; the --mks-fg-inverse value AFTER the .dark cascade override (tokens.css 244 sets --mks-fg-inverse: var(--mks-ink-900)) — the strategy's contrast flip is empirically verified. Implementation notes (deviation Rule 3 — observed environment constraints fixed inline): - Initial extension ID resolver used browser.targets() polling + regex; rewritten to use the canonical Puppeteer 22.x browser.extensions() Map approach. - Initial screenshot used ElementHandle.screenshot(); Puppeteer Runtime.callFunctionOn timed out on the second elementHandle evaluate in headless extension page context. Rewritten to a single page.evaluate() that returns getBoundingClientRect() + computedStroke in one CDP round trip, then page.screenshot({clip}) against those coordinates — succeeds reliably. - protocolTimeout set to 120s to match the UAT harness baseline. References: - .planning/phases/04-harden-clean-up-optional/04-06-PLAN.md Task 4. - tests/uat/lib/launch.ts (the canonical extension-loading pattern). - https://pptr.dev/api/puppeteer.browser.extensions - https://pptr.dev/api/puppeteer.page.screenshotOperator-empirical Task 4 checkpoint flagged the dark-mode mark stroke as muddy ink-on-madder. Root cause: .welcome-hero__mark used `color: var(--mks-fg-inverse)`, which is a SEMANTIC text-foreground-on- inverse-surface token that flips to ink-900 in the dark theme (tokens.css line 244). The mark sits on a theme-independent madder-600 circle, so the stroke must be theme-independent too. Fix: introduce a dedicated BRAND-COMPONENT token --mks-mark-stroke = var(--mks-linen-50) in the universal :root block. CRUCIALLY NOT overridden in the .dark/[data-theme="dark"] block — stays linen-50 on every surface. Rewire .welcome-hero__mark to point at the new token. SVG (mokosh-mark.svg) unchanged — `stroke="currentColor"` cascade plumbing identical; only the wrapper's color source changed. A35 strengthened: extracted live-DOM probe into a helper, now probes BOTH light + dark themes (data-theme="dark" toggle on documentElement), and added A35.5 — the decouple proof that light.computedStroke === dark.computedStroke === "rgb(250, 247, 241)" (linen-50). No new __MOKOSH_UAT__ symbol; FORBIDDEN_HOOK_STRINGS stays at 12. Scope expansion note: src/welcome/welcome.css was not in Plan 04-06 re-plan iter-2 files_modified. The edit is authorized by the operator's TWEAK verdict on Task 4 checkpoint. Verification: - /tmp/04-06-welcome-hero-{light,dark}.png re-shot — both show identical crisp linen-on-madder grid icon. - A35.5 LIVE-DOM probe (UAT): light="rgb(250, 247, 241)", dark=same. - UAT 36/36 GREEN; vitest 187 + 1 tolerated webm-remux flake. - 6/6 pre-checkpoint bundle gates PASS; FORBIDDEN_HOOK_STRINGS = 12. Debug session: .planning/debug/04-06-dark-mode-mark-decouple.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>