---
phase: 01-stabilize-video-pipeline
plan: 09
type: tdd
wave: 2
depends_on:
  - 01-08
files_modified:
  - manifest.json
  - src/offscreen/recorder.ts
  - src/background/index.ts
  - src/popup/index.ts
  - src/popup/index.html
  - tests/offscreen/display-surface-constraint.test.ts
  - tests/background/toolbar-action.test.ts
  - tests/background/badge-state-machine.test.ts
  - tests/background/onstartup-notification.test.ts
  - smoke.sh
autonomous: false
requirements:
  - REQ-video-ring-buffer
tags:
  - getDisplayMedia
  - displaySurface
  - toolbar
  - notifications
  - badge
  - state-machine
  - manifest

must_haves:
  truths:
    - "getDisplayMedia call in src/offscreen/recorder.ts carries video.displaySurface='monitor' and video.cursor='always' constraints (grep-verifiable)."
    - "After picker grant, recorder validates track.getSettings().displaySurface and emits RECORDING_ERROR code 'wrong-display-surface' when operator picks a tab or window."
    - "Clicking the toolbar icon while NOT recording triggers startVideoCapture via chrome.action.onClicked (no popup needed for start). Clicking while recording opens the popup for SAVE."
    - "Popup is SAVE-ONLY: no auto-start request on open; shows SAVE button when isRecording is true; empty-state copy directs operator to toolbar icon when not recording."
    - "chrome.runtime.onStartup fires chrome.notifications.create once per browser startup; chrome.notifications.onClicked drains the notification and triggers startVideoCapture (consuming the click as activation gesture)."
    - "chrome.action badge state machine: 3 states REC, OFF, ERROR with deterministic setBadgeText + setBadgeBackgroundColor + setTitle calls."
    - "onUserStoppedSharing transitions badge to OFF and emits a recovery notification inviting the operator to start again."
    - "manifest.json adds 'notifications' permission; default_popup retained for SAVE flow."
    - "smoke.sh updated for displaySurface monitor mode OR carries a documented limitation note that the auto-select tab targeting no longer applies."
  artifacts:
    - path: "src/offscreen/recorder.ts"
      provides: "getDisplayMedia carries displaySurface monitor and cursor always constraints; post-grant validation emits wrong-display-surface RECORDING_ERROR; CaptureErrorCode union extended with 'wrong-display-surface'."
      contains: "displaySurface"
    - path: "src/background/index.ts"
      provides: "chrome.action.onClicked handler routes by isRecording flag; chrome.runtime.onStartup creates notification; chrome.notifications.onClicked consumes activation and triggers startVideoCapture; badge state machine helper setBadgeState(state); onUserStoppedSharing recovery notification on RECORDING_ERROR message receipt."
      contains: "chrome.action.onClicked"
    - path: "src/popup/index.ts"
      provides: "Auto-start permission request removed from init; popup is SAVE-only; updated empty-state copy."
    - path: "src/popup/index.html"
      provides: "Empty-state copy may need a one-line text change to match SAVE-only role."
    - path: "manifest.json"
      provides: "Adds 'notifications' to permissions array; preserves existing permissions; default_popup retained."
      contains: "notifications"
    - path: "tests/offscreen/display-surface-constraint.test.ts"
      provides: "4 RED-to-GREEN tests pinning displaySurface monitor constraint applied + wrong-display-surface error emission + classifyCaptureError branch + happy-path monitor accepted."
    - path: "tests/background/toolbar-action.test.ts"
      provides: "Tests pinning chrome.action.onClicked routing: when isRecording false triggers startVideoCapture; when isRecording true does not double-start; setPopup dance gating onClicked vs popup-open."
    - path: "tests/background/badge-state-machine.test.ts"
      provides: "Tests pinning the 3-state badge contract REC/OFF/ERROR via setBadgeText + setBadgeBackgroundColor + setTitle."
    - path: "tests/background/onstartup-notification.test.ts"
      provides: "Tests pinning chrome.runtime.onStartup creates exactly one notification; chrome.notifications.onClicked clears the notification and triggers startVideoCapture; RECORDING_ERROR receipt emits recovery notification."
  key_links:
    - from: "src/offscreen/recorder.ts:startRecording"
      to: "navigator.mediaDevices.getDisplayMedia"
      via: "constraints object with displaySurface monitor and cursor always"
      pattern: "displaySurface.*monitor"
    - from: "src/background/index.ts chrome.action.onClicked handler"
      to: "startVideoCapture()"
      via: "if (!isRecording) await startVideoCapture()"
      pattern: "chrome.action.onClicked"
    - from: "src/background/index.ts chrome.runtime.onStartup handler"
      to: "chrome.notifications.create"
      via: "type basic; title 'Mokosh ready'; message instructs operator to click"
      pattern: "chrome.notifications.create"
    - from: "src/background/index.ts chrome.notifications.onClicked handler"
      to: "startVideoCapture()"
      via: "consumes notification activation as user gesture"
      pattern: "chrome.notifications.onClicked"
    - from: "setBadgeState helper"
      to: "chrome.action.setBadgeText + setBadgeBackgroundColor + setTitle"
      via: "called from startVideoCapture (REC), stopRecording/onUserStoppedSharing (OFF), RECORDING_ERROR handler (ERROR)"
      pattern: "setBadgeState"
---

## Scope Sanity Note (W-03 fix, 2026-05-16 checker pass)

**5 tasks + 9 files modified.** The plan-checker pass flagged that 5 tasks is the WARNING threshold (per the planner's <scope_estimation> guidance: "2-3 tasks max") and 9 files in `files_modified` is at the borderline.

**Why we accept the borderline rather than split:**

1. The three concerns (displaySurface constraint, toolbar-onClicked routing, badge+notification UX) are tightly coupled at the operator-experience level — splitting them across plans would force the operator-driven Task 5 checkpoint to be duplicated (one per sub-plan), tripling the operator's manual-test burden for no architectural benefit.
2. The 9 files are NOT uniformly heavy: manifest.json gains 1 line; smoke.sh changes 3 string literals + adds ~14 comment lines (per W-04 enumeration); src/popup/index.html may not change at all (Task 4 step 15 says "no markup change needed"). The genuinely heavy edits are concentrated in `src/background/index.ts` (~80 LOC of new helpers + listeners) and `src/offscreen/recorder.ts` (~12 LOC for the displaySurface constraint + post-grant validation block).
3. Context-cost estimate: Task 1 + Task 2 fit in ~25% context (small displaySurface change); Task 3 + Task 4 fit in ~40% context (the broader background + popup work). Task 5 is a checkpoint with no executor-side context cost. Total: ~50% — within the planner's quality-degradation-curve target of "complete within ~50% context."

**Per the planner's own guidance** ("<scope_estimation>: Split if any of these apply: more than 3 tasks, multiple subsystems"): the "split if multiple subsystems" trigger would normally apply (we touch offscreen + background + popup + manifest + smoke), BUT the per-subsystem cost is small enough that the unified plan stays within budget. The plan-checker's recommendation (a) — "accept as borderline; document rationale; do not duplicate the operator checkpoint" — is the path taken here.

**If a future revision DOES force a split,** the natural cut line is: Plan 01-09A = displaySurface constraint (Tasks 1+2 only — purely offscreen-recorder-side); Plan 01-09B = toolbar/badge/notification UX (Tasks 3+4 — background-side + popup + manifest). Task 5 (operator checkpoint) merges into 01-09B since the displaySurface change is verifiable via the unit tests in 01-09A alone.

<objective>
Reduce per-session activation cost and eliminate the operator-picks-tab footgun.

Per-session clicks drop from 3 (toolbar -> popup-open -> Start button -> picker) to 2 (toolbar -> picker accept). Active prompting via OS notification at Chrome startup makes recording state operator-visible without requiring them to remember to click anything.

Three coordinated changes:
1. displaySurface constraint in getDisplayMedia locks Chrome's screen-share picker to monitor selection (eliminates UAT Test 3 "Share this tab instead" footgun documented in 01-UAT.md gaps section, and lifts the Phase 5 cursor:'always' refinement opportunistically).
2. chrome.action.onClicked migration replaces popup-Start-button flow with direct toolbar-icon-click flow. Popup remains for SAVE only.
3. Notification + badge state machine surfaces recording state: onStartup notification invites a fresh-day start, badge color/text shows REC/OFF/ERROR at a glance, onUserStoppedSharing emits a recovery prompt.

Output:
- src/offscreen/recorder.ts updated getDisplayMedia call + new wrong-display-surface CaptureErrorCode branch.
- src/background/index.ts adds chrome.action.onClicked + chrome.runtime.onStartup -> notification + chrome.notifications.onClicked + badge helper + RECORDING_ERROR handler.
- src/popup/index.ts has start-on-open path removed; popup is SAVE-only; src/popup/index.html copy may need a one-line update.
- manifest.json adds notifications permission.
- 4 new test files covering each new contract.
- smoke.sh updated for the displaySurface monitor mode (or carries a documented limitation note).
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md
@.planning/phases/01-stabilize-video-pipeline/01-UAT.md
@.planning/phases/01-stabilize-video-pipeline/01-08-PLAN.md
@src/offscreen/recorder.ts
@src/background/index.ts
@src/popup/index.ts
@src/popup/index.html
@manifest.json
@smoke.sh

<interfaces>
Key types and Chrome API surfaces the executor will use.

From src/offscreen/recorder.ts current getDisplayMedia call (lines 213-216):
```
const stream = await navigator.mediaDevices.getDisplayMedia({
  video: true,
  audio: false,
});
```

New shape under this plan (D-15-display-surface):
```
const stream = await navigator.mediaDevices.getDisplayMedia({
  video: { displaySurface: 'monitor', cursor: 'always' },
  audio: false,
});
// Post-grant validation:
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack !== undefined) {
  const settings = videoTrack.getSettings();
  if (settings.displaySurface !== 'monitor') {
    // Operator overrode the hint and picked a tab or window.
    stream.getTracks().forEach((t) => { try { t.stop(); } catch (e) {} });
    mediaStream = null;
    throw new Error('wrong-display-surface: got "' + settings.displaySurface + '"');
  }
}
```

From src/offscreen/recorder.ts CaptureErrorCode union (lines 158-164) — extend with new code:
```
export type CaptureErrorCode =
  | 'user-cancelled'
  | 'permission-denied'
  | 'codec-unsupported'
  | 'no-source-selected'
  | 'capture-failed'
  | 'wrong-display-surface'   // NEW (Plan 01-09 D-15-display-surface)
  | 'unknown';
```

classifyCaptureError (line 166) gains one new branch — matched on Error message prefix:
```
if (error instanceof Error && error.message.startsWith('wrong-display-surface')) {
  return 'wrong-display-surface';
}
```

Chrome API: chrome.action.onClicked.addListener fires when the operator clicks the toolbar icon UNLESS a default_popup is set. Two options:
- Option A: remove default_popup entirely; onClicked fires on every click; handler opens popup programmatically via chrome.action.openPopup() when needed.
- Option B: dynamically swap setPopup ('' for start mode; 'src/popup/index.html' for save mode) so onClicked fires when popup is empty.

This plan uses Option B + setPopup:
```
function setIdleMode() {
  chrome.action.setPopup({ popup: '' });
  setBadgeState('OFF');
}
function setRecordingMode() {
  chrome.action.setPopup({ popup: 'src/popup/index.html' });
  setBadgeState('REC');
}
chrome.action.onClicked.addListener(async () => {
  // This fires only when setPopup('') i.e. OFF mode.
  if (!isRecording) {
    await startVideoCapture();
  }
});
```

Chrome API: chrome.notifications.create(notificationId, options, callback):
```
chrome.notifications.create('mokosh-startup-prompt', {
  type: 'basic',
  iconUrl: chrome.runtime.getURL('icons/icon128.png'),
  title: 'Mokosh ready',
  message: 'Click here to start recording your session.',
  priority: 1,
});
chrome.notifications.onClicked.addListener((notificationId) => {
  if (notificationId.startsWith('mokosh-')) {
    chrome.notifications.clear(notificationId);
    startVideoCapture().catch((err) => logger.warn('notification-triggered start failed:', err));
  }
});
```

Chrome API: badge surface — chrome.action.setBadgeText, setBadgeBackgroundColor, setTitle. Suggested palette:
- REC: background #00C853 (green), text 'REC', title 'Recording — last 30 s buffered. Click to save.'
- OFF: background #D32F2F (red), text '', title 'Not recording. Click to start.'
- ERROR: background #F9A825 (yellow), text 'ERR', title 'Recording error. Click to try again.'

Existing isRecording flag in src/background/index.ts line 36 is the source of truth the badge state machine reads.

Existing onMessage handler at line 631 routes RECORDING_ERROR (the offscreen recorder sends these). This plan extends it: on RECORDING_ERROR receipt set badge to ERROR and emit a recovery notification.

Existing popup at src/popup/index.ts lines 60-87 calls checkPermissions on init which triggers REQUEST_PERMISSIONS auto-start. The new design: checkPermissions is removed from init; popup just shows SAVE; empty-state copy in updateUI (line 51) becomes "Откройте запись через иконку расширения" (Open recording via the extension icon).

smoke.sh update path: Path A (preferred) updates auto-select string to target entire-screen ("Screen 1" or "Entire screen" — try both via fallback); Path B (fallback) documents a one-time manual step (KEEP_PROFILE=1 preserves picker memory across runs).

Existing patterns to reuse:
- Test scaffold: tests/background/request-id-protocol.test.ts (lines 53-120) shows the canonical pattern for a chrome stub with onConnect/onMessage/onInstalled callbacks plus dynamic import + await import. Extend with chrome.action, chrome.notifications, and badge methods.
- CaptureErrorCode extension pattern: tests/offscreen/codec-check.test.ts (lines 58-123) per-code-branch test idiom.

NO 'as any', NO @ts-ignore — every new chrome.* call must be properly typed via @types/chrome (already installed).
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: RED tests — displaySurface constraint applied to getDisplayMedia + wrong-display-surface error code.</name>
  <read_first>
    - src/offscreen/recorder.ts lines 158-202 (CaptureErrorCode union + classifyCaptureError)
    - src/offscreen/recorder.ts lines 206-245 (startRecording — the getDisplayMedia call site)
    - tests/offscreen/codec-check.test.ts (test scaffold: per-error-code branch tests)
    - tests/offscreen/handshake.test.ts (chrome stub pattern with sendMessage spy)
  </read_first>
  <files>tests/offscreen/display-surface-constraint.test.ts</files>
  <behavior>
    - Test 1: when getDisplayMedia is mocked, startRecording invokes it with constraints `{video: { displaySurface: 'monitor', cursor: 'always' }, audio: false}`. **I-03 fix (2026-05-16 checker pass): use strict deep-equality on the constraints object.** Assert via `expect(getDisplayMediaSpy).toHaveBeenCalledWith({video: { displaySurface: 'monitor', cursor: 'always' }, audio: false})` — partial match (`expect.objectContaining`) would UN-PIN the `cursor: 'always'` refinement that this plan opportunistically lifts from the Phase 5 deferral list per D-15-display-surface. If a future refactor accidentally drops `cursor: 'always'` while keeping `displaySurface: 'monitor'`, the partial-match form would silently pass; strict deep-equality fails loudly. RED today (current call passes `video: true`).
    - Test 2: when getDisplayMedia returns a stream whose video track getSettings reports displaySurface 'browser' (operator picked a tab), startRecording stops the stream tracks AND emits chrome.runtime.sendMessage with RECORDING_ERROR + wrong-display-surface code. RED today.
    - Test 3: when getDisplayMedia returns a stream whose track getSettings reports displaySurface 'monitor', startRecording proceeds without emitting a wrong-display-surface RECORDING_ERROR. (Ensures Task 2 does not over-fire.)
    - Test 4: classifyCaptureError on Error with message starting 'wrong-display-surface' returns 'wrong-display-surface'. RED today (union doesn't include this code).
  </behavior>
  <action>
    1. Create tests/offscreen/display-surface-constraint.test.ts.
    2. Mock navigator.mediaDevices.getDisplayMedia per the handshake.test.ts chrome-stub pattern. Build a MediaStreamStub that includes getVideoTracks returning a track with getSettings, stop (vi.fn), and addEventListener.
    3. Use vi.resetModules + dynamic await import per test (matches existing offscreen test discipline).
    4. For Test 1: spy on getDisplayMedia; trigger startRecording via the recorder's onMessage handler (send START_RECORDING through the registered listener captured via the chrome.runtime.onMessage.addListener stub). **I-03 fix:** assert the spy was called with the exact constraints object via strict deep-equality — `expect(getDisplayMediaSpy).toHaveBeenCalledWith({video: { displaySurface: 'monitor', cursor: 'always' }, audio: false})` — NOT `expect.objectContaining(...)`. The strict form pins both the `displaySurface: 'monitor'` (D-15-display-surface footgun retirement) AND the `cursor: 'always'` (Phase 5 deferral opportunistically lifted); partial-match would leave the latter unpinned.
    5. For Test 2: return a stream with displaySurface 'browser'; trigger START_RECORDING; await microtask drain (await Promise.resolve a few times). Assert the chrome.runtime.sendMessage stub received {type: 'RECORDING_ERROR', error: 'wrong-display-surface'}. Assert the stream's track stop was called for every track.
    6. For Test 3: return a stream with displaySurface 'monitor'. Trigger START_RECORDING. Assert NO RECORDING_ERROR with wrong-display-surface was sent. (MediaRecorder will throw because jsdom doesn't supply it; catch and ignore — this test only cares about whether wrong-display-surface fires, not whether startRecording completed.)
    7. For Test 4: await import the module; assert mod.classifyCaptureError on Error with 'wrong-display-surface' prefix returns 'wrong-display-surface'.
    8. Run the test file; expect ALL 4 RED with descriptive failures.
  </action>
  <verify>
    <automated>npx vitest run tests/offscreen/display-surface-constraint.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - File exists with 4 tests; all 4 RED.
    - Baseline (Plan 01-08 final 12 files / 60 tests / all GREEN) + 4 new RED here = 13 files / 64 tests / 4 failed | 60 passed.
    - tsc still exit 0.
  </acceptance_criteria>
  <done>4 RED tests pin the displaySurface contract; baseline preserved.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: GREEN — add displaySurface monitor + cursor always constraint and post-grant validation in src/offscreen/recorder.ts.</name>
  <read_first>
    - tests/offscreen/display-surface-constraint.test.ts (contract from Task 1)
    - src/offscreen/recorder.ts lines 158-202 (CaptureErrorCode union + classifyCaptureError)
    - src/offscreen/recorder.ts lines 206-245 (startRecording)
  </read_first>
  <files>src/offscreen/recorder.ts</files>
  <behavior>
    Drive the 4 RED tests from Task 1 to GREEN.
  </behavior>
  <action>
    1. In the CaptureErrorCode union (line 158), add a new line: '| wrong-display-surface'.
    2. In classifyCaptureError (line 166), add a branch near the top (after the instanceof Error guard, before the codec-unsupported check):
       if the error message startsWith 'wrong-display-surface', return 'wrong-display-surface'.
    3. Update the getDisplayMedia call in startRecording (lines 213-216) to pass {video: { displaySurface: 'monitor', cursor: 'always' }, audio: false}. Add a block comment citing Plan 01-09 D-15-display-surface (UAT Test 3 footgun retirement, disambiguated from the historical 'D-15: Operator switching tabs' decision per B-02 fix) AND lifting the Phase 5 cursor:'always' deferral opportunistically.
    4. Immediately after mediaStream = stream; add a post-grant validation block: read getVideoTracks()[0].getSettings().displaySurface; if not 'monitor' then call stream.getTracks().forEach(t => t.stop()), null out mediaStream, throw new Error(`wrong-display-surface: got "${observed}", expected "monitor"`). The throw routes through startRecording's existing catch block — classifyCaptureError maps the prefix; RECORDING_ERROR is broadcast; the throw propagates.
    5. Run npx vitest run tests/offscreen/display-surface-constraint.test.ts — all 4 must flip GREEN.
    6. Run full suite — 13 files / 64 tests / all GREEN.
    7. Run npx tsc --noEmit — exit 0.
    Per project style: extensive docstrings; the post-grant validation block gets a dedicated multi-line comment explaining the constraint-hint-vs-enforcement gap (Chrome's getDisplayMedia spec treats displaySurface as a HINT, not a hard constraint — the operator can override; post-grant validation is the actual enforcement). No 'as any'; the settings check uses the official MediaTrackSettings.displaySurface field (lib.dom.d.ts covers this).
  </action>
  <verify>
    <automated>npx vitest run tests/offscreen/display-surface-constraint.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - CaptureErrorCode union includes 'wrong-display-surface'.
    - getDisplayMedia call carries displaySurface 'monitor' and cursor 'always' (grep verifies).
    - Post-grant validation block exists and emits wrong-display-surface when picker yields non-monitor.
    - 4 tests from Task 1 all GREEN.
    - Full suite 13 files / 64 tests / all GREEN.
    - npx tsc --noEmit exit 0.
  </acceptance_criteria>
  <done>displaySurface contract pinned and enforced; per-error-code routing extended; baseline preserved.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: RED tests — chrome.action.onClicked routing + badge state machine + onStartup notification + RECORDING_ERROR badge transition.</name>
  <read_first>
    - tests/background/request-id-protocol.test.ts lines 53-200 (chrome stub pattern; extend it)
    - src/background/index.ts lines 631-687 (onMessage handler — where REQUEST_PERMISSIONS lives; understand the existing dispatch shape)
    - src/background/index.ts lines 689-737 (initialize + onInstalled — where new onStartup will sit)
    - .planning/phases/01-stabilize-video-pipeline/01-UAT.md gaps section (the displaySurface footgun + the operator-side ergonomics motivation)
  </read_first>
  <files>tests/background/toolbar-action.test.ts, tests/background/badge-state-machine.test.ts, tests/background/onstartup-notification.test.ts</files>
  <behavior>
    Three new test files. All tests start RED today.

    File 1 — tests/background/toolbar-action.test.ts:
    - Test A: chrome.action.onClicked.addListener is registered when the SW initializes.
    - Test B: when isRecording is false and onClicked fires, the handler invokes startVideoCapture (the existing startVideoCapture path — chrome.tabs.query then ensureOffscreen then send START_RECORDING — should be detectable via the chrome.runtime.sendMessage spy receiving START_RECORDING).
    - Test C: when isRecording is true (set via prior startVideoCapture call landing) and onClicked fires, the handler does NOT call startVideoCapture again (spy is called exactly once across both clicks). Defensive guard test.
    - Test D: setPopup is called with empty string when transitioning to OFF state; with 'src/popup/index.html' when transitioning to REC state.
    - **Test E (W-02 fix, 2026-05-16 checker pass) — popup-loads-when-idle race pin.** The checker flagged that Task 4 removes `checkPermissions` from `src/popup/index.ts` (lines 60-87) without any test asserting the post-removal behavior. Two assertions in this single test cover the change:
      (E1) "popup init no longer fires `chrome.runtime.sendMessage` with `type: 'REQUEST_PERMISSIONS'`" — mock `globalThis.chrome.runtime.sendMessage = vi.fn().mockResolvedValue({granted: false})`; load `src/popup/index.ts` via dynamic `await import()` inside a JSDOM context with a stub `<body>` that includes `<button id="saveButton"><span class="button-text"></span></button>` and `<div id="statusMessage">`; await microtask drain (`await Promise.resolve()` ×3); assert `sendMessageSpy` was NOT called with an argument matching `expect.objectContaining({type: 'REQUEST_PERMISSIONS'})`. RED today because the current popup init at line 60-87 + line 167 (`init()` calls `checkPermissions()`) fires REQUEST_PERMISSIONS on every popup open. GREEN after Task 4 step 14 deletes the `checkPermissions()` call from `init()`.
      (E2) "`saveButton.disabled === false` after popup init completes, regardless of any chrome message round-trip" — under the new SAVE-only popup charter (the popup ONLY opens when `isRecording === true` because `setRecordingMode` swaps `setPopup` to point at `src/popup/index.html`; in OFF mode `setPopup('')` causes the toolbar click to fire `onClicked` instead of opening the popup), the popup loading IMPLIES recording is active, so the SAVE button must be enabled unconditionally. After loading the popup module under JSDOM, query `document.querySelector('#saveButton')` and assert `(button as HTMLButtonElement).disabled === false`. RED today because the current `updateUI()` at line 33 gates `saveButton.disabled = !popupState.hasPermissions`, and `popupState.hasPermissions` defaults to false at init. GREEN after Task 4 step 14 also lands the `popupState.hasPermissions = true` unconditional set on popup init (per the action step's "Simplify: on popup load, ALWAYS enable the SAVE button" guidance).
      Note: this test file's chrome stub must additionally provide `chrome.runtime.id`, `chrome.runtime.onMessage.addListener`, and an empty `chrome.action`/`chrome.notifications` shape if the popup module's strict-mode access patterns reach them; copy the minimal shape from the existing popup tests (none exist yet, so this is the first pop-up test — start with the chrome stub from `tests/background/request-id-protocol.test.ts` and PARE DOWN to only what the popup module actually touches via grep). The JSDOM environment is supplied by Vitest's default config; if not, override via `// @vitest-environment jsdom` pragma at the top of the test file.

    File 2 — tests/background/badge-state-machine.test.ts:
    - Test A: setBadgeState('REC') calls chrome.action.setBadgeText with text 'REC' AND setBadgeBackgroundColor with the green color AND setTitle.
    - Test B: setBadgeState('OFF') calls setBadgeText with empty text AND setBadgeBackgroundColor with the red color AND setTitle.
    - Test C: setBadgeState('ERROR') calls setBadgeText with 'ERR' AND setBadgeBackgroundColor with yellow AND setTitle.
    - Test D: receiving a RECORDING_ERROR message via chrome.runtime.onMessage triggers setBadgeState('ERROR') (asserted via the setBadgeText spy receiving 'ERR' within a microtask of the dispatch).

    File 3 — tests/background/onstartup-notification.test.ts:
    - Test A: chrome.runtime.onStartup.addListener is registered when the SW module loads.
    - Test B: firing the onStartup callback creates exactly one chrome.notifications.create call with notificationId starting 'mokosh-startup-' (or similar stable prefix), with type 'basic', title 'Mokosh ready', message containing 'Click'.
    - Test C: firing chrome.notifications.onClicked with a 'mokosh-startup-...' id triggers chrome.notifications.clear(id) AND invokes startVideoCapture (assert via sendMessage spy receiving START_RECORDING after microtask drain).
    - Test D: receiving a RECORDING_ERROR message triggers a recovery notification (a second chrome.notifications.create call with id prefix 'mokosh-recovery-...').
  </behavior>
  <action>
    1. Copy the chrome stub from tests/background/request-id-protocol.test.ts as a starting point. Extend it with:
       - chrome.action: { onClicked: { addListener, _callbacks }, setPopup: vi.fn(), setBadgeText: vi.fn(), setBadgeBackgroundColor: vi.fn(), setTitle: vi.fn() }
       - chrome.notifications: { create: vi.fn(), clear: vi.fn(), onClicked: { addListener, _callbacks } }
       - chrome.runtime.onStartup: { addListener, _callbacks }
       Build each file's chrome stub by importing a shared builder helper if convenient; OR copy the stub into each test file (the existing tests duplicate the stub pattern, so duplication is acceptable per project convention).
    2. Author each test per the behavior list. For tests that drive flows (Test C in file 1, Test C in file 3): set up the stub, register expected listeners, then synthesize the trigger by invoking the captured callback array directly (e.g. chromeStub.action.onClicked._callbacks[0]()).
    3. For Test D in file 2 and Test D in file 3: invoke the onMessage callback via chromeStub.runtime.onMessage._callbacks[0]({type:'RECORDING_ERROR', error:'codec-unsupported'}, {id:'ext-id-test'}, vi.fn()); await microtask drain; assert downstream effects.
    4. Run each test file individually; all should be RED with errors like "expected setBadgeText to have been called" or "expected chrome.notifications.create to have been called once".
    5. DO NOT modify src/background/index.ts yet — Task 4 is the GREEN side.
  </action>
  <verify>
    <automated>npx vitest run tests/background/toolbar-action.test.ts tests/background/badge-state-machine.test.ts tests/background/onstartup-notification.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - 3 new test files exist with the listed tests (13 tests total: 5+4+4 — toolbar-action gains Test E per W-02 fix).
    - All 13 are RED today.
    - Baseline (after Task 2: 13 files / 64 tests / 0 RED) + 13 new RED = 16 files / 77 tests / 13 failed | 64 passed.
    - tsc exit 0.
  </acceptance_criteria>
  <done>13 RED tests pinning toolbar (5: 4 routing + 1 popup-idle-race per W-02) + badge (4) + notification (4) contracts; baseline preserved.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 4: GREEN — add chrome.action.onClicked + badge state machine + onStartup notification + RECORDING_ERROR handler in src/background/index.ts; popup transitions to SAVE-only.</name>
  <read_first>
    - tests/background/toolbar-action.test.ts, tests/background/badge-state-machine.test.ts, tests/background/onstartup-notification.test.ts (contracts from Task 3)
    - src/background/index.ts (whole file — already familiar)
    - src/popup/index.ts (lines 60-115 — the checkPermissions + requestPermissions auto-init flow to be removed)
    - src/popup/index.html (the markup — likely no change needed)
    - manifest.json (to add notifications permission)
  </read_first>
  <files>src/background/index.ts, src/popup/index.ts, src/popup/index.html, manifest.json</files>
  <behavior>
    Drive the 13 RED tests to GREEN (per W-02 fix the popup-idle-race test joins toolbar-action.test.ts).
  </behavior>
  <action>
    1. manifest.json: add 'notifications' to the permissions array. Preserve all existing permissions. Keep default_popup pointing to src/popup/index.html.
    2. src/background/index.ts top-level state additions (after isRecording line 36):
       - Constants for badge palette:
         BADGE_REC_COLOR = '#00C853', BADGE_OFF_COLOR = '#D32F2F', BADGE_ERROR_COLOR = '#F9A825'
         BADGE_REC_TEXT = 'REC', BADGE_OFF_TEXT = '', BADGE_ERROR_TEXT = 'ERR'
         BADGE_REC_TITLE = 'Recording — last 30 s buffered. Click to save.'
         BADGE_OFF_TITLE = 'Not recording. Click to start.'
         BADGE_ERROR_TITLE = 'Recording error. Click to try again.'
         NOTIFICATION_ICON_PATH = 'icons/icon128.png' (already in manifest)
         NOTIFICATION_STARTUP_PREFIX = 'mokosh-startup-'
         NOTIFICATION_RECOVERY_PREFIX = 'mokosh-recovery-'
       - Note in comments that the badge state machine is the operator-facing surface for recording state; per project naming use SCREAMING_SNAKE for these true constants.
    3. setBadgeState(state: 'REC'|'OFF'|'ERROR'): void helper — calls chrome.action.setBadgeText, setBadgeBackgroundColor, setTitle with the three values per the palette table. Wrap each chrome call in try/catch (the SW context normally has chrome.action available but unit tests may not stub all methods atomically — defense in depth).
    4. setIdleMode() helper: chrome.action.setPopup({popup: ''}); setBadgeState('OFF');
    5. setRecordingMode() helper: chrome.action.setPopup({popup: 'src/popup/index.html'}); setBadgeState('REC');
    6. setErrorMode() helper: chrome.action.setPopup({popup: 'src/popup/index.html'}); setBadgeState('ERROR'); (popup remains accessible so operator can see any error copy AND attempt save anyway).
    7. startVideoCapture (line 305) — at the end of the try block (after isRecording = true; line 342), call setRecordingMode().
    8. startVideoCapture catch block (line 345) — call setErrorMode() before re-throwing.
    9. Register chrome.action.onClicked at SW module load (near the bottom of the file, before the initialize() call):
       chrome.action.onClicked.addListener(async () => {
         if (isRecording) return;
         try { await startVideoCapture(); } catch (err) { logger.warn('toolbar-onClicked start failed:', err); }
       });
       Document inline that this listener fires ONLY when setPopup is '' (idle mode); setRecordingMode swaps the popup back so subsequent clicks open the popup for SAVE.
    10. Register chrome.runtime.onStartup near initialize:
       chrome.runtime.onStartup.addListener(() => {
         setIdleMode();
         const notificationId = NOTIFICATION_STARTUP_PREFIX + Date.now();
         chrome.notifications.create(notificationId, {
           type: 'basic',
           iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
           title: 'Mokosh ready',
           message: 'Click here to start recording your session.',
           priority: 1,
         });
       });
    11. Register chrome.notifications.onClicked:
       chrome.notifications.onClicked.addListener((notificationId) => {
         if (!notificationId.startsWith('mokosh-')) return;
         chrome.notifications.clear(notificationId);
         startVideoCapture().catch((err) => logger.warn('notification-triggered start failed:', err));
       });
    12. Extend onMessage handler (line 631) with a new RECORDING_ERROR branch:
       case 'RECORDING_ERROR':
         logger.warn('RECORDING_ERROR received:', message); // do not destructure data — types use unknown
         setErrorMode();
         const recoveryId = NOTIFICATION_RECOVERY_PREFIX + Date.now();
         chrome.notifications.create(recoveryId, {
           type: 'basic', iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
           title: 'Mokosh stopped', message: 'Recording stopped. Click here to start a new session.',
           priority: 1,
         });
         return false;
    13. In initialize() (line 690), at the end, call setIdleMode() — ensures fresh SW always boots into the OFF badge state and idle popup (popup='') so chrome.action.onClicked fires on first click.
    14. src/popup/index.ts (line 60 onward): REMOVE checkPermissions function entirely. REMOVE the call to checkPermissions() in init() (line 167). The popup now opens; if popupState.hasPermissions is false (which it always will be on open since we never set it), the SAVE button stays disabled with the empty-state copy. UPDATE the empty-state copy in updateUI (line 51) to: 'Откройте запись через иконку расширения' (Open recording via the extension icon).
       Actually re-think: with setRecordingMode active, the popup ONLY opens when isRecording is true (because in idle mode setPopup('') causes onClicked to fire instead). So when the popup loads, we KNOW isRecording is true (or the user got here through some odd race). Simplify: on popup load, ALWAYS enable the SAVE button (set hasPermissions = true unconditionally on init). Drop the entire requestPermissions function too.
    15. src/popup/index.html: no markup change needed (the SAVE button works as-is); but consider updating the sub-label copy "Последние 30 сек видео + 10 мин лога" — it stays the same since it accurately describes SAVE.
    16. **W-04 fix (2026-05-16 checker pass): smoke.sh update for monitor-mode picker — enumerate ALL five sub-steps** (the SHARE_TARGET infrastructure is more entangled than the prior single-line summary suggested; see `smoke.sh` lines 44, 109-113, 133 for the affected sites):

        **16.a — Change the `SHARE_TARGET` variable value at line 44.**
        ```
        # smoke.sh line 44 (was):
        SHARE_TARGET="Mokosh Smoke Test"
        # smoke.sh line 44 (after Plan 01-09):
        SHARE_TARGET="Entire screen"
        ```
        This variable is interpolated into line 133's `--auto-select-desktop-capture-source="${SHARE_TARGET}"` flag — changing the variable itself (NOT just the flag arg as a one-off literal) keeps the data-flow honest.

        **16.b — Update the `data:` URL `<title>` element at line 107 to match.**
        The HTML stub embedded in `smoke.sh` (the heredoc starting at line 106) currently begins with `<title>Mokosh Smoke Test</title>`. The `--auto-select-desktop-capture-source` Chromium flag historically matched against tab titles; under Plan 01-09 we want it to match the screen-source label instead. To keep the smoke tab's title from accidentally matching the new "Entire screen" target string, change the title to something distinct:
        ```
        <title>Mokosh Smoke Test — monitor mode</title>
        ```
        (Distinct enough that even if Chrome falls back to title-matching in some edge case, it does NOT match.)

        **16.c — Update the on-screen instruction list in the same `data:` URL HTML.**
        Lines 109-113 (the `<ol>` list inside the heredoc) currently say "The picker auto-accepts THIS tab". Under Plan 01-09 the picker auto-accepts the entire screen instead. Update the list-item copy:
        ```
        <li>The picker auto-accepts <em>the entire screen</em> (not this tab — the new D-15-display-surface charter constrains to monitor mode).</li>
        ```
        Also update any text that says "this tab is the share-screen target" near the top of the body to "this tab is informational; the picker shares the whole monitor".

        **16.d — Document the empirical-test fallback for non-English Chrome locales.**
        Add a comment block above line 44 explaining the fallback mode:
        ```
        # Plan 01-09 D-15-display-surface: SHARE_TARGET must match the OS-locale-specific
        # name Chrome's picker uses for entire-screen selection. Known strings:
        #   English: "Entire screen"
        #   Russian: "Весь экран"
        #   German:  "Gesamter Bildschirm"
        #   French:  "Ecran entier"
        # If --auto-select-desktop-capture-source="${SHARE_TARGET}" fails to auto-accept
        # on the operator's Chrome locale, the operator picks the screen manually one
        # time; then KEEP_PROFILE=1 on subsequent runs carries the picker's last-pick
        # memory across re-runs, sidestepping the auto-select string altogether.
        ```

        **16.e — Cite the authoritative source for the locale-specific string.**
        The picker string is locale-bound. **Authoritative source:** Chromium's grit resource definitions in `chrome/app/generated_resources.grd` (and per-locale `.xtb` translation files under `chrome/app/resources/`) — IDS_* identifiers like `IDS_DESKTOP_MEDIA_PICKER_SOURCE_TYPE_SCREEN` live in `.grd` (grit resource definition), not in `.cc` source. The previous draft cited `content/browser/media/desktop_capture_device_uma_types.cc` — that file contains UMA enums, not localized strings; **the citation was wrong, dropped here.** However the only reliable empirical check is to run the picker once and inspect the source label in Chrome's UI: per-locale translations drift across Chrome major versions and an offline `.grd` lookup is fragile. Document the operator's Chrome locale + version next to the SHARE_TARGET literal so future operators on different locales can update without surprise. Recommend the executor test the smoke harness against the operator's actual Chrome locale + version BEFORE committing the SHARE_TARGET value; if uncertain, default to the operator's locale and document the manual-pick fallback in the commit message body.

        Net diff: smoke.sh adds ~14 lines of comments + changes 3 string literals (SHARE_TARGET value, `<title>` text, `<ol>` copy). No behavioral change to the polling / Downloads-snapshot / ffprobe-gate logic.
    17. Run the 3 new test files; all 12 must GREEN.
    18. Run full suite — 16 files / 77 tests / all GREEN.
    19. Run npx tsc --noEmit — exit 0.
    20. Run npm run build — exit 0; confirm dist/manifest.json has notifications permission.
    Naming compliance: setBadgeState, setIdleMode, setRecordingMode, setErrorMode — camelCase verbs; NOTIFICATION_* and BADGE_* constants — SCREAMING_SNAKE; isRecording remains the boolean naming standard. No 'continue' statements; if-else chains for the badge-state switch. No 'as any'.
  </action>
  <verify>
    <automated>npx vitest run tests/background/toolbar-action.test.ts tests/background/badge-state-machine.test.ts tests/background/onstartup-notification.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - manifest.json permissions array includes 'notifications'.
    - src/background/index.ts has setBadgeState, setIdleMode, setRecordingMode, setErrorMode helpers; chrome.action.onClicked + chrome.runtime.onStartup + chrome.notifications.onClicked listeners; RECORDING_ERROR onMessage branch; initialize() calls setIdleMode().
    - src/popup/index.ts no longer auto-requests permissions on init; popup is SAVE-only.
    - smoke.sh updated for monitor mode OR carries documented limitation note.
    - All 13 new tests GREEN.
    - Full suite 16 files / 77 tests / all GREEN.
    - npx tsc --noEmit exit 0.
    - npm run build exit 0.
  </acceptance_criteria>
  <done>Toolbar + badge + notification UX live; popup is SAVE-only (with W-02 test pinning the empty-REQUEST_PERMISSIONS init + always-enabled SAVE button); manifest updated; baseline preserved.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 5: Operator runs smoke.sh against the post-Plan-01-09 build; confirms toolbar-click recording flow + monitor-only picker + notification + badge state machine work end-to-end.</name>
  <files>(operator-driven; no specific source file modified by this checkpoint)</files>
  <action>See <how-to-verify> below — operator-driven empirical check; the executor agent must not bypass this checkpoint by stubbing.</action>
  <verify>
    <automated>echo "checkpoint:human-verify — see how-to-verify section; resume signal is the gate"</automated>
  </verify>
  <done>Operator types "approved" after running the how-to-verify steps. See <resume-signal> for the exact gate.</done>
  <what-built>
    Tasks 1-4 landed: getDisplayMedia constrained to monitor mode (UAT Test 3 footgun retired), chrome.action.onClicked direct toolbar flow live, chrome.notifications onStartup + recovery emitting, badge state machine REC/OFF/ERROR wired, popup is SAVE-only, manifest carries notifications permission. The 13 new unit tests are GREEN. This checkpoint validates the runtime end-to-end behavior in real Chrome.
  </what-built>
  <how-to-verify>
    1. Build: npm run build (must exit 0).
    2. Run smoke: KEEP_PROFILE=0 ./smoke.sh.
    3. After Chrome launches, Load Unpacked → select dist/. Observe the extension toolbar icon. Badge should display OFF state (no text, red background, tooltip 'Not recording. Click to start.').
    4. Restart Chrome via chrome://restart (or close and re-launch the smoke profile). On startup, an OS notification 'Mokosh ready — Click here to start recording your session.' should appear within ~2 seconds.
    5. Click the notification. Chrome's screen-share picker should appear. Confirm it offers ONLY entire-screen / monitor options (no tab list, no window list — or at least confirm tabs/windows are not the default selection).
    6. Pick 'Entire screen' (or 'Screen 1' depending on Chrome build) and accept. The 'Sharing your screen' banner appears.
    7. Observe the badge transitions to REC (green background, 'REC' text, tooltip 'Recording — last 30 s buffered. Click to save.').
    8. Wait ~35 seconds (or longer to validate the post-Option-C port lifecycle still holds).
    9. Click the toolbar icon. The popup opens (NOT the picker — because setRecordingMode set the popup to 'src/popup/index.html'). Click 'Сохранить отчёт об ошибке'. The save flow runs; the zip lands in ~/Downloads as session_report_*.zip.
    10. Open the zip; confirm video/last_30sec.webm is present and (validated by Plan 01-08) plays for ~30s in Chrome and mpv.
    11. Stop the sharing via Chrome's 'Stop sharing' banner button. Badge transitions to OFF ('OFF' text, neutral background — user stopped deliberately, this is not an error). NO recovery notification fires (would be noise for a deliberate stop; the OS 'Sharing your screen' indicator going away is the operator's confirmation). Popup is cleared so toolbar.onClicked re-fires for restart. (AMENDED 2026-05-17 post-Bug B: prior expectation routed user-stopped-sharing through ERROR state which locked operator out — see .planning/debug/resolved/01-09-recovery-flow.md.)
    12. Click the toolbar icon (no notification to click since stop was deliberate). The picker reappears; pick Entire screen; recording resumes; badge returns to REC.
    13. Edge-case: click the toolbar icon WITHOUT a prior recording (after step 3 + reload extension without restart). The picker should appear directly (NOT the popup). This validates that initialize → setIdleMode set the popup to '' so onClicked fires.
    14. If any of steps 4 (notification appears), 5 (picker is monitor-only), 9 (popup opens on toolbar click while recording), 11 (badge transitions to OFF after deliberate stop — NOT ERROR; no recovery notification), 12 (post-stop toolbar click triggers picker for restart), or 13 (idle toolbar click triggers picker directly) FAIL, document the exact failure mode + reproduction steps. The plan iterates on the failing handler.
    15. ERROR state coverage (separate from 11-12): genuine recording failures (codec init failure, port disconnect, etc.) still route through setErrorMode + recovery notification — that path is preserved by Bug B fix as the defensive fallback. No operator step here unless a real error surfaces during the smoke; if it does, document it.
  </how-to-verify>
  <resume-signal>
    Type 'approved' after steps 1-13 all PASS. If any step fails, paste the failure diagnostic + your Chrome version + locale + whether KEEP_PROFILE was used; Task 4 iterates.
  </resume-signal>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| OS notification system <-> Chrome SW | NEW: chrome.notifications.* relies on the OS notification center. Misuse risks notification spam. Mitigation: notification ids are prefix-scoped ('mokosh-startup-', 'mokosh-recovery-') and we drain on click; we never create more than one notification per event class per second. |
| Popup activation gesture | UNCHANGED: getDisplayMedia requires a user-gesture; previously the popup-button-click provided it. Now chrome.action.onClicked and chrome.notifications.onClicked both provide it per Chrome's documented behavior (toolbar clicks and notification clicks are activation-bearing). |
| operator-controllable picker (displaySurface hint) | NEW HARDENING: a tab or window pick no longer reaches recording — track validation enforces monitor and tears down the stream + surfaces RECORDING_ERROR. |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-09-01 | Spoofing | chrome.notifications.onClicked accepting any notificationId | mitigate | Handler validates notificationId.startsWith('mokosh-') before any side effect. Foreign extensions cannot inject our prefixes (chrome.notifications IDs are per-extension-scoped). |
| T-1-09-02 | Tampering | operator-overridden displaySurface yielding tab/window capture | mitigate | Task 2 post-grant validation reads track.getSettings().displaySurface and aborts if not monitor; partial-screen capture cannot reach the buffer. |
| T-1-09-03 | Information Disclosure | recording the operator's whole desktop captures more sensitive content than tab mode would | accept | The whole-desktop constraint is the EXPLICIT user-requested charter (Plan 01-09 must-haves). Operator-facing UX surfaces the recording state via Chrome's permanent 'Sharing your screen' indicator + new REC badge + notifications; operator can stop sharing at any time. |
| T-1-09-04 | Denial of Service | repeat-firing of RECORDING_ERROR creating notification spam | mitigate | Recovery notification has Date.now()-suffixed id, so chrome.notifications.create coalesces if the OS notification center deduplicates by title; even worst-case (3 RECORDING_ERRORs in a row) produces 3 notifications which is operator-actionable, not spam. A future Phase 5 hardening can debounce; out of scope here. |
| T-1-09-05 | Elevation of Privilege | dynamic setPopup race — popup loads with isRecording false, attempts SAVE, ships empty archive | mitigate | Save path's existing EmptyVideoBufferError (Option C, D-17-port-lifecycle amendment) surfaces the error to the popup which displays it; the operator does not silently receive an empty archive. The setPopup dance is best-effort UX; the save-path correctness gate is the EmptyVideoBufferError throw. |
</threat_model>

<verification>
- npx vitest run shows 16 files / 77 tests / all GREEN.
- npx tsc --noEmit exit 0.
- npm run build exit 0; dist/manifest.json carries 'notifications' permission.
- Operator empirical: notification appears at startup; picker is monitor-only; toolbar click in idle triggers picker; toolbar click in recording opens popup for SAVE; badge transitions REC/OFF/ERROR observed; recovery notification fires on stop-sharing.
- grep -n "displaySurface: 'monitor'" src/offscreen/recorder.ts returns at least one match.
- grep -n "chrome.action.onClicked" src/background/index.ts returns at least one match.
- grep -n "chrome.notifications.create" src/background/index.ts returns at least one match.
- grep -n "chrome.runtime.onStartup" src/background/index.ts returns at least one match.
- grep -n "notifications" manifest.json returns at least one match within the permissions array.
</verification>

## Amendment 2 (2026-05-19) — Closure via Plan 01-13 Harness

**Closure gate change.** Task 5's 15-step operator-driven empirical checkpoint is superseded as the primary closure gate by Plan 01-13's Puppeteer-based UAT harness (`npm run test:uat`). At commit `d793c9e` (Plan 01-13 Wave 3D) all 14 harness assertions are GREEN; the harness drives Chrome end-to-end via an extension-internal page (`tests/uat/extension-page-harness.html`) with synthetic `getDisplayMedia` + offscreen test hooks, and exercises every functional contract Task 5 originally verified manually. See `.planning/phases/01-stabilize-video-pipeline/01-13-SUMMARY.md` (written post-Task-9 of Plan 01-13) for the assertion-to-contract mapping.

**Coverage map — original Task 5 steps now covered by `npm run test:uat`:**

| Task 5 step | Original manual check | Harness assertion |
|-------------|----------------------|-------------------|
| Step 4 | onStartup notification fires within ~2 s | A8 (Bug A canonical: chrome.notifications.create called once with valid icon) |
| Step 5 | Picker is monitor-only (entire-screen) | A3 (displaySurface === 'monitor' on captured track via synthetic stream) |
| Step 7 | Badge transitions to REC on START_RECORDING | A2 (chrome.action badge text === 'REC' after toolbar onClicked path) |
| Step 8 | Buffer holds last 30 s across continuous capture | A11 (35 s synthetic playback yields ≥ 3 self-contained segments via offscreen test hook) |
| Step 9 | Popup opens (NOT picker) on toolbar click while recording; SAVE produces zip | A4 + A5 (setPopup pointing at SAVE-only popup during isRecording; SAVE_ARCHIVE → chrome.downloads zip) |
| Step 10 | Zip contains video/last_30sec.webm that plays end-to-end | A12 + A13 (ffprobe-via-host on extracted webm; zip structure + meta.json schema validation) |
| Step 11 | Stop-sharing → badge OFF, NO recovery notification (Bug B fix) | A6 (Bug B canonical: track.dispatchEvent('ended') → badge='', notification count delta = 0) |
| Step 12 | Post-stop toolbar click re-triggers picker | A6 indirectly + A4 (setPopup back to '' after stop puts onClicked in path) |
| Step 13 | Idle toolbar click triggers picker directly | A1 (SW bootstraps with setIdleMode → setPopup('')); A2 covers the click → REC path |
| Step 15 | ERROR state + recovery notification for genuine error codes | A7 (synthetic RECORDING_ERROR with non-user-stopped code → ERR badge + recovery notification create) |

Original Task 5 step 6 (manual picker dismiss + accept) is a UI-handler step subsumed by A3's synthetic-stream bypass — the picker UI itself is not the contract; the post-grant `displaySurface === 'monitor'` validation is, and A3 pins it.

**Operator steps retained:**

The harness cannot verify two things, both retained as operator-facing gates:

1. **Step 1 (build verification)** — `npm run build` must exit 0 against the operator's local toolchain (Node version, npm registry availability, etc.). The harness assumes `dist/` exists; this step is the build-environment sanity check.
2. **Step 14 (brand / design check)** — Plan 01-13's charter explicitly does NOT cover visual / brand / typography correctness (notification iconography, badge color contrast against the operator's OS theme, popup typography rendering on the operator's locale). This is the operator-only gate per Plan 01-13's scope. The operator visually inspects the loaded extension on their actual Chrome + OS combination and confirms the brand presentation matches the design intent.

**What Plan 01-09 closure now requires:**

- `npm run build` exit 0 (operator's local sanity check — Step 1 retained).
- `npm run test:uat` exit 0 with 14/14 GREEN (replaces operator Steps 4, 5, 7-13, 15 — the functional contract).
- Operator brand/design ack on the loaded extension (Step 14 retained as the operator gate Plan 01-13 carves out).

The historical 15-step empirical checkpoint (lines 519-548 above) remains in this document as the reference for the original closure intent, and as a fallback if the harness toolchain becomes unavailable (e.g., Puppeteer breakage on a future Chrome major where the harness needs revision).

## Amendment 3 (2026-05-19) — Save-Does-Not-Stop-Recording (Reverses Save-Stops)

Per operator UX preference iteration (2026-05-19), the prior save-stops
behavior (Amendment 2's harness-PASS-as-closure-gate validated save-stops
via A14) is REVERSED. Charter clarified:

**Recording is continuous; SAVE creates a new zip but does NOT stop the
recorder.** The only termination paths are:
1. Operator clicks Chrome's "Stop sharing" banner button →
   user-stopped-sharing path → setIdleMode (Bug B routing preserved)
2. Browser closes (SW terminates; offscreen tears down with it)
3. Extension uninstalled

Matches SPEC's "continuous capture / always-on safety net" framing
(Тз расширение фаза1.md). Operator workflow: hit bug → save → continues
working → next bug also captured.

Implementation: SAVE_ARCHIVE handler's `finally` block (Amendment 2's
STOP_RECORDING + setIdleMode dispatch) REMOVED. See debug session
`.planning/debug/resolved/01-09-save-does-not-stop-recording.md`.

Test inversions lock the new charter: `tests/background/save-archive-does-not-stop-recording.test.ts`
(renamed via `git mv` + inverted, history preserved) and Plan 01-13
harness A14 (inverted to assert continuous-recording post-SAVE).

<success_criteria>
Plan 01-09 is complete when:
1. The 4 displaySurface tests + 13 toolbar/badge/notification/popup-idle-race tests (17 new total) are all GREEN.
2. All 60 baseline GREEN tests from Plan 01-08 remain GREEN.
3. Operator runs the Task 5 checkpoint and confirms the end-to-end UX works: idle-toolbar-click triggers picker; recording-toolbar-click opens popup; onStartup notification appears; deliberate stop-sharing transitions badge → OFF (not ERROR) with NO recovery notification (per Bug B fix — user-stopped-sharing is a normal lifecycle event); operator can restart via direct toolbar click; ERROR state + recovery notification still trigger for genuine error codes.
4. manifest.json + smoke.sh + popup updated consistently with the new UX charter.
5. tsc + build clean.
</success_criteria>

<output>
After completion, create .planning/phases/01-stabilize-video-pipeline/01-09-SUMMARY.md per the standard template. Cite: the 17 new tests landed GREEN (4 displaySurface + 5 toolbar-action including W-02 popup-idle-race + 4 badge + 4 notification); manifest permission delta; new chrome.action / chrome.notifications listener registrations; popup role transition (start-on-open removed); smoke.sh updated for monitor-only picker; Task 5 operator-verification result.
</output>
