---
title: Plain Speak — implementation notes (part 4)
---

# Plain Speak — implementation notes (part 4)

## What it is

This is part 4 of the [Plain Speak](plain-speak.md) page — the internals, for a reader who has the repository open.

## Where to find it

Agent- and developer-facing: read from the code rather than from the app.

## How it behaves

### Limits and known gaps

Plain Speak v1 is intentionally minimal. The following are explicitly **not** in scope and may show up in later versions:

- **No project-level or session-level overrides — only global.** The daily cap is global to your Omniscio install, and the rule prompt is locked to the built-in default (not editable as of 2026-05-30 — see _Prompt: locked to the built-in default_). You can't configure a different rewrite style for project A vs. project B, and (as of 2026-05-14) there is no per-session opt-out — the only knobs are the global master enable and the global daily cap.
- **Skipped messages are not surfaced.** When Plain Speak skips a message (under 80 characters, or the defensive `ORIGINAL_ONLY` backstop fires), the original markdown renders normally with no visible "we considered rewriting this and decided not to" indicator. The skip is recorded in the decisions log but not on the message itself.

## For agents

### Implementation notes

For agents working in the repo:

- **Pipeline dispatch.** [`overlay-evaluator.ts`](../../src/main/services/ai-manager/overlay-evaluator.ts) is a thin dispatcher: it lazily imports the pipeline map and calls `resolvePipeline(DEFAULT_PIPELINE_ID)` from [`cascade/pipeline-map.ts`](../../src/main/services/ai-manager/cascade/pipeline-map.ts), then delegates `run(args)` to the matched `OverlayPipeline`. It no longer reads any `aiManager.overlayPipeline` setting — the per-user pipeline picker was removed (F009), so the pipeline is always the default. The `EvaluateOverlayArgs` / `EvaluateOverlayResult` contract lives in [`overlay-evaluator-types.ts`](../../src/main/services/ai-manager/overlay-evaluator-types.ts). The pipelines are a **static map** in `pipeline-map.ts` (`OVERLAY_PIPELINES`), NOT a self-registering registry — `resolvePipeline` is a plain lookup that falls back to `DEFAULT_PIPELINE_ID` for an unknown/undefined id. Pipeline IDs are canonicalized in [`cascade/pipeline-ids.ts`](../../src/main/services/ai-manager/cascade/pipeline-ids.ts) + [`src/shared/ai-manager-types.ts`](../../src/shared/ai-manager-types.ts) (`OVERLAY_PIPELINE_IDS`, `DEFAULT_PIPELINE_ID`) so the Zod schema and the dispatcher share one source of truth (there is no renderer dropdown — the pipeline is a hidden fallback).
- **Adding a new pipeline.** (1) implement `OverlayPipeline` from [`cascade/pipeline-types.ts`](../../src/main/services/ai-manager/cascade/pipeline-types.ts) in a new `cascade/pipeline-<id>.ts` file; (2) add the new id to [`cascade/pipeline-ids.ts`](../../src/main/services/ai-manager/cascade/pipeline-ids.ts) and `OVERLAY_PIPELINE_IDS` in `src/shared/ai-manager-types.ts`; (3) add one entry to the `OVERLAY_PIPELINES` map in [`cascade/pipeline-map.ts`](../../src/main/services/ai-manager/cascade/pipeline-map.ts). There is no self-registration barrel and no `registerPipeline()` call — the map is the single mount point. (Pipelines are a hidden fallback with no user-facing label — the settings picker was dropped on 2026-07-19.)
- **Reducing the transcript before generation.** An `OverlayPipeline` may declare an optional `reduceRows(rows: EligibleRow[]): EligibleRow[]` hook. When present, the dispatcher in [`overlay-evaluator.ts`](../../src/main/services/ai-manager/overlay-evaluator.ts) applies it to `args.transcriptRows` BEFORE invoking `pipeline.run()` and re-derives `args.transcript` from the reduced set via `formatTranscriptFromRows()`. This keeps the reducer out of every pipeline's `run()` body so a pipeline can be "cascade + a different reducer" by reusing `cascadePipeline.run` directly — see [`pipeline-cascade-v4.ts`](../../src/main/services/ai-manager/cascade/pipeline-cascade-v4.ts) (`targetPlusLastOp` reducer) for the canonical example. Reducers receive eligibility-filtered rows (system rows + `metadata.systemInjected` operator messages already dropped by `selectEligibleRows()` in [`context-builder.ts`](../../src/main/services/ai-manager/context-builder.ts)) so no further filtering is needed inside the reducer. **The default `cascade` itself now uses a `blendRows` reducer** — `[firstOp (original ask), lastOp (live follow-up), target (latest agent)]`, deduped — so the reframe doctrine can track a short follow-up ("is it merged?", "give me the link") without the whole thread burying it. It keeps BOTH the original ask AND the live follow-up, which is what distinguishes it from v4's `targetPlusLastOp` (that drops the original). The dispatcher rebuilds the reduced rows as plain `[operator]`/`[agent]` lines — the format the prompt documents.
- **Cascade stages.** The cascade lives under [`cascade/`](../../src/main/services/ai-manager/cascade/): V3 draft (`stage-v3-generator.ts`), V7 critic (`stage-v7-critic.ts`), V8 regex post-processor (`stage-v8-postprocess.ts` — pure functions, no LLM), V9 word-count fixer (`stage-v9-wordfix.ts`, calls Haiku 4.5 conditionally), V10 structural guardrail (`stage-v10-microchecks.ts`, calls gpt-oss-20b via OpenRouter), then the **section reviewer** (`stage-section-reviewer.ts`, calls Qwen 3 32B — the round-3 winner). The reviewer is **delete-only**: it asks the model which OPTIONAL sections (Response / Recommended Action / Questions / TLDR) fail to earn their slot and blanks each to the `(none)` sentinel via `replaceSection`, never rewriting surviving prose (rewriting is why round-2's draft-then-cut failed). It never cuts Latest, carries a link-guard (never cut a link-bearing section when the live ask is FOR a link), runs LAST among the LLM stages — after the structural fallback, before the jargon scrub — and is default-on with an `AMC_DISABLE_OVERLAY_SECTION_REVIEWER=1` kill switch. cascade-v4 inherits it (shared `run()`); qwen-single / sonnet-single do not. Invariants: [plain-speak-cascade-contract.md](../../.claude/memory/contracts/plain-speak-cascade-contract.md). Stages V7/V9/V10/reviewer are best-effort: any non-abort error is logged and the prior stage's output is kept. V3 is required; if it fails, the cascade pipeline falls back to a single Qwen 3 32B call and surfaces the failure on that. The V8 stage also exports `normalizeQuestionShape(rewrite)` — a deterministic final pass that runs **after V10** in the cascade and **after V3** in the qwen-single pipeline. It parses the `## Questions` section, drops any question that lacks ≥2 lettered options (`- A. ...`, `- B. ...`), strips orphan `- Or:` / prose bullets between question units, and renumbers numbered headers. This is the pipeline-boundary backstop the QuestionWidget parser depends on so every Q renders in-widget with no stranded prose bullets — even when an upstream LLM ignores the prompt's `<question_shape>` rules.
- **Reasoning-leak structural-validation backstop (5-layer defense).** Qwen 3 32B (the V3 generator and V7 critic) is a reasoning model; under token pressure the OpenRouter adapter can leak chain-of-thought into `message.content` despite `reasoning: { exclude: true }`, and a 2,000–4,000-char reasoning dump fits under the overlay's `MAX_OVERLAY_CHARS` cap so the existing truncation gate doesn't catch it. Five coordinated layers prevent the leak from reaching the user's bubble: (1) **V7 budget cap** — `MAX_TOKENS = 2048` (down from 8192) so the model has to commit to writing the rewrite within reach instead of burning the budget on hidden reasoning; (2) **V7 structural gate** — every `V7CriticResult` carries a `rejected: boolean` set when `ensureLeadingHeading(trimmed)` returns `null` on the trimmed reply, and the pipeline keeps the V3 draft when it fires; (3) **pipeline-final backstop** — after V10 returns, `ensureLeadingHeading(rewrite)` runs once more on the final rewrite and falls back to the V3 draft on failure (this catches any malformed output that survives V8/V9/V10 transformation); (4) **V10 `fallbackDraft` arg** — when V10's own validation rejects its output it now falls back to the V3 draft, not the rewrite it was handed (which may itself be malformed); (5) **renderer empty-string fallback** — `sanitizePlainSpeakOverlay` in [`src/renderer/src/lib/sanitize-plain-speak.ts`](../../src/renderer/src/lib/sanitize-plain-speak.ts) returns `''` (collapsing the overlay so the original bubble shows) rather than rendering raw text if all four backend backstops somehow let malformed content through. See [postmortem](../../.claude/memory/postmortems/plain-speak-v7-reasoning-leak-postmortem.md).
- **The renderer hides any "none" section — for ALL five sections, not just Response/Questions.** `sanitizePlainSpeakOverlay` ([`sanitize-plain-speak.ts`](../../src/renderer/src/lib/sanitize-plain-speak.ts)) drops a section whose body is a nothing-phrase, recognising it in **any format** — wrapping `()`/`[]`, surrounding `*`/`_`, trailing punctuation, case-insensitive — plus action-style synonyms (`no action` / `no action needed` / `no action required` / `no further action[ needed]` / `nothing to do`). This is the **consumer of the cascade's `(none)` sentinel**: [`cascade-prompts.ts`](../../src/main/services/ai-manager/cascade/cascade-prompts.ts) instructs the model to emit the literal token `(none)` for an empty body, and the section reviewer blanks a cut section to `(none)` ([`stage-section-reviewer.ts`](../../src/main/services/ai-manager/cascade/stage-section-reviewer.ts), comment: "the renderer hides `(none)` sections") — so the renderer MUST strip it, including the mandatory `Recommended Action` / `Latest` / `TLDR`. The separate "too short to be useful" trim (`<8` chars) stays scoped to the optional sections, so a terse-but-real `Recommended Action: Archive` still shows (short ≠ none). When every section is a nothing-phrase the card sanitizes to `''` and the overlay hides so the original message shows (overlay-gating contract `every-overlay-ui-gate-site`/`drops-nothing-phrase-sections`). Locked by [`sanitize-plain-speak.test.ts`](../../tests/unit/lib/sanitize-plain-speak.test.ts).
- View state for the Plain Speak ⇄ Original toggle lives in [`src/renderer/src/stores/ai-manager-store.ts`](../../src/renderer/src/stores/ai-manager-store.ts) on a `overlayViewByMessageId: Map<string, 'plain' | 'original'>` slice with `setOverlayView` / `toggleOverlayView` / `getOverlayView` actions. Every mutation creates a new `Map` reference so Zustand selectors fire correctly. Default for an unknown message id is `'original'` (flipped 2026-07-19 — a message shows its real content first; every reader of the map uses the same `'original'` fallback so they can't drift).
- **The default-view setting stays live across renderers.** The resolved default (Plain Speak vs Original for an untouched message) comes from `resolveDefaultOverlayView(rules)`, where `rules` is `useAiManagerStore`'s copy of `aiManager`. `rules` follows the `SETTINGS_CHANGED` push via a listener in [`src/renderer/src/app/useAiManagerPushListeners.ts`](../../src/renderer/src/app/useAiManagerPushListeners.ts) that applies the pure [`pickAiManagerFromSettingsPush`](../../src/renderer/src/lib/ai-manager-rules-sync.ts) through the store's `setRules` — so flipping **Show Plain Speak first** anywhere (another window, the phone/mobile-web client, the CLI, a settings import) updates every open view immediately, with no restart. Before this, `rules` refreshed only at app-mount and on a Plain Speak panel save on that same renderer, so an out-of-band change left other views on the stale default. Locked by [`plain-speak-overlay-gating-contract.md`](../../.claude/memory/contracts/plain-speak-overlay-gating-contract.md) `ai-manager-rules-store`.
- The toggle pill is rendered by `PlainSpeakTogglePill`, which lives in its own file [`src/renderer/src/components/ui/PlainSpeakTogglePill.tsx`](../../src/renderer/src/components/ui/PlainSpeakTogglePill.tsx) (extracted from `MessageBubble.tsx` on 2026-05-30) and is imported into [`src/renderer/src/components/ui/MessageBubble/MessageBubble.tsx`](../../src/renderer/src/components/ui/MessageBubble/MessageBubble.tsx). It sits in the agent message header row as a **direct child of the same flex container that holds the timestamp** (no `ml-auto` wrapper — that would fling it to the far-right edge on desktop, visually disconnected from the time). Gated on `!isOperator && overlayText` so it only renders for agent messages that have a Plain Speak rewrite applied. Inside the button, the `<span>` carrying the "Plain Speak" / "Original" label has class `hidden md:inline` so the text only shows on desktop; mobile renders icon-only (the `ArrowLeftRight` SVG stays visible on both viewports). The accent-tinted background and `aria-label` / `aria-pressed` attributes apply on both viewports so the pill is still visibly clickable on mobile and a11y-equivalent. Reads/writes the store via a `useCallback`-memoized primitive selector keyed on `messageId` (this preserves `MessageBubble`'s `React.memo`). The pill's class string is the `PLAIN_SPEAK_TOGGLE_PILL` constant in [`src/renderer/src/lib/styles.ts`](../../src/renderer/src/lib/styles.ts). `AutoResponseBadge` (operator-message-only, mutually exclusive with the Plain Speak pill) keeps its `ml-auto` right-aligned wrapper as a separate sibling — that one's right-alignment is intentional and out of scope for the Plain Speak pill placement contract.
- **Right-click → Plain Speak settings.** `PlainSpeakTogglePill`'s `onContextMenu` handler `preventDefault()`s the native menu, `stopPropagation()`s so the event never bubbles to an ancestor row/panel click handler, and opens a one-item menu built from the shared [`ContextMenuShell.tsx`](../../src/renderer/src/components/ui/ContextMenuShell.tsx) primitive (a portal + keyboard/viewport-clamp shell extracted from `ImageContextMenu.tsx` so both menus share it). The single **Plain Speak settings** item calls `navigateToSettingsProject({ section: 'plain-speak' })`, closing the menu **before** navigating (the panel unmounts on nav, and a bubbled click could otherwise re-activate the session and undo the navigation). Right-click never toggles the view; left-click and the **V** hotkey are unchanged. Pinned by [`tests/unit/components/plain-speak-toggle-pill.test.tsx`](../../tests/unit/components/plain-speak-toggle-pill.test.tsx) (left-click toggles / right-click does not, native-menu suppression, deep-link + menu-close, non-bubbling).
- **Dwell-preservation hook (now a dormant no-op — default view is `'original'` as of 2026-07-19, so there is no plain-first swap to suppress; kept as belt-and-suspenders).** [`src/renderer/src/hooks/usePreSetOverlayView.ts`](../../src/renderer/src/hooks/usePreSetOverlayView.ts) is wired into the agent branch of `MessageBubble` and subscribes to `useAiManagerStore` to catch the null→set transition of `overlayByMessageId.get(messageId)`. The effect early-returns if `!isAgent || overlayText !== null` (so it only watches the first arrival on agent rows) and the subscriber inside it short-circuits unless: (a) the new state has a defined overlay AND the prev state did not (the null→set edge), (b) `overlayViewByMessageId.has(messageId)` is false (no explicit user choice), (c) `useSessionStore.getState().activeSessionId === sessionId`, (d) `document.visibilityState === 'visible'`, (e) the bubble's `getBoundingClientRect()` intersects the viewport (width+height > 0, top<viewportH, bottom>0, left<viewportW, right>0). Only when all five hold does it call `setOverlayView(messageId, 'original')`. The hook returns a `React.RefObject<HTMLDivElement>` that `MessageBubble` merges with the parent-passed `summaryRef` via a `useCallback`'d callback ref (`setBubbleRef`) so a single DOM node feeds both refs without refactoring `summaryRef` call sites. The subscriber fires synchronously inside Zustand's `setState` between the store mutation and React's commit, so the per-message view is already `'original'` when `PlainEnglishOverlay` reads it — no one-frame Plain Speak flash. Test file: [`tests/unit/hooks/use-pre-set-overlay-view.test.tsx`](../../tests/unit/hooks/use-pre-set-overlay-view.test.tsx) (10 scenarios — happy path + each gate independently + post-arrival user toggle + unmount cleanup).
- The body component is [`src/renderer/src/features/sessions/PlainEnglishOverlay.tsx`](../../src/renderer/src/features/sessions/PlainEnglishOverlay.tsx) and is **body-only** — no toggle button lives inside the overlay. It reads the current view from `useAiManagerStore.overlayViewByMessageId` via the same `useCallback` selector pattern and swaps `text` (rewrite) vs `originalContent` (raw markdown). The overlay is a **transparent pass-through on the rendering axis for BOTH views** (2026-05-26 Rev 31): it accepts an optional `overviewMode?: 'inline' | 'summary-first' | 'external-pill' | 'prose-only'` prop (default `'external-pill'`) AND an optional `liteContent` handle, and forwards both verbatim to `AgentMarkdown` regardless of whether the user has flipped to Plain or Original. `MessageBubble` passes the same `overviewMode` + `liteContent` the non-overlay branch uses, so flipping to **Original** matches the regular agent-message render path exactly: settled turns hide the per-action "> N actions —" pills AND the intermediate progress prose (already summarised by the sibling `TotalsPillBubble`), streaming turns keep them inline. The Plain Speak rewrite text itself contains no activity segments, so the props are no-ops for the `'plain'` view. Prior to Rev 31, the overlay overrode `overviewMode` to `'inline'` + `liteContent` to `undefined` on the Original branch as defensive code; that override caused intermediate "thinking prods" prose to leak into Original view — see [postmortem](../../.claude/memory/postmortems/plain-speak-original-rescue-truncation-postmortem.md) § "2026-05-26 — Bug D".
- The **V hotkey** is wired in [`src/renderer/src/hooks/useKeyboardShortcuts.ts`](../../src/renderer/src/hooks/useKeyboardShortcuts.ts) under `case 'togglePlainSpeak'`. It walks the active session's `conversationCache` from the end backward, looking for the latest message whose `source === 'agent'` AND whose id is in `overlayByMessageId`. When found, it calls `toggleOverlayView(id)` and returns; if no eligible message exists (e.g. a streaming turn with no overlay yet, or a session with no rewrites), V is a silent no-op. The action is registered in [`src/shared/keybindings.ts`](../../src/shared/keybindings.ts) as `togglePlainSpeak` with `defaultBindings: ['V']`, `passthroughInputs: false` (won't fire while typing in the composer or any other input/textarea/contenteditable), and `category: 'global'` so it surfaces in the `?` cheat-sheet alongside P, H, T.
- **The gating + enable-flag state machine is the test-locked single source of truth** in [`plain-speak-overlay-gating-contract.md`](../../.claude/memory/contracts/plain-speak-overlay-gating-contract.md) — the resolved-enable rule (`overlay.enabled && per-session toggle`, never the Inbox Pilot router flag and never a legacy pause flag), the overlay-pending gate lifecycle, the skip-on-error bad-set, the 5-min backstop, and the narrow `needs_you`-only suppression, each invariant naming its enforcing test. READ it before changing any overlay gate or enable flag.
- The "while rewriting" gate is an in-memory `Set<sessionId>` in [`src/main/services/ai-manager/overlay-pending.ts`](../../src/main/services/ai-manager/overlay-pending.ts). Set-based on purpose: a DB-based "is overlay in_flight?" gate lost a sync-vs-microtask race (see comment block at the top of the file). The gate is **marked synchronously inside `evaluate(...)` BEFORE the first `await`** so it's already set when `notifyNeedsYou` runs in the same tick as `notifyTurnEnded`.
- Status-indicator override (sidebar dot, panel border-left, StatusDot label/tooltip, unified-inbox dot color): the five helpers in [`src/renderer/src/lib/utils.ts`](../../src/renderer/src/lib/utils.ts) (`getStatusBgColor`, `getStatusTextColor`, `getStatusBorderLeftColor`, `getStatusLabel`, `getStatusTooltip`) take an optional `isOverlayPending?: boolean` third argument. A new `pickStatusRow()` helper short-circuits to `STATUS_DISPLAY.running` whenever `isOverlayPending === true && status === 'needs_you'`, regardless of `pendingAction`. Call sites read the per-session boolean from `useAiManagerStore((s) => s.overlayPendingSessions.has(sessionId))` (a primitive selector so each row re-renders only when ITS state flips) and pass it through.
- Mark + clear sites live in [`src/main/services/ai-manager/index.ts`](../../src/main/services/ai-manager/index.ts) — `evaluate` sets the gate and the outer `release()` closure clears it as a safety net; `runOverlay` clears it explicitly **before** firing the deferred `notifyNeedsYou` so the gate doesn't suppress the re-fire it triggered itself.
- The notification consumer is [`src/main/services/notification-service.ts`](../../src/main/services/notification-service.ts) — `notifyNeedsYou` early-returns when `isOverlayPending(sessionId)` is `true`. The gate sits **after** the pipeline-lane and arm gates (those win first) and **before** the chime and OS toast.
- Renderer-side: the inbox/sidebar exclusion is mirrored in `useAiManagerStore`'s `overlayPendingSessions` Set, with a 5-minute defensive timeout that DB-recovers any applied overlay text BEFORE clearing the pending gate (so a missed `DECISION_WRITTEN` push doesn't surface a row without its Plain Speak pill). The 5-minute cap is wide enough that real slow Qwen/OpenRouter cascades — empirically 60–180 s under load — don't trip it prematurely. Six consumers read the Set:
  - `computeOrderedInboxGroups` in [`src/renderer/src/features/dashboard/inbox-helpers.ts`](../../src/renderer/src/features/dashboard/inbox-helpers.ts) — keeps overlay-pending `needs_you` sessions out of the unified inbox's Needs You group.
  - `computeProjectSessionCounts` in [`src/renderer/src/lib/utils.ts`](../../src/renderer/src/lib/utils.ts) — re-routes overlay-pending `needs_you` from `attention` to `running` so the project-icon amber count badge does not tick up. Snooze/scheduled sessions zero-count regardless of the overlay flag (those exclusions take precedence). Callers MUST pass the Set: [`ProjectsSidebar.tsx`](../../src/renderer/src/features/dashboard/ProjectsSidebar.tsx), [`MobileProjectsList.tsx`](../../src/renderer/src/features/dashboard/MobileProjectsList.tsx), [`MobileProjectDrawer.tsx`](../../src/renderer/src/components/ui/MobileProjectDrawer.tsx).
  - `needsYouSessions` and `filteredSessions` in [`SessionsSidebar.tsx`](../../src/renderer/src/features/dashboard/SessionsSidebar.tsx) — desktop project sub-sidebar. Overlay-pending `needs_you` is excluded from the Needs You bucket and kept visible in the regular list.
  - `needsYouSessions` and `filteredSessions` in [`MobileSessionsList.tsx`](../../src/renderer/src/features/dashboard/MobileSessionsList.tsx) — mobile project sub-sidebar; same predicate as desktop.
  - `bucketSession` in [`AICoachingSubSidebar.tsx`](../../src/renderer/src/features/ai-coaching/sidebar/AICoachingSubSidebar.tsx) — `needs_you` overlay-pending sessions bucket as `'active'` (green Active group), not `'needsYou'`.
- The literal-message gate (the **GATE 4.5** block in `evaluate()` in [`src/main/services/ai-manager/index.ts`](../../src/main/services/ai-manager/index.ts)) sits AFTER GATE 4 (per-session toggles) and BEFORE GATE 5 (api-key guard). It matches `session.pendingAction` against two flavors: `'user_stopped'` (audit reason `"user-interrupted turn"`) for partial mid-stream cutoffs, and `'rate_limited'` / `'auth_error'` / `'api_error'` (audit reason `"<pendingAction> turn"`) for messages where the agent's reply IS the literal error notice and a paraphrased card would obscure it. When any match fires it calls `writeSkipped(db, sessionId, 'overlay', <reason>, firedAt)`, sets `overlayOn = false`, and short-circuits to `return` only when router is also off. Router (Inbox Pilot) is intentionally untouched — it can still classify these turns into `no_action` / `unstuck` / etc. The gate runs BEFORE `markOverlayPending`, so the OVERLAY_PENDING gate never engages and the chime/badge/inbox/OS-toast fire immediately for the interrupt or error.
- Test coverage:
  - Gate semantics: [`tests/unit/services/ai-manager/overlay-pending.test.ts`](../../tests/unit/services/ai-manager/overlay-pending.test.ts)
  - Notification consumer with mocked gate + the sync-mark race scenario: [`tests/unit/services/notification-service-overlay-gate.test.ts`](../../tests/unit/services/notification-service-overlay-gate.test.ts)
  - Deferred re-fire end-to-end with REAL `notification-service`: [`tests/unit/services/ai-manager/run-overlay-deferred-notify-real-service.test.ts`](../../tests/unit/services/ai-manager/run-overlay-deferred-notify-real-service.test.ts) (covers both directions — gate cleared → fires, gate set → suppressed)
  - Pipeline-mocked deferred re-fire: [`tests/unit/services/ai-manager/run-overlay-deferred-notify.test.ts`](../../tests/unit/services/ai-manager/run-overlay-deferred-notify.test.ts)
  - User-interrupted-turn skip (overlay does NOT fire on `user_stopped`, OVERLAY_PENDING gate does NOT mark, router still fires, audit row reasoning is `"user-interrupted turn"`, other pendingAction values are unaffected): [`tests/unit/services/ai-manager/evaluate-overlay-skip-reasons.test.ts`](../../tests/unit/services/ai-manager/evaluate-overlay-skip-reasons.test.ts) (the `user_stopped` row of the merged SKIP_REASONS suite — was `evaluate-skip-on-user-stopped.test.ts`, merged 2026-07-09)
  - V8 deterministic post-processor pure functions (`parseSections`, `fixActionVerb`, `stripTldrMarkdown`, `joinSentences`, `stripCommitSHAs`, `bulletCap`, `truncateBullets`, `replaceSection`, end-to-end `postProcess`): [`tests/unit/ai-manager/cascade/stage-v8-postprocess.test.ts`](../../tests/unit/ai-manager/cascade/stage-v8-postprocess.test.ts)
  - Jargon scrub final pass (`scrubJargon` — each jargon class → its plain phrase, real-branch vs slash-word-pair, the backtick bare-ID blind spot, the "the agent"→"the session" voice fix, and preservation of links / keyboard shortcuts / benign labels; idempotent; no-op on clean input): [`tests/unit/ai-manager/cascade/jargon-scrub.test.ts`](../../tests/unit/ai-manager/cascade/jargon-scrub.test.ts)
  - Dispatcher behaviour (qwen-single forwarded to `llmProviderService.chat`, abort/timeout/cap, cost from Qwen price table): [`tests/unit/ai-manager/overlay-evaluator.test.ts`](../../tests/unit/ai-manager/overlay-evaluator.test.ts)
  - Settings schema accepts each `OVERLAY_PIPELINE_IDS` value, rejects unknown ids, treats `overlayPipeline` as optional: `updateSettingsSchema — aiManager.overlayPipeline` block in [`tests/unit/ipc-schemas.test.ts`](../../tests/unit/ipc-schemas.test.ts)

## Related

- [Plain Speak](plain-speak.md) — part 1.
- [Plain Speak part 3](plain-speak-part-3.md) — the cost controls and privacy rules this code implements.
- [plain-speak-feedback.md](plain-speak-feedback.md) — the reporting path back from a bad rewrite.

