What’s new?

Changelog

0.22.026 Jul 2026

A large release — codebooks you can switch on and off, a self-contained offline report you can share as a single file, a more reliable AutoCode, and a pass over the whole report's visual system.

  • Codebooks you can install, switch off, and trust to stay put. The Codebook Library is now a two-way surface — every tile has one button, Install or Uninstall (retiring the old Add / Remove), so an installed codebook is no longer look-only. Each codebook carries an on/off switch with a matching status dot in the sidebar (blue on, grey off); switch one off and its section folds to a one-line summary, its badges hide across the whole report, and it drops out of the tag sidebar and tag suggestions — but its tags stay on your quotes and every result is kept, so flipping it back on restores everything. "Off means off" is functional, not cosmetic. Uninstalling now also forgets a codebook's on/off state, so reinstalling one you'd switched off returns it on, not greyed-out. And the codebooks you have switched on now code newly-added sessions too — add interviews to a finished project and the existing codebooks catch up on the new quotes, rather than leaving them blank. Ships on PyPI and in the desktop app.
  • Share an offline report — a self-contained leave-behind that opens from a file. Export a report as a single HTML file that runs the full app straight from file:// with the data inlined — no server, no internet, nothing to host. It opens read-only (the editing controls that can't persist offline are removed, not left as dead buttons), bakes in the researcher's UI language so it reads the same offline, and its coverage is guaranteed by construction: a build-time gate derived from the live API surface fails the build if any report data would be missing from the embed, so the download can't silently ship half a report. Security: the embedded data is now escaped against < / > / &, closing a stored-XSS hole where a literal </script> in transcript text, a participant name, or a tag could break out of the data block in a shared file. Ships on PyPI and in the desktop app.
  • AutoCode stopped losing proposals — and a stalled run is recoverable. A run could cheerfully report "AutoCoded in 50s ✓ — 0 of 0 proposals" while the model had actually returned dozens (seen against the IKEA data with Bristlenose UXR and JJG's Elements: 38 proposals returned, every tag stuck at zero). The culprit was a cosmetic progress counter sinking the real payload: after each batch the job wrote its running quote-count with a read-then-write transaction, which SQLite refuses outright ("database is locked") the moment any other write is in flight, and because that write wasn't isolated its failure dragged the batch's proposals down with it. The progress write is now a bare, write-only update that waits its turn for the lock, and can no longer discard proposals if it does fail. Alongside it: re-apply proposals are de-duplicated per quote (no more perpetual silent re-fails), a cancelled or failed AutoCode job can be re-run instead of being stuck, start-time errors surface instead of looking like nothing happened, and orphaned jobs are reconciled on server startup. Ships on PyPI and in the desktop app.
  • The report reads calmer. A pass over the whole report's visual system: one true heading per content zone instead of competing title weights, section dividers unified to a single full-width rule (the heavier capped rule is gone), balanced gutters on panes with no minimap, and a consistent corner-radius tier so nested cards stop inverting. The Analysis lens scrolls to the bottom properly again, transcript margin annotations use a cleaner grid layout with first-occurrence de-duplication, and the quotes-page minimap keeps its true proportion when quotes reflow into columns — it reads the live grid's column count rather than a second set of breakpoints. Ships on PyPI and in the desktop app.
  • CLI: configure a provider without a system keyring, using the env vars the SDKs already expect. configure now persists keys to a config file when no OS keyring is available (headless boxes, some Linux setups), and Bristlenose reads the SDK-native, unprefixed environment variables for Gemini and Azure — so a key that already works with the official SDK works here without renaming. The no-provider guidance was reworked to point straight at configure (in the house cyan accent) and to name the right app store per platform. Ships on PyPI.
  • The Snap is now strictly confined. Converted from classic to strict confinement (home, network, network-bind, and removable-media interfaces), with libblas3 / liblapack3 staged so ffprobe finds its shared libraries and the frontend built under Node 22 (core24's Node 18 can't build Vite 8). Published to the edge channel. Snap users only.
  • Fixes worth calling out. Media probing now fails loud when ffprobe / ffmpeg errors, instead of reporting an empty success and producing a silently blank transcript. The Sessions sidebar detects moderators correctly again (the stored role is researcher, never moderator, so the old check silently never fired). And the thin-theme bucket is now plain "Uncategorised". Ships on PyPI and in the desktop app.
  • Desktop (reaches the alpha cohort with the next bundled build). The welcome screen is now illustrated — looping science-cell animations for the sentiment, quote, and theme concepts, a study-tools carousel introducing AutoCode and Codebooks one at a time, and a self-scaling book-shelf motif. The Settings window is resizable with animated per-tab height, a uniform width, and in-cell help subtitles. The menus fill in: dynamic Hide/Show Projects, Help ▸ Welcome to Bristlenose, a Sessions-contents toolbar toggle, a preference-gated Diagnostics menu, and debug windows no longer double into the Window menu. Distribution: a signed direct-download .dmg (developer-id archive→export flow) now works for the expiring alpha alongside the App Store / TestFlight path.

0.21.016 Jul 2026

  • "Out of credit" now says out of credit — not "rate limit reached." When a provider refused a call because the account had run dry, Bristlenose told you to wait. Credit exhaustion and rate-limiting both landed in the same QUOTA category, which rendered as "rate limit reached" — advice that will never come true for a bankrupt key. Billing exhaustion is now its own non-retryable category (waiting doesn't fix it; topping up does), across every surface that reports a failed LLM call: the run-failure cause, the CLI banner, the desktop failed-run row, a new out-of-credit toolbar pill, and the Settings provider check. Detection is a single shared classifier with provider-scoped rules, and the wire is stranger than the documentation admits — Anthropic serves out-of-credit as an HTTP 400 carrying "credit balance is too low", not the documented 402, and OpenAI shares 429 between billing (insufficient_quota) and genuine rate-limiting — so the classifier reads the provider's own error code first, its message second, and the HTTP status last. Where a provider genuinely doesn't distinguish the two — Azure has no credit concept, and Gemini folds billing into RESOURCE_EXHAUSTED — Bristlenose keeps calling it rate-limiting rather than inventing a top-up story the wire never told it. 33 tests built from real provider payloads. Ships on PyPI and in the desktop app.
  • Feedback from every state — and it can't be redirected off-host. The native feedback sheet arrived in 0.20.0 for the screen that needed it most (the status page after a failed run); it now opens from every state of the macOS app, so Help ▸ Send Feedback never dead-ends on whatever happens to be on screen. Alongside it, a hardening that applies to every feedback submission on every surface: the endpoint allow-list only ever checked the first request, and the HTTP client follows redirects by default — so a redirect from the feedback host would have quietly re-sent your typed message somewhere else. Redirects off bristlenose.app are now refused outright. Ships on PyPI and in the desktop app.
  • The dashboard fills the window, and features up to five quotes. The Project tab still carried a 52rem reading-width cap that every other tab had shed, so on a wide display it sat in a column with the right-hand side empty. It now fills the width like the rest of the app — full-span panes stretch, and the two navigation lists share a flush row instead of trailing a ragged right edge. Featured quotes go from three to five: three across on a laptop, five across from 1400px up, with the visible count always matching the column count so a row never ends half-empty. Ships on PyPI and in the desktop app.
  • Desktop: the welcome screen teaches you the app, a little at a time. The screen you land on with no project open was a single static panel — drop a folder, and that was the whole of it. It's now a grid that drip-feeds the things worth knowing: features you haven't found yet, the research science the analysis rests on, practical tips, help getting an LLM set up, and pointers into the documentation. The drip is the point. A wall of onboarding gets read once and dismissed, whereas the empty window is a surface you pass back through every time you finish a project — so it can keep handing you one more thing you didn't know, rather than one thing you've already ignored. Desktop-only (Swift); reaches the alpha cohort with the next bundled-sidecar build.
  • Desktop: the shoal moves like a murmuration — and your analysis is what disturbs it. The word-flock that drifts during a run moved as though floating in treacle. The cause was a force clamp crushing every steering impulse — including the startle and attract behaviours — down to an idle nudge. Retuned, it banks and overshoots around a wandering attractor the way a real murmuration does. And it's no longer decorative motion running beside the work: the analysis drives it. Each batch of arriving words, themes and sentiments fires a matching disturbance through the flock — a cohort joining, a startle wave, a predator swoop — so the shoal reacts as your interviews are actually processed. Desktop-only (macOS); reaches the alpha cohort with the next bundled-sidecar build.
  • Developer: a read-only database browser, in the direct-download beta only. The SQLAdmin browser now mounts at /admin for debugging a cohort user's data on a call — read-only over transcripts and PII, bound to localhost, and fail-closed by construction: it's gated on a compile-time flag set only by the direct-notarised beta build, so the App Store and TestFlight builds both hide it. A receipt check was considered and rejected — App Review runs under the StoreKit sandbox, so a receipt can't tell a reviewer from a tester, and the Debug menu would have been exposed to review. Read-only also disables table export, which was an unauthenticated full-table dump. Developer-facing.
  • Under the hood: the macOS app is sandboxed, hardened and accepted by App Store Connect. The Release build now runs under the App Sandbox with the Hardened Runtime, pinned to arm64 (the Python sidecar is arm64-only), with every nested binary signed the way App Store Connect requires — three separate upload rejections' worth of signing corrections. Not user-visible; it's what makes the macOS app distributable at all.

0.20.011 Jul 2026

  • Your curation now survives re-analysis — and what's new is flagged. Re-run the pipeline on a project and the work you'd already put in stays put. Any quote you've starred, edited, or tagged is frozen with a durable identity and its exact wording, held exempt from the stale-cleanup that clears regenerated data — so a fresh analysis can never quietly drop a quote you'd marked, even one the model wouldn't surface again. Report sections keep their identity by membership, not by their heading text, so a re-run that renames or re-clusters a theme no longer scrambles the headings you'd edited; a theme name you've anchored to a starred quote persists across runs. And genuinely new material earns a dismissible green New badge on its section, so a returning researcher's eye goes straight to what changed without disturbing the sections they'd already curated. This is the persistence foundation for incremental analysis (adding sessions to a finished project); the manual re-assignment write path — moving a quote between sections by hand — lands alongside it. Ships on PyPI and in the desktop app.
  • Desktop: the "Interviews" folder proxy reveals your source folder in Finder. The folder chip above the sessions table is now a real proxy for the interviews folder: click it in the macOS app and Finder opens with that folder selected — the shortest path from "the report on screen" back to "the recordings on disk," the exact move a researcher reaches for the moment they've recorded another session. It follows the same Show in Finder idiom as the project sidebar, and it's sandbox-safe by construction — the reveal is handed to Finder out-of-process, so it needs no extra file-access permission, no security-scoped bookmark, nothing new in the entitlements. In the browser (bristlenose serve), where there's no native bridge to hand off to, the chip keeps its copy-the-path-to-clipboard behaviour, and both the action and its tooltip follow the surface ("Show in Finder" in the app, "Copy folder path" in the browser). The desktop reveal reaches the alpha cohort with the next bundled-sidecar build; the groundwork behind it — a new reveal-in-finder WKWebView bridge action and a Show in Finder label seeded across every language Bristlenose speaks — ships on PyPI.
  • Feedback that survives a failed run — and a native way to give it. When a run is cancelled or fails, the report gives way to a status page, and its "Send feedback" link used to lead nowhere: it pointed straight at the submission endpoint, so a browser GET got back a bare "Method not allowed." Now feedback works from that screen in every context. The macOS app opens a native feedback sheet — five-point rating, a message, Cancel/Send, no web chrome — while the browser (bristlenose serve) opens an inline form on the status page itself. Both send the same anonymous {rating, message}, and both now report "sent" only when the server actually confirms receipt — a captive-portal or corporate-proxy page that answers 200 OK with its own HTML can no longer swallow your feedback and call it delivered. If a send can't be confirmed, your message is preserved and copied to the clipboard rather than lost. The reliability fix reaches the in-report feedback form too. Browser form + reliability ship on PyPI; the native sheet reaches the alpha cohort with the next bundled build.
  • Help opens the live docs — so you can read it beside the report, not over it. The in-app Help overlay is retired in favour of the online documentation, which supersedes it and stays current. Help → Bristlenose Help and Keyboard Shortcuts now open the matching page at bristlenose.app/docs in your browser, and Acknowledgements opens the project's open-source credits. A panel that covers the very thing you need help with is the wrong shape for a tool you're actively using — the docs open in their own window (and the ? key reuses a single tab), so the analysis stays in view while you read. Ships on PyPI and in the desktop app.
  • Jump from a session's journey straight to the moment it happened. The screen path under each row in the Sessions view — Beds Category → Bed Product Detail → Checkout — is now a row of live links. Click any step and Bristlenose opens that participant's transcript scrolled to the exact moment they first reached that screen, the segment briefly highlighted — the same deep-link the in-transcript journey navigation already performs, now reachable straight from the overview. The anchor is the participant's earliest quote on that screen, resolved to the nearest transcript segment so a step always lands somewhere real even when its first quote falls mid-segment. Plain click navigates in place; ⌘-click opens the moment in a new tab. Ships on PyPI and in the desktop app.
  • Sessions view: play from the thumbnail, and a steadier Start column. The session thumbnail is now a play control — click it to open the pop-out video player at the start of that session, matching the filename link beside it (with the filename as a hover tooltip). And the Start column, which stacks the date above a sometimes-long screen path, gains a minimum width so it no longer collapses to one word per line as the window narrows — the path stays readable instead of shattering into a tall column. A fuller responsive pass on this dense, frequently-seen screen is tracked as follow-up. Ships on PyPI and in the desktop app.
  • A calmer failure, and a proper landmark. A route-level error boundary now catches a crashing report view and shows a calm fallback instead of a blank page, and the routed content sits inside a <main> landmark so a screen reader can jump straight to it. Ships on PyPI.
  • Drop loose interview files, not just folders — Bristlenose builds the project for you. The desktop app could only analyse a folder of interviews; dragging in individual files created a project that dead-ended on "this was created from individual files, so it can't be analysed." Now dropping loose files — onto the sidebar, or onto a new project you've just created — opens a native Create Project Folder sheet: name it, pick a location, and Bristlenose creates the folder, copies your interviews into it, and analyses them. Your originals stay where they are. It's a proper window-modal sheet — it slides from the title bar and follows the app's light or dark theme — not a stray floating panel, and single- vs multi-file drops read correctly. Desktop-only (Swift) — reaches the alpha cohort with the next bundled-sidecar build; the supporting interface strings ship on PyPI.
  • Add more interviews to a project you've already analysed. Drop new files onto an analysed project — or use File → Add Files… (⇧⌘A) — and Bristlenose folds them in with an incremental re-run instead of starting over: a calm "Adding N interviews…" confirmation up front, then the normal stage ladder. Your starred, tagged, and edited quotes carry across (the curation persistence above). Desktop-only (Swift); the per-session caching that makes the re-run incremental ships on PyPI.
  • Recover a stopped or half-finished analysis. Stop a run partway — or have one fail — and the interviews are already copied into the project's folder, so the project isn't a dead-end. Right-click it and choose Analyse to resume from the partial output already on disk. Files belong to the project: a stopped run is always visible and always retryable. Desktop-only (Swift).
  • Two fixes worth calling out. Claude API-key validation now probes /v1/models rather than calling a since-retired model, so a valid key no longer reads as invalid. And a project — with its cached inputs — is now identified by its resolved filesystem path rather than the spelling you happened to type, so the same folder reached two ways is one project, not two. Ships on PyPI.
  • Fix: your project's real name in the report, not "Untitled." A project analysed the moment it was added could have its report and dashboard title freeze at the placeholder "Untitled" — the database row behind the report is built in the split second before the pipeline writes the project's name to disk, and every later re-analysis healed that row's file paths but never its name. The importer now heals a stuck "Untitled" back to the pipeline's own project name (and its URL slug) on the next re-import, scoped tightly to the placeholder so it can never overwrite a name you've set on purpose. The desktop sidebar always read the right name — this repairs the report and dashboard heading, which reads from the database. Ships on PyPI and in the desktop app.
  • Shoal, sized to your screen — a fuller flock, held smooth. The in-run word-flock now scales to the window it's drifting in: sparse in a laptop pane, a proper shoal across a 24″ external display, and it re-flocks live when you drag the window between screens — where before it drew the same fixed handful everywhere. It's capped at a steady 50 fps so the motion stays fluid instead of straining for a 120 Hz ProMotion refresh it can't hold (30 fps read janky; 50 was the sweet spot). The fix came from a better diagnosis: the flock's real cost is the main-thread flocking simulation — an O(n²) neighbour search every frame — not the GPU render we'd assumed, so 150 words only turned smooth once the frame rate was capped and the count was tied to the canvas area (clamped 24–200, still thickening early → late as analysis proceeds). Desktop-only (macOS); reaches the alpha cohort with the next bundled-sidecar build.
  • Acceptance testing you can't fake-pass — a conformance tier that runs the real thing. Bristlenose's two automated test layers are hermetic by design: they mock every provider and binary so they stay fast and free, which leaves one whole class of bug invisible — the cross-seam kind where a setting reaches the wrong environment variable and every run silently falls back to a too-small model, green all the way down. This lands the first rung of a third tier that runs the real pipeline against real providers and asserts only shape, never content. Every input format we advertise now has a sub-second coverage check — all sixteen classify correctly, and thirteen media containers are produced and decoded through real ffmpeg, retiring the idea of a full transcription run per format. A provider matrix drives bristlenose run across Claude, ChatGPT, Azure, Gemini, and local Ollama and refuses to call a run successful unless it actually produced work: a completed-not-failed terminus, non-empty quote clusters, and the re-identification keys (llm-calls.jsonl, pii_summary.txt) provably absent from every export. The guards are themselves tested — an empty run, a missing artifact, a participant name that leaked past the anonymisation boundary all turn the check red — so a green run means something. The tier was designed against an adversarial multi-agent review and then fired for real, which immediately earned its keep: it caught a fail-closed bug in the harness itself (a missing artifact crashed the check instead of failing it — fixed), and proved the discipline end to end — an empty run fails, a model that timed out fails, a genuine Claude run passes. Developer-facing; no user-visible surface — everything lives in docs/testing/ and scripts/acceptance/.

0.19.04 Jul 2026

  • Nine new locales — Bristlenose now speaks nineteen languages. Dutch (nl), Finnish (fi), Polish (pl), Russian (ru), Ukrainian (uk), Danish (da), Swedish (sv), Norwegian Bokmål (nb), and Turkish (tr) join the roster across the desktop app, web report, and CLI (Settings → Language) — nineteen locales in total. Each is a machine-seeded community preview, complete against all nine namespaces (common, cli, desktop, doctor, enums, pipeline, preflight, server, settings), offered so native speakers can refine on Weblate; the seed is fill-empty-only, so contributed translations always win the next sync. Slavic + Turkic plural rules are respected — Polish uses the 1 / few / many / other split, Russian and Ukrainian retain {{count}} in the _one form because the shape recurs at 21/31/etc., Turkish is binary — and Scandinavian is one/other. Turkish register is formal (siz-and-imperative, Vazgeç for Cancel rather than the abrupt İptal). New tooling to catch regressions: a placeholder-union check across every locale, a _one completeness gate for East-Slavic, and a pytest guard that classifies unseeded strings against the English source. Norwegian ships as Bokmål only — the no → nb fallback path is an open follow-up. Ships on PyPI and in the desktop app.
  • Shoal — a live word-flock while your interviews are being analysed. During a run, a SpriteKit flock of the words the pipeline is currently extracting drifts across the detail pane in the desktop app. A Python-side sampler filters transcript words for stopwords and appends them to a shoal-feed.jsonl sidecar as they surface; the Swift ShoalFeed polls the sidecar every ~1.5 s and injects the batch into the scene, colour-graded by sentiment. The flock respects Reduce Motion system-wide and a dedicated Show animation while analysing toggle in Appearance → Animation; if the feed is stale by the halfway mark it logs once and falls back to a canned word pool so you never see an empty pane. A 0–600-word density rig with FPS + node counters ships behind the DEBUG build for anyone tuning the flock. Desktop-only feature; the sampler + i18n strings ship on PyPI so the SPA carries the plumbing.
  • Palette picker + Edo, end to end. Settings → Display picks between Default (re-grounded on Apple's system values — system-blue accent #007AFF / #0A84FF, the neutral-grey AppKit selection capsule #EFEFEF / #2B2B2B, 6 px corners, native inactive-window dimming — so the seam with the desktop app's native chrome disappears) and Edo (a Prussian-blue accent sampled from the pigment's brilliant undertone rather than the dull masstone of aged prints, paper-white paper, passport-navy ink, with a warmer paper in dark mode so it stays legible). The picker is now a native macOS pulldown in Settings — no restart needed, the SwiftUI .tint() propagates through Settings/Debug scenes, and a WKWebView bridge repaints the report the instant you switch. Your choice persists locally and paints before first render (no flash of the wrong palette), validated against an explicit default / edo allowlist rather than a permissive regex. A --palette CLI flag (renamed from the ad-hoc env var) lets bristlenose serve --palette edo boot straight into your preference. Translated across all nineteen locales; the picker's labels are provisional in the newer languages, pending native review. Ships on PyPI and in the desktop app.
  • Terminology fixes across four locales. A glossary-consistency pass corrected drift the multi-locale audit surfaced: Japanese (Quotes 引用 → 発言, the glossary-forbidden form), Korean (a 왜곡 mis-spelling, Accept 승인 over 수락, Signals 시그널 with 을/은 particle fixes), German (Sessions Sitzung → Interview, undo Rückgängig → Widerrufen, three informal du → formal Sie), and Spanish (dropping the over-literal Tubería for Pipeline). Values only, no key changes — verified against check-locales.py and the locale-key parity tests. Ships on PyPI.
  • Supply-chain hardening — CycloneDX SBOMs, signed and attached. Every release now attaches runtime-scoped CycloneDX SBOMs (Python + frontend) as durable GitHub Release assets, and binds the Python SBOM to the built wheel + sdist with a Sigstore attestation via pypi-publish's attestation flow. A new gitleaks history-scan CI job checks the full git history on every push and PR (with three historical false positives allowlisted); a tracked-vs-gitignore guard catches the class of .env-shaped files being accidentally tracked while gitignored. Trusted Publishing (the OpenSSF baseline) was already in place — SBOM + attest close the falsifiable-property loop on "what shipped from what source." Ships in the release pipeline, no user-facing surface.
  • Lighter first paint. The report's tab views (Dashboard, Quotes, Transcript, Codebook, Analysis) now code-split into lazy per-tab chunks instead of being simultaneously dynamically and statically imported — a contradiction that quietly folded them all back into the main bundle. The landing bundle drops to ~213 kB (size-limit ceiling ratcheted 230 → 220 kB), and switching tabs fetches its island on demand behind a shared loading boundary. Invisible on a fast machine; a real saving on a cold load. Ships on PyPI.
  • Smaller polish. AccentColor.colorset is finally filled (it had been silently empty, letting the system pick a nearby colour). AutoCode is hidden on auto-applied Sentiment framework so it stops offering itself when there's nothing to auto-code. A no-fake-success pipeline acceptance test is added (slow, skip-in-CI) — the class of "green suite while the pipeline silently returned nothing" is now caught end-to-end. Translucent detail-column chrome merged from spike after full-screen inset handling was resolved. Ships on PyPI and in the desktop app.

0.17.030 Jun 2026

  • Five new locales — Portuguese, Italian, and Traditional Chinese. Bristlenose now speaks Brazilian and European Portuguese (pt-BR / pt-PT), Italian (it), and Traditional Chinese for Taiwan and Hong Kong (zh-Hant / zh-Hant-HK) across the desktop app, web report, and CLI (Settings → Language) — twelve locales in all. Portuguese and Chinese each ship as a deliberate pair rather than one catch-all, because a single code reads as foreign to one side: Brazilian and European Portuguese diverge on everyday words (arquivo / ficheiro, usuário / utilizador), register (você), and the progressive (Brazilian salvando vs. European a guardar); and Bristlenose's Chinese is Traditional only — a Taiwan-leaning zh-Hant plus a distinct Hong Kong variant — not Simplified. As with Czech, each locale is a complete machine-seeded baseline (all 8 namespaces + preflight) offered as a community preview for native speakers to refine on Weblate; the seed is fill-empty-only, so a contributed translation is never overwritten and Weblate's own work always wins on the next sync. Apple-HIG action verbs and the Bristlenose research-method glossary (Codebook, Quotes, Sessions, Themes…) seeded per language so reviewers start from an agreed taxonomy. New guard tests pin locale-key parity, {{placeholder}} consistency, and plural-form completeness across every locale.

0.16.128 Jun 2026

  • Send to Miro becomes a native macOS sheet, and shows exactly where the board will land. On the desktop app the export moves out of the in-report panel into a native sheet with its own Miro connection — paste your access token once and it's held in the macOS Keychain — and the sheet now names the connected account, workspace, and organisation plus the destination team, so you can confirm where the board lands before you push. The web report's Miro panel gains the same connected-account / workspace identity, so bristlenose serve users see it too. Desktop sheet reaches the alpha cohort with the next bundled-sidecar build; the Miro-panel changes ship on PyPI.
  • Desktop Quotes toolbar: a native search field and a starred-only toggle. The Quotes view's search box and "starred only" filter are now native macOS toolbar controls rather than web widgets, debounced through a single-writer bridge to the report, and the redundant tag dropdown is removed. Desktop-only — reaches the alpha cohort with the next bundled-sidecar build.
  • Fix: the native Miro export no longer 401s after you switch projects. The serve process now resets its bearer auth token on a cold start, so the desktop app's token stays in sync with the freshly-booted server and the Send-to-Miro request authenticates instead of failing 401. The serve change ships on PyPI; the symptom was desktop-only.
  • Desktop embedded report suppresses the web sidebar rails and close-×. In the macOS app the native sidebar owns project navigation, so the SPA's own sidebar rails and close button are hidden when the report runs embedded in the app. Theme/CSS change ships on PyPI but only takes effect in the embedded (desktop) context.
  • Contributor tooling: a dev-only Run Inspector and a native Debug menu. bristlenose serve --dev gains a Run Inspector (/api/dev/run, dev-gated + auth-enforced) that visualises instrumentation the pipeline already captures — llm-calls.jsonl, pipeline-events.jsonl, timing — without adding new logging paths, and the macOS DEBUG build gains a Debug menu (Run Inspector at ⌃⌘R, a live diagnostic-fixtures submenu, reveal-.bristlenose / open-log / copy-build-provenance). A build-time sidecar-staleness gate fails the desktop build loudly when the bundled sidecar is stale versus the Python source. Developer-facing; no change to the report or exports.

0.16.027 Jun 2026

  • Send to Miro — push your analysed quotes onto a Miro board. A new export builds a first-draft research wall: a fresh board each time, one column per section then per theme (the report's left-to-right order), pink section headers and yellow quote stickies stacked session-then-time, coloured by sentiment. Connect once by pasting a Miro access token (guided setup at bristlenose.app/docs/send-to-miro.html), name the board, choose colour-by, optionally link quotes to hosted clips, and preview the whole board credential-free before pushing. Participant display names never leave your machine — stickies carry speaker codes (p1, p2) only, and hidden quotes are excluded. Experimental, phase 1 (paste-token; one-click browser connect comes later). In the SPA export menu now; the macOS native menu arrives with the next bundled build. SECURITY.md documents Miro as an opt-in sub-processor.
  • Desktop: native SF Pro typography, with a Typography setting. The macOS app now renders on a calibrated SF Pro type scale aligned to Apple's text styles, and a new Typography setting switches between SF Pro and Inter. The type scale ships in the theme (so the in-app report matches, via WKWebView type-parity fixes); the setting is the macOS app.

0.15.1924 Jun 2026

  • Desktop: the macOS project sidebar is rebuilt on native AppKit. The sidebar moved off SwiftUI's List — which had hit selection and tap dead-ends on macOS 26 — onto a native NSOutlineView, bringing the row affordances the old list couldn't carry: two-line project rows (icon · name · session count, with a live status subtitle beneath), native activity and copy rings with hover-to-cancel on the row itself, status glyphs for can't-find and iCloud-evicted projects, the failure → diagnostic popover opened from the row, project and folder context menus, and Finder folder-of-videos drops wired into the sidebar. The migration came with its own test seams — pure ProjectCellSpec, OutlineNode, and DropRouting helpers with unit coverage — so the AppKit layer isn't an untested blind spot.
  • Desktop: the five report views become a lens rail, with a native window title + session subtitle. Project · Sessions · Quotes · Codebook · Analysis move out of the toolbar picker into a lens rail at the top of the sidebar; switching lens updates a native window title and a lens-contextual session subtitle (the old toolbar title pill is gone). Desktop-only (Swift) — both land in the alpha cohort with the next bundled-sidecar build; the PyPI / Homebrew / Snap package gains the supporting interface strings across all seven languages.

0.15.1821 Jun 2026

  • Desktop: copy progress moved onto the project row, and switching back to a recent project is now near-instant. Two macOS-app refinements. (1) When you drag files into a project, the copy progress — a determinate ring, a "Copying · N%" subtitle, and hover-to-cancel — now rides that project's sidebar row instead of a separate toolbar pill, so per-project status lives where the project lives (the standalone copy pill is gone). (2) Switching back to the immediately-previous project re-points to its still-running ("parked") server instead of tearing it down and restarting, so the boot wait disappears for the common back-and-forth switch — the "warm-sidecar pool." Localised across all seven languages.

0.15.1718 Jun 2026

  • Default Claude runs no longer break when Anthropic retires a model. Bristlenose's built-in Claude default pointed at claude-sonnet-4-20250514, which Anthropic retired on 15 June — so from that day a plain bristlenose run on the Claude provider (with no --model set) failed at the topic-segmentation stage with a 404 model_not_found. The defaults now track current aliases (claude-sonnet-4-6, plus claude-opus-4-8 where Opus is offered), updated across every place a model id is named — provider registry, config, and pricing — so the next retirement is a one-line change rather than a forty-file hunt. Pin a model with --model or BRISTLENOSE_LLM_MODEL and nothing changes. Ships on PyPI.
  • Quote cards pack tighter on the report, where the browser supports it. The quote grid now uses CSS Grid Lanes masonry (Safari 26.4+ and the desktop app's WebKit) so variable-height cards settle into the shortest column instead of leaving ragged gaps — a progressive enhancement that falls back cleanly to the existing grid everywhere else, with cards kept in reading order for keyboard and screen-reader users.
  • The desktop sidebar shows real progress on cached and first-time runs, instead of a frozen "Analysing…". The per-stage verb ladder (Identifying speakers → … → Rendering) was driven only by the timing estimator, so two run types never advanced it — cache-verified / transcript-only runs (the only per-stage signal lived inside the "stage actually ran" branch), and every new user's first few runs (the estimator stays silent until it has ~4 runs of history, so day-one runs showed nothing). An estimator-independent signal now fires at the entry of each analysis stage, carrying the last-known ETA so warm runs are unchanged (the determinate ring doesn't regress) and cached / cold-start runs advance the verb with the ETA simply absent. Python-only — ships on PyPI and in the desktop sidecar; no change to the report or exports.

0.15.1610 Jun 2026

  • Quote extraction no longer fails on dense interviews when the model's output limit is small. Some models cap how much they can write in a single response — ChatGPT's gpt-4o stops at 16,384 tokens, small local models far less — and on a long, quote-rich transcript the quote-extraction stage could hit that ceiling mid-response: the JSON came back truncated and the whole run failed. We were seeing this on roughly one run in three against dense ChatGPT transcripts (caught 8 Jun during a real interview-analysis session). Bristlenose now detects the truncation, splits the transcript at a natural topic boundary — falling back to even halves when no confident boundary exists — extracts quotes from each piece separately, and merges the results, recursing up to three levels deep (≤8 chunks) for the densest material. Duplicate quotes that straddle a split are removed by matching their verbatim text, and the split is all-or-nothing per interview so a partial failure never leaves a half-analysed session. A new typed TruncatedResponseError carries an OUTPUT_TRUNCATED cause through the pipeline (mirrored on the desktop side so a truncation reads honestly, not as a generic failure), and the cloud LLM client now retries up to six times on rate-limit bursts. The split-point picker was deliberately kept to two tiers — a high-confidence topic boundary from the segmentation stage, then mechanical halves — after a third "moderator question pivot" tier was dropped: topic shifts in skilled interviews emerge from semantic drift over many turns, not from lexical signposts. Verified on Claude with a forced low cap (A/B), on a full real-interview pipeline, and against the live gpt-4o SDK; every quote still appears in exactly one report section. Ships on PyPI — benefits every provider with a tight output cap.

0.15.159 Jun 2026

  • Čeština — Bristlenose now speaks Czech, and it's the first language our users asked for. Czech (cs) joins Spanish, Japanese, French, German, and Korean across the desktop app, web report, and settings (Settings → Language → Čeština). The backstory is the nice part: a volunteer signed up to translate Bristlenose into Czech on Weblate before we'd shipped the language — the first organic demand signal for a locale we've had. So rather than hand them a blank slate, we machine-seeded a complete Czech baseline (all 8 namespaces + preflight, ~950 strings) with proper Czech four-form CLDR plurals — one / few / many / other — for them to react to and refine. The seed is fill-empty-only (it never overwrites a contributed string), and Weblate's own translations win on the next sync, so the human work is always safe. Apple-HIG Czech glossary terms added (Uložit, Zrušit, Zavřít, Kopírovat…).
  • A coverage sweep so more of the app actually translates — settings, codebook, transcripts, accessibility labels, the desktop pipeline — plus the first Czech-correct plurals. Adding Czech surfaced how many strings still hard-coded English and so reached none of the seven languages; this pass wired them through i18n. Newly translatable: the Settings → Config reference (72 category + setting labels), the Codebook's "New group" / "New code" defaults, the transcript page's loading / error / empty states, ~30 React aria-labels and tooltips across eleven components (so screen-reader users on a non-English locale hear their own language), and the desktop pipeline-activity status — "Analysing…", "Stage 3", "Queued", "Elapsed", the popover header and the Stop button. The headline fix is grammatical: every desktop count-bearing string used a hardcoded count == 1 ? one : other selector that could never reach Czech's few form (counts 2–4), so a new I18n.pluralCategory() computes the real CLDR category and both the diagnostic-overflow text and the three sidebar chrome counts (interview / unanalysed / missing) now render the right form — proven by Swift tests against the live Czech locale. New guard tests pin locale-key parity, {{placeholder}} consistency, the CLI↔GUI string mirror, and four-form completeness. Translation quality for the machine-seeded strings remains the native-speaker community's to refine on Weblate; this pass is the mechanical groundwork. Mostly internal / UX — no API change.

0.15.147 Jun 2026

  • Desktop runs the LLM provider you actually chose — and an LLM failure no longer reads as "Transcription failed". Four diagnosed defects in the desktop provider-resolution path, closed together. (1) The AI-consent resolver no longer flips a deliberately-chosen provider back to Claude when its cached verdict is merely absent (absent ≠ invalid), so a configured ChatGPT / Gemini / Azure account survives the consent sheet. (2) Spawn-time overlayPreferences injects provider and model as a matched pair instead of a bare global model — the bug that ran gpt-4o under the Anthropic endpoint and 404'd. (3) The failure classifier checks LLM-error markers before Whisper markers and names the provider in its human summary, so an out-of-credit or bad-key LLM failure stops masquerading as a transcription failure; degradedBody no longer hides the captured cause. (4) The Settings status board is now eager — every provider row paints from cache the moment Settings opens and silently reconfirms, retiring the lazy dashboard and the activation no-op. Out-of-credit (402) becomes its own sticky state shown through a cached green, with activation decoupled from strict-online via canActivate; a pure, unit-tested resolveStatus centralises the cache-vs-masking decision. Reviewed by six agents + William.
  • Desktop status polish — copyable help, ⌘, from any focus, honest offline caption. Provider status explanations now render as markdown (backtick command → inline monospace) and are selectable, so every explanation is copyable; embedded shell commands (today Ollama's ollama pull … / ollama serve) get a one-click monospace copy row reusing the diagnostic popover's silent copy idiom (no "Copied" flash — the Finder / Safari pattern), backed by a pure LLMValidator.shellCommands(in:) extractor. ⌘, now opens the native Settings window from report focus too — BristlenoseWebView was claiming the key equivalent while it hosted the first responder, so Settings previously opened only from native chrome (⌘1/2/3, ⌘F, ⌘⇧E unaffected). The offline status caption softens "Your key is fine" → "Your key was fine" — offline we can only vouch for the last known-good check, not the current key; 402 / 429 messages keep present tense because there the key authenticated. Status-indicator vocabulary settled on colour + label with no per-state glyphs (the blue selected-tick vs green OK-tick collision was decisive), and the provider / keychain design docs are trued to the shipped data-protection-keychain + eager-status-board reality. New desktop.llmSettings.copyCommand string across all 6 locales. Desktop-only; reaches the alpha cohort with the next bundled-sidecar build.

0.15.134 Jun 2026

  • bristlenose pipeline view gains per-(provider, model) grain (feature rung v2). The pipeline view added in 0.15.11 now renders a row per (provider, model) pair instead of one row per provider: each LLM stage shows Claude / ChatGPT / Gemini / Azure with their individual models, per-model editorial quality ratings, and a recommended axis split from default (the first build where they diverge — Opus 4 and gpt-4o are recommended over the defaults). llm_summary deleted in favour of per-stage rendering; payload schema_version 3 → 4 with a _migrate_schema placeholder hook. CLI and the React Settings → Pipeline tab both render the sectioned-flat matrix with quality glyphs, inline notes, and a compact symbol key (Availability / Quality subheadings group the glyphs; italic = from-your-settings). Column alignment fixed across every stage table (table-layout: fixed + shared colgroup so transcription / anonymisation line up with the LLM tables); the current/in-use row carries the app's selection wash + aria-current + sr-only label rather than a loud badge, and collapsed-provider rows render flush-left at provider level so ChatGPT / Gemini / Azure read as peers of Claude, not its children. list_models(timeout) uses errors="replace" so a wedged Ollama binary can't break probe_host()'s never-raises contract. Provider naming stays product-names; the account-vs-product rename + logos are parked post-TestFlight. Feature rungs and schema_version are decoupled both ways (internal payload restructure with no UX change; render-only rungs over data the schema already carries) — documented in docs/design-pipeline-view.md. New test_models.py declaration invariants + _collapse() unit tests; brittle model-id pins dropped. Ships on PyPI (CLI surface).
  • Desktop: on-device (Ollama) setup is now a model-first "flow B", and consent activates the chosen provider. Two desktop landings. (1) Flow B setup: a toolbar pill drives phase popovers — choose model → get Ollama → wait → download → finishing — replacing the old OllamaSetupSheet. Honesty rule throughout: a static hourglass while Bristlenose is waiting on the human (download Ollama, daemon spin-up), a moving bar/spinner only while Bristlenose is actually doing work, red reserved for genuine failure — no timeout-to-failure, no animation when nothing is moving. The pill sits in the toolbar .status zone (its own trailing slot, not absorbed into macOS 26's primary-action capsule). Cancellation guards on every late daemon-snapshot write so a re-entry or cancel can't be clobbered by a stale async result. 43-key locale layer across all 6 desktop.json (German uses formal Sie). (2) Consent activation: the AI-consent sheet's Continue/Done path never wrote activeProvider, so a validated cloud key silently didn't take effect and the pipeline routed to a stale backend. Continue now runs the same activation the Settings toggle does, via a tested ConsentActivation.resolve helper that only switches when the active provider is local or unconfigured (never overrides a deliberate cloud choice). Stay-local applies the RAM-aware default and pulls the model ambiently through an app-level download surfaced in the pill, so the fetch survives sheet dismissal. Review fixes: failure pill error glyph/colour (xmark.circle.fill / .red), VoiceOver label + value on the pill, consent relabel + download-size hint, redundant UserDefaults mirror writes dropped (AppStorage already persists), consent audit-trail (DPIA anchor) documented in the desktop security audit. DEBUG-only menu-bar "Debug" harness cycles every pill/popover state for live QA — menu-bar, because macOS swallows .contextMenu on toolbar items with its own "Customize Toolbar" menu. Desktop-only; reaches the alpha cohort with the next bundled-sidecar build.
  • CI hardening — bounded jobs, pinned actions, least-privilege tokens. Three classes of fragility closed after the 2026-06-03 e2e hang burned toward the 6-hour ceiling: every job now carries timeout-minutes (e2e + perf-gate) with a bounded-retry wrapper around the Playwright install so a hung CDN download fails fast instead of cascading to sibling workflows; the three third-party actions that touch secrets (peter-evans/repository-dispatch, snapcore/action-build, snapcore/action-publish) are SHA-pinned; every workflow lacking one gets a read-only default permissions: block. Methodology documented in docs/design-ci.md as a six-class fragility map with standing audit targets and verify-pypi as a load-bearing fence. /usual-suspects gains a CI workflow lens routing security-review + silent-failure-hunter at any diff touching .github/workflows/**. Workflow + docs only — wheel unchanged.

0.15.1223 May 2026

  • Anthropic credit-exhausted is now reported as "out of credit", not "model unavailable". Preflight's BadRequestError classifier was substring-matching on credit_balance_too_low (an error.type token that doesn't appear in str(exc)) — the live SDK message is Your credit balance is too low to access the Claude API. ..., so the branch silently missed and fell through to the model-unavailable copy. Hit on 21 May on a clean bristlenose run after credit ran out; user saw "The requested model is not available for your Claude account" and was directed at the wrong fix. Substring widened to credit balance to match what the SDK actually emits, aligning with the runtime path at pipeline.py:339 (runtime classification was already correct; this was a preflight-only miss). Regression-pinned with the real SDK message in tests/test_preflight_api_key.py plus a sibling test asserting that the underscored-only token does NOT match, so any future "tidy-up" back to the original substring fails loudly. Broader LLM-error taxonomy work (16-kind LLMErrorKind enum, runtime-classification rewire, per-provider classifiers) parked as a separate branch — over-built for current evidence; let user reports drive what gets distinguished.

  • Desktop: ⌘⌫ no longer beeps on placeholder projects or multi-selection. The Project menu's hasProject predicate conflated "is a project selected" with "does the bridge handler hold a serve-able path" — so ⌘⌫ on a brand-new placeholder from + New Project (empty path) and ⌘⌫ on any multi-selection both hit AppKit's disabled-shortcut beep instead of removing the row(s). Receivers in ContentView.swift were already total over empty/mismatched selection (they guard on sole / iterate the selection set), so the gates were duplicating work the handlers already do. Removed hasProject and the four .disabled(!hasProject) sites; sharpened Show in Finder to just selectedProjectRevealablePath.isEmpty (the real predicate). William adjudication on the usual-suspects review added back two surgical gates so single-only operations dim correctly rather than silently no-op on multi-select — Rename and Move-to-folder submenu now .disabled(selectedProjectPath.isEmpty), matching the Finder / Notes / Mail / Things 3 indie-consensus pattern. Remove-from-Sidebar stays ungated. Desktop-only; doesn't affect CLI / PyPI users until the next bundled-sidecar build reaches the alpha cohort.

0.15.1121 May 2026

  • bristlenose pipeline + read-only Pipeline tab in Settings. New CLI verb (bristlenose pipeline, with --json and --stage <id> filter) and matching last-position tab in the React Settings modal that render the mixture of models Bristlenose currently uses across its pipeline stages: MLX/faster-whisper for transcription, the chosen LLM provider × model for speaker identification / topic segmentation / quote extraction / quote clustering / thematic grouping, Presidio (or "Off") for anonymisation, and an Apple Foundation Models row that returns Unknown from CLI until the Swift-side probe ships. Two-column definition-list layout; deterministic ingest/audio/parse/merge stages omitted (they don't participate in the mixture story). Host context strip at the bottom shows OS · arch · RAM · keys present · ollama status. Read-only on purpose — to change a backend you still edit settings or .env. Reuses _resolve_backend from s05_transcribe.py for the transcription row; no parallel resolver. Cross-language schema lock at tests/fixtures/pipeline-view-contract.json round-tripped by Python tests and the React tab. New module bristlenose/pipeline_view/ (renamed mid-branch from pipeline/ after the collision with the existing pipeline.py orchestrator surfaced); the underscore in the package name keeps the user-facing CLI verb intact. 28 new tests; full suite green. Design captured in docs/design-cli-improvements.md §Captured design.

0.15.1017 May 2026

  • Release pipeline actually fixed (test-only — wheel unchanged). PyPI had been stuck at 0.15.3 since ~10 May — six tag pushes (0.15.4-0.15.10) reached GitHub but ci/perf-gate failed on every one, blocking the release workflow. Root cause: the v0.15.4-era server-rendered status page interceptor (bristlenose/server/status_page.py) returns "Nothing to see here, yet." when app.state.last_run[1] has no terminus event, and the smoke-test fixture's bristlenose-output/ had never carried a pipeline-events.jsonl. Every Playwright run against that fixture got the status page instead of the SPA, #bn-app-root mounted empty, and perf-gate's DOM nodes — Quotes test timed out 30s into waitForPageReady — surfacing the failure five layers downstream of the actual cause and silently cascading six other perf-gate tests as "did not run". Fix: ship a fixed RunCompletedEvent line for project 1 in the smoke fixture's .bristlenose/pipeline-events.jsonl. Test-infrastructure only — tests/ doesn't ship in the wheel, so the v0.15.10 source artifact is byte-identical before and after this fix. Tag re-pushed after the fix landed on main; PyPI sees 0.15.10 for the first time.
  • Structural protections so the next silent-mount regression fails loudly. Three new layers: tests/test_server_status_page.py::TestSmokeFixtureMountsSPA asserts the on-disk smoke fixture boots into the SPA, not the status page (pytest layer — the cheapest place to catch the regression class). e2e/tests/spa-mounts.spec.ts is a standalone Playwright mount-precondition gate across four primary routes, running in the e2e job in parallel with perf-gate, with console.error / pageerror surfaced on failure so future mount issues land with stack traces instead of a bare 30s timeout. perf-gate.spec.ts itself gains a beforeEach browser-error capture for the same reason.
  • Post-push PyPI verification step in CLAUDE.md release flow. A tag push reaching GitHub is not the same as a release reaching PyPI. New release-flow step polls https://pypi.org/pypi/bristlenose/json for up to 15 minutes after git push origin main --tags and applies the v0.15.0 debouncing workaround (git push --delete origin v<X.Y.Z> && git push origin v<X.Y.Z>) if the workflow didn't fire. Five versions of silent-gap masking should not be repeatable.
  • SIGTERM lifecycle subprocess test resilience. tests/test_run_lifecycle.py::test_subprocess_sigterm_writes_run_cancelled and the SIGINT sibling have post-signal _wait_for_event timeout bumped 30s → 60s. Heavy-load Python 3.12 ubuntu-latest runners under full CI matrix contention have been observed missing the 30s window — flake on top of the perf-gate failure, picked up opportunistically while the release pipeline is open.
  • --codebook=<slug> flag + bristlenose codebooks subcommand. CLI plumbing for selecting an AutoCode codebook framework from the command line. bristlenose codebooks lists the nine available templates (sentiment, uxr, cli-ux, garrett, morville, nielsen, norman, yablonski, plato) with title and author; --codebook=<slug> on run and analyze validates the slug against the YAML inventory and exits cleanly with the available list and a pointer to the subcommand on a typo (preflight-abort convention, exit 2, matches the v0.15.8 banner mapping). Validation only — the pipeline doesn't auto-run AutoCode against the selected codebook yet; the flag stores the preference on BristlenoseSettings.codebook for a follow-up branch to wire the consumer side. C1 from the brew-first-run + IKEA-call cluster; the reframe (11 May) deliberately deferred the "auto-run default codebook" UX moment to a later desktop codebook-picker chunk, gated on cohort calls. Reviewed via three agents (code-review, james-bach, william-of-ockham); Rich-markup escape applied at the interpolation site even though no current YAML triggers the gotcha, future-proofing per the CLAUDE.md audit-on-write convention. New list_available_templates() + list_available_slugs() helpers in bristlenose/server/codebook/ give the enabled-filter a single source of truth across the validator and the subcommand. 11 new tests; full suite 2681 passed.

0.15.916 May 2026

  • Multi-project Phase 2 #11 + #14 — drag-onto-folder copy machinery + folder watcher foundation. Two big landings on top of the v0.15.8 multi-project foundation. Drag-onto-existing-project now goes through a copy path (CopyMachinery) instead of reference-in-place — progress pill with per-file estimate, NewFilesSheet stub for post-copy review, regression-pin tests in CopyMachineryTests covering collision-rename and folder-name preservation. Folder watcher: NSFilePresenter + SQLite ingested-set table (opened with immutable=1 for the read path) tracks what's been seen per project; count pill on the row surfaces the delta against the project's current input set; clicking opens a sheet for triage. Two review passes ran on top — first pass: F1 iCloud-evicted detection, F3 debounce scans, F6 SQLite busy-handling; second pass: F49 cloud-evicted glyph swap (icloud outline, not icloud.and.arrow.down — that arrow connotes download-in-progress, wrong meaning here), F50 pointing-hand cursor on the delta button with no underline (native idiom, not web anchor), F52 session count uses .footnote (function-correct per Apple's text-style scale, not "12pt eyeballed"), F53 subtitle text uniformly .secondary (state via glyph, not via graduated text colour), F59 / F62 i18n + comments. NewFilesSheet flagged as TF scaffolding for post-TF SPA migration — data views belong in the React project dashboard, not native chrome. Row reshape: session count moved onto the title row, deltas moved into the subtitle, Schema A dates throughout.

  • Sidebar honesty wave 2 — .ready gated on disk evidence + per-reason cantFind glyphs. v0.15.8 sealed the "fake-success" class of bug at the pipeline layer; this pass closes the matching surface in the sidebar. A re-opened project that had completed analysis weeks ago briefly showed "analysing…" because the row state was reading is_running instead of inspecting bristlenose-output/ for completion artefacts. .ready now requires disk evidence — a completed run record plus at least one canonical output file — so re-opens read "Ready" immediately. cantFind glyph specialises per reason: .movedquestionmark.folder, .cloudEvictedicloud (outline), .permissionDeniedlock.shield, .removedfolder.badge.minus. Volume remount no longer regresses to .moved when the bookmark resolves cleanly to the original path. AppleDouble (._*) sidecars and .DS_Store skipped across every directory scanner (is_os_metadata() in bristlenose/utils/fs.py, applied at discover_files, load_transcripts_from_dir, and the server importer scan sites) — hit 16 May 2026 when a project was copied to an ExFAT SD card and ._s1.txt looked like a transcript to the pipeline and crashed utf-8 decode with UnicodeDecodeError: byte 0xb0. Swift ProjectFolderWatcher already filters via .skipsHiddenFiles + name.hasPrefix("."), so the Python side closes the symmetry. New tests/test_appledouble_filter.py pins the behaviour. CLAUDE.md now flags the trap for future scan sites.

  • Pipeline subtitle i18n across 6 locales. Sidebar row subtitles for in-flight pipeline state (Transcribing…, Extracting quotes…, Clustering themes…, etc.) now render in the user's locale — previously English-only because the strings were synthesised in Swift from raw stage keys. Translations across es / fr / de / ko / ja matched against the Apple glossary where possible; dt() overrides where the desktop register diverges from the CLI's. New keys flow through the existing six-locale desktop.json shape so subsequent additions stay symmetric.

  • Keychain hardening — biometric ACL on stored API keys + dev-build persistence. API keys held in the macOS Keychain now require Touch ID (or password fallback) to read, via biometric access control on the Keychain item — adds a "you, at the keyboard" gate to the key escrow even if the device is unlocked and another app is malicious. Distinct from disk encryption; it raises the cost of a piggybacked-process attack. Debug builds switched from Apple Distribution to Apple Development signing because Distribution-signed debug builds were burning Keychain ACLs on every rebuild — every debug Run prompted for Touch ID and asked the user to re-grant access, making the feature un-dogfoodable. Hardware-validated on the dev machine across multiple build cycles; the underlying keychain entry survives across rebuilds, restarts, and worktree switches.

  • HIG corpus mirror + citation discipline for review agents. Local Apple Human Interface Guidelines mirror added so what-would-gruber-say can cite HIG sections by path + paragraph anchor instead of paraphrasing from training data — same epistemics as docs/design-test-philosophy.md for what-would-james-bach-say, but for native-feel reviews. The scraper that built the corpus is written generic from day one so the public release (planned at github.com/cassiocassio/what-would-gruber-say) is a copy operation not a rewrite, mirroring the william-of-ockham pattern (MIT, 1 May 2026). Citation discipline is now expected for every Mac-app review finding the agent surfaces.

  • Misc polish. Window > Bristlenose menu item to reopen the closed main window — Mac default that every document-shaped app has; ours didn't. Session start time no longer floored to midnight on the dashboard — display was rounding started_at to the date boundary, losing same-day session ordering when multiple sessions were transcribed within one calendar day. In-flight switch modal: "Continue analysing" reframed as the cancel role so Esc and the default action keep the analysis running rather than killing it — HIG-correct default is the non-destructive option, and the Stop and Switch action carries the destructive style.

0.15.814 May 2026

  • Honesty everywhere — the pipeline, the install path, the sidebar all stop faking success. v0.15.7 was a pipeline-only meta-release; this is the substantive follow-up. The walks-fix-walks gate (7 May quality reset) demanded that "did this work?" be answerable everywhere in the app. Three currents land together:
  • CLI honesty (A-stream brew/IKEA cluster). bristlenose doctor now hard-errors with a brew/pipx/pip-aware fix message when [serve] extras are missing (was a silent runtime crash later in bristlenose serve); the message is zsh-glob-safe quoted to keep 'bristlenose[serve]' from being eaten as a shell pattern. The Rich console.print markup-eating gotcha — [serve] rendered as '' because Rich parsed it as a style tag — fixed at three print sites by adding markup=False to the relevant calls; a CLAUDE.md gotcha now flags any new console.print that interpolates package-spec / file-glob / version-range text. bristlenose run / serve / analyze all gate serve_deps in preflight before stage 1; when the pipeline abandons mid-run, PipelineAbandonedError maps Cause.category to a researcher-facing banner (QUOTA, API_REQUEST split from QUOTA, AUTH with provider name + key-shape suffix, NETWORK, API_SERVER, DISK, MISSING_INPUT, MISSING_BINARY) instead of a generic "Failed". The static-render naming is finally sealed: bristlenose render removed (replaced by a hidden Typer catch-all stub that redirects to run / serve with a friendly message), --static deleted as a run option. --no-serve restored as a hidden flag — A3's first pass deleted it alongside --static, the doc sweep caught it being aliased on the macOS sidecar's PipelineRunner.swift call site separating two semantically-distinct concerns (suppress static render vs suppress auto-serve), so it came back without the misleading --static alias. _QUOTA_RE split into QUOTA + API_REQUEST; _AUTH_RE extended to cover real Anthropic / OpenAI SDK 401 strings + tightened to drop bare-word false positives. Doc truing across man page, README, manual, CLAUDE / CONTRIBUTING / DEVELOPMENT / SECURITY, and 20 design docs in the same release.
  • Stage-cache honesty (A4). Abandon-check now fires BEFORE mark_stage_complete (was after — the bug at pipeline.py:1308 that produced fake-success states where an LLM-failed stage cached its empty output and on rerun was skipped as "already done"). s08 PII removal added abandon-check. s10 (quote extraction) and s11 (quote clustering) flipped from soft-fail to hard-fail: StageFailure is now emitted at the LLM call site BEFORE fallback runs, so the upstream cache and downstream pipeline both know the truth. mark_stage_complete refuses to record content_hash for empty content (defence-in-depth: even if a stage misbehaves, the cache stays honest). Privacy contract on cause.message — class name + provider + stage only, never str(exc) (exception strings can carry PII / API keys / file paths). Whisper-zero-segments treated as success (silent interviews are a real research signal, not a failure). topics bucket added to PipelineSummary; cross-branch contract fixture bumped to v5. 470 lines of new tests across test_pipeline_abandon.py / test_manifest.py / test_run_lifecycle.py.
  • Long-audio quality (B1). Whisper-loop band-aids: collapse_adjacent_repeats() post-processor catches Whisper looping on the same word ("thanks thanks thanks", "facebook facebook") while preserving natural interjection doubling ("No. No. No.", "yeah yeah"). Three Whisper params tuned for mlx-whisper: condition_on_previous_text=False (stops decoder context-poisoning across silent gaps), no_speech_threshold=0.85 (raises bar for hallucinated transcript in silence), compression_ratio_threshold=1.8 (catches repetition loops). Speaker propagation surface: regression-pin unit tests for moderator/participant heuristic + pct_words (defends against silent flip on future refactors); design-doc clarification + INFO log surfacing the architectural limit ("LLM splitter forward-propagates last label from 5–8 min sample window past the window"). Pre-implementation diagnostic against preserved interview artefacts caught two of three reported bugs as already-fixed or by-design before any code was written.

  • Multi-project foundation (Phase 0 + Phase 1) — the sidebar starts telling the truth about each project's state. Phase 0 schema lock: ProjectAvailability enum (ready / not-found / cloud-evicted / permission-denied) with last_seen_at timestamps in projects.json, propagated through BridgeHandler and the sidebar's ProjectRow. Cross-branch contract fixture v5 documents the schema for follow-up branches. Phase 1 UX wins: removing a project from the sidebar is now undoable via a 5-second toast (Cmd-Z works during the window) — undo restores both the row position and any in-flight scroll state; cancelling commits the removal. "Locate…" flow for not-found projects walks the user through Spotlight one-shot search → NSOpenPanel fallback → cancel with grace; the chosen path is captured via security-scoped bookmark and stored alongside the existing bookmark, so the next launch resolves cleanly even after another move. Spotlight one-shot uses a 2-second NSMetadataQuery (returns top match by display name; tie-break by depth then mtime); query cancels cleanly if the user clicks Browse first or dismisses.

  • Multi-project sidebar switching (Phase 2 #1/#2/#3). Sidebar-clicking a different project now tears down the current sidecar (SIGINT → 50ms poll → SIGKILL after 2s) and spawns a new one pointed at the picked project. In-flight pipeline runs prompt a confirm sheet (Stop and Switch / Stay) before cancelling — HIG-correct default-action "Stay" across all 6 locales, with Apple's negated-form translations (es No cambiar / fr Ne pas changer / de Nicht wechseln / ko 그대로 두기 / ja 変更しない). ServeManager.shutdown(timeout:) escalating teardown; switchProject(to:) orchestrator. Server /api/projects/* gets Cache-Control: no-store as belt-and-braces against WKWebView HTTP cache surviving the .id() rebuild. ProjectBookmarkLease (Mini-spec 3) ships as foundation with tests; the next-branch wiring (#14 folder watcher) consumes it. Reviewed by 6 agents + William's parsimony pass; finding 30 (in-flight cancel atomicity) parked — cost-bounded by existing manifest+resume; durability hardening is post-beta. Phase 3 (cloud-evicted single-state) remains open.

  • Pipeline-failure trust-UX (re-run failure ≠ broken project). Cohort-style QA on multi-project switching surfaced a fake-success cousin: clicking Run on a project that already has bristlenose-output/ produced Failed (unknown) + sidebar badge "Something went wrong" + Retry-that-just-fails-again in the popover. Cohort testers would read "the project is broken" — but the re-run attempt failed, not the prior analysis. New PipelineFailureCategory.outputExists (matches Python CauseCategoryEnum snake_case rawValue); categoriseFailure regex matches "output directory already exists" first in the chain so the broader whisper/model heuristic can't hijack it. Human summary in researcher register: "Already analysed — re-analysing would replace the existing results." Sidebar badge: "Already analysed." The popover swaps Retry → "Re-analyse…" which opens a destructive-confirm alert ("This will delete the existing analysis and run from scratch. Any researcher edits in this project will be lost.") before spawning with --clean. PipelineRunner.start(project:clean:) overload threads the flag through; queue path doesn't preserve it (queue + Re-analyse interaction is a follow-up if observed).

  • Drag-onto-project to add files, with project-onto-project rejection. Plan §11 data-integrity guard. Dragging a Finder folder onto an existing project's row in the sidebar adds its files via the existing addFiles path (reference-in-place, not Copy — adequate for typical cohort flow where source folders are stable on local volumes). Guard: when the dropped folder is itself a Bristlenose project (folderLooksAnalysedbristlenose-output/ with at least one canonical artefact), the drop is rejected with a non-modal toast ("'%@' is already a Bristlenose project — drag its source files instead"). Started life as a single-button Apology-Alert; cohort QA — "alerts for things users won't want to do anyway? that's not how Mac apps work" — flipped it to a toast per HIG (alerts are for decisions, not explanations). Self-drops silently ignored. Six locales updated; out-of-scope deferred to chips: actual file copying, progress pill / Cancel / rollback for cross-volume multi-GB drops, "folder that contains a project" rejection (rare nesting), disk-space precheck, hover affordance polish.

  • Sidebar drop hit-test refactored to the native SwiftUI pattern. Original implementation hand-rolled hit-testing on top of .onDrop + DropDelegate + GeometryReader + PreferenceKey plumbing in a custom coordinate space. The macOS coordinate-space mess (.global returns bottom-left Cocoa coords; .named/.local returns top-left SwiftUI coords; DropInfo.location documented as local but empirically inconsistent across SwiftUI versions — Apple forum thread 732076) produced two real bugs in cohort QA: drag-highlight followed the cursor at a constant y-offset (rows lit up one off the cursor's actual position), and the project-onto-project reject alert was silently unreachable because the hit-test missed the target. Two patches tried to fix the coordinate-space math (record frames in .global, then self-healing hit-test that prefers direct match) before the native-pattern refactor replaced ~209 lines of bespoke machinery with per-row .dropDestination(for: URL.self, action:isTargeted:) (since macOS 13). SwiftUI now manages coordinate-space concerns internally; URLs come pre-loaded — no NSItemProvider async dance. Polish: .onDisappear clears the hover-target state as insurance against the macOS intermittent bug where isTargeted=false doesn't fire when the cursor drag-leaves the window entirely. Chipped for follow-up: FolderRow accepting Finder URL drops directly, internal-project payload as a Transferable newtype (custom UTType app.bristlenose.project-id) instead of String, drop-affordance VoiceOver label.

  • Locales now reach the host bundle under sandbox. The Xcode "Copy Sidecar Resources" script phase only declared en/common.json as an input/output anchor; editing any other locale file (en/desktop.json, the five non-en locales) didn't trigger the phase to re-run — Xcode's dependency analysis saw no relevant input change. The rsync inside the script never fired and the bundle kept yesterday's copy of every locale file. Symptom: new desktop.chrome.* keys rendered as raw key strings in alert titles/messages under the default scheme (sandbox=on, sidecar=bundled). Closes a longstanding "i18n locales not reaching host bundle under sandbox" gotcha. Fix: alwaysOutOfDate=1 on the phase, same pattern Generate Build Info already uses; the rsync body is idempotent and the cost is ~50ms of syscalls per build.

  • Two pre-existing UX bugs surfaced by cohort QA. common.cancel rendered as a raw key string in three alert dismiss buttons because the key actually lives under common.buttons.cancel and common.activity.cancel — none at the top level. Fixed all three call sites. File-subset projects (projects analysed against an explicit file selection rather than a whole folder) gated their detail pane on inputFiles != nil alone, so even file-subset projects with completed analysis showed the UnsupportedSubsetView instead of their report; same principle as the trust-UX work above — run state shouldn't block viewing what's already there. Added pipelineHasViewableData (true for .ready and .partial), ANDed with the inputFiles check.

  • HIG: trim ellipsis from "Choose Icon" menu label. Opens a popover, not a dialog — ellipsis is wrong per HIG. Dropped from all 6 locales. Full flyout/submenu UX (trim palette, hybrid Finder-tag-colour, custom-grid Menu) captured for later.

  • Swift 6 main-actor isolation fix. SpotlightOneShot() as a default argument was evaluated in the caller's isolation context, but the initializer is @MainActor-isolated. Swift 6 strict concurrency rejects this even though LocateFlow itself is @MainActor. Defaulting to nil and constructing inside the body keeps the call MainActor-scoped without forcing every caller into an async path. Was blocking Xcode build on main.

  • CI hygiene. .tool-versions now pins node 24.6.0 + python 3.12.13 (mise / asdf / rtx all read this; CI workflows derive Node + Python versions from the same file). No more "works on my machine but fails on CI because mise gave me 24.8.0 and CI runs 24.6.0" drift. Branch-protection-style no-red-CI-merges policy documented in CONTRIBUTING.md — main branch only accepts green merges; two pre-existing CI failures surfaced by the gate are noted in TODO with reproduction steps. Release-pipeline fix from v0.15.7 stays in (the Node 24 measurement-artefact 210 → 215 kB ceiling re-baseline; frontend/CLAUDE.md documents the call: runtime currency / security / dep coherence outweighs sub-1% gzip headroom).

  • Workflow tooling for parallel Claude sessions. Three skills land:

  • /end-session sign-off sentinel + /close-branch drift check/end-session writes .claude/last-end-session.json (gitignored) recording phase outcomes + HEAD sha. /close-branch reads it: warns before archiving a branch that was never end-sessioned, or that has drifted (commits added) since sign-off. Stops the "I thought I closed that" class of mistake.
  • /new-feature --print-launch-url flag + Step 14 hand-off — branch-creation can now emit a claude:// deep link the user can hand to the next session; the spawned session reads the canonical handoff path automatically. Pairs with the existing handoff convention.
  • /sitrep skill — situation-report with backward truth-check (plan-docs vs git over a rolling window) + forward candidate ladder. Sprint-aware grid format: prose preamble → sprint frame + contents tables → recent ships → in-flight branches → forward ladder (with sprint-tag column) → recommendation → suggestions. William of Ockham agent triaged the first review pass to 7 spec edits before the skill landed.

  • Agent ecosystem. New what-would-james-bach-say reviewer agent — test-placement reviewer in the Bach / context-driven-testing tradition: catches tests at the wrong layer, tests that lock implementation rather than invariants, coverage gaps for invariants the user would actually notice, brittle exemplars that future agents will imitate, "while we're here let's also test X" scope creep. Sibling to code-review / perf-review / security-review — this one is testing taste and proportion, not correctness. Reads docs/design-test-philosophy.md for the project's house position. Reminder slated to audit aggregate impact after a month.

  • /true-the-docs v2. New --claude-pointers mode reconciles the references-and-pointers layer of CLAUDE.md files specifically (which can drift independently of substantive prose). Phase 5 sweep landed: 3 historical docs archived, CLAUDE.md bloat trimmed, source-aware archival pattern documented in the public archive README. Sibling to A3's design-doc sweep — same epistemics, different doc class.

  • Sidecar build fixes. PyInstaller bundle now ships inflect + typeguard .py sources alongside their .pyc — both packages call inspect.getsource() on their own functions at runtime, which fails on a bytecode-only bundle. Fix: collect_data_files for both packages. Sidecar resource-copy step also switched to rsync -aL (was cp -RL) to dereference symlinked sources cleanly — cp -RL was breaking the mlx metallib's internal rpath symlinks under macOS sandbox.

  • Frontend bundle ceiling 210 → 215 kB. Node 20 → 24 zlib upgrade (1.2.13 → 1.3.1) compresses the live SPA bundle slightly differently; the 0.75 kB "overage" against the 210 kB ceiling was a measurement artefact, not a regression. Re-baselined: current live SPA is ~210.5 kB gzipped against the new 215 kB ceiling. frontend/CLAUDE.md documents the call.

0.15.712 May 2026

  • Release-pipeline fix that unblocks v0.15.4–6 reaching PyPI + Homebrew. Three independent CI failures had been silently stacking up since 10 May, leaving PyPI and the Homebrew tap pointed at 0.15.3 while the source tree was on 0.15.6. No user-facing source change in this version; the entire v0.15.4–6 changelog (preflight block, server-rendered status page, SPA auto-refetch, sandbox-safe Export, structured failure causes, etc.) is what users actually pick up when brew upgrade bristlenose (or pip install -U bristlenose) catches up to 0.15.7.
  • tests/test_pipeline_abandon.py — the ffmpeg preflight introduced in v0.15.5 aborts on Linux CI runners (no ffmpeg on PATH). The test already patched the Whisper preflight; the same bypass is now extended to ffmpeg, with a reminder for future preflights.
  • tests/test_serve_auth.pytest_spa_html_sets_cookie hit /report/, which returns 500 (fail-loud, by design since C3) when the React bundle is absent. CI's Python test job doesn't run npm run build. Switched to the existing _VITE_INDEX_HTML + _STATIC_DIR patch pattern from test_serve.py — a 5-line fixture change rather than restructuring CI to build the bundle in 8 matrix cells.
  • .github/workflows/{ci,release}.yml — Node 20's zlib 1.2.13 compressed the live SPA bundle to 210.75 kB (over the 210 kB limit); Node 24's zlib 1.3.1 compresses identical bytes to 204.4 kB. The 0.75 kB "overage" was a measurement artefact of the CI Node version, not a real regression. frontend/CLAUDE.md already documented Node 24 LTS as required; CI is now in sync.

0.15.611 May 2026

  • Server-rendered status page for runs the SPA can't renderbristlenose serve's /report/* catch-all now serves a server-rendered page (no React mount) when the project has no completed run, or the latest run failed or was cancelled. The SPA's invariant becomes: it only mounts when there's data to render. Three states surfaced — no-run-yet (CLI vs desktop copy via BRISTLENOSE_PLATFORM), failed (with cause + log tail in a <details> block, sourced from pipeline-events.jsonl), cancelled. Reuses the five-kind MessageKind taxonomy from bristlenose/ui_kinds.py — no sixth kind invented. Page styling lives at bristlenose/theme/templates/status-page.css and uses the design system's tokens exclusively (sibling to report.css / transcript.css / print.css / export.css). The event-watcher startup seed in bristlenose/server/app.py also widened to seed failed and cancelled termini, not just completed, so non-happy-path runs survive a server restart. English-only in v1; locale fills for es/fr/de/ko/ja batched into the next translation pass

0.15.511 May 2026

  • First-run preflight block: Whisper / ffmpeg / API-key / spaCy, all before stage 2. Eight-slice landing of docs/design-cli-just-works.md. Triggered by a 2-hour first-run call with a friendly CTO on 9 May where four blockers fired in sequence (silent ~1.5 GB Whisper download, Ctrl+C resumption opacity, --redact-pii hard-failing on missing en_core_web_sm, API key with no billing balance burning six minutes of transcription before stage 8 noticed). Every gap now surfaces up front:
  • bristlenose/preflight/whisper.py — Whisper-model preflight with backend-aware cache probe (Systran/*model.bin, mlx-community/*weights.npz; tolerates HF Hub leaving config.json from an interrupted download). Framed banner above (model name, ~1.5 GB, "Cancellable with Ctrl+C; resumes cleanly next run."), HF Hub's native progress in the middle (Option B from the design-doc Debate 1 — we step aside; the Rich spinner stops + restarts around the fetch), ✓ Whisper model ready [Ns] below. WHISPER_SIZE_HUMAN is the single source of truth — referenced by both the banner and the doctor --fetch help text. New bristlenose <run|transcribe|analyze> --no-fetch aborts cleanly with the recovery message instead of fetching; bristlenose doctor --fetch pre-warms the cache for offline runs.
  • bristlenose/preflight/ffmpeg.py — distro detection from /etc/os-release (macos / ubuntu / fedora / arch / other). Per-distro install table with the matching distro row first, padding computed at print time so translations don't misalign. On macOS with Homebrew on PATH and brew --prefix writable, an explicit [y/N] offers brew install ffmpeg; default flipped to N so a hurried Enter never installs system software. The brew subprocess runs natively with the Rich spinner stopped, ✓ ffmpeg installed [Ns] after.
  • bristlenose/preflight/api_key.py + bristlenose/llm/billing_hints.py — paid-call validation against billing. Single-token /messages request (locked literal prompt ".", never user-derived; User-Agent: bristlenose/<version>) to the configured Anthropic or OpenAI account — costs ~$0.0001 and exercises the credit-balance path that a free probe misses. Anthropic 400 credit_balance_too_lowbilling_empty; OpenAI 429 insufficient_quotabilling_empty; AuthenticationErrorinvalid_key; BadRequestError (other) → model_unavailable; RateLimitErrorrate_limit. Each maps to a translatable wrapper around the code-owned ProviderBilling registry (console_url / keys_url / billing_url / minimum_note per provider). Azure / Gemini fall through to the generic "Provider says: …" copy per finding 24 in the design doc. Result cached for 24 h per provider in state.json (mode 0o600, atomic write via os.open + O_TRUNC) at ~/Library/Application Support/Bristlenose/state.json (macOS) / $XDG_DATA_HOME/Bristlenose/state.json (Linux). Non-TTY runs still validate (CI catches expired keys); only the first-run source-attribution banner is suppressed in non-TTY mode. BRISTLENOSE_SKIP_PREFLIGHT=1 is the explicit escape hatch for spoofed-TTY CI runners. Network / TLS / corporate-proxy faults surface with a dedicated network error class rather than collapsing to the generic provider-error copy.
  • bristlenose/stages/s07_pii_removal.py — spaCy en_core_web_sm (~12 MB) lazy-fetches on the first --redact-pii run via python -m spacy download. Single inline line, no banner ("fetch UX scales with fetch size"). The spaCy Console() instantiation moved into _ensure_spacy_model() so terminal-width detection isn't frozen at module-import time (sandbox-piping concern). MessageKind.SUCCESS / MessageKind.ERROR glyphs used for the done/failed lines.
  • bristlenose/utils/package_install.py — two install helpers (ensure_spacy_model, ensure_hf_model) with frozen-sidecar guards so a desktop-bundle accidentally hitting these paths raises FrozenSidecarError instead of silently failing against read-only site-packages. (ensure_wheel shipped in an interim draft, deleted in the post-review pass when it had no callers.)
  • Front-loaded as a single block in bristlenose/pipeline.py — all preflights fire between ingest (stage 1) and audio extraction (stage 2). After the block, the pipeline prints No more questions. ~X min (±Y min) to your report. Ctrl+C anytime. (estimate from the existing Welford estimator when warmed up; degrades gracefully without the parenthesis when it isn't). No prompt fires for the rest of the run.
  • Unified PreflightAbortedError in bristlenose/preflight/__init__.py for all three preflights; CLI catches once at the run / analyze / transcribe-only call sites and exits 2 with the recovery message.
  • i18n — new preflight.* namespace under bristlenose/locales/<loc>/preflight.json covering banner copy, install-table labels + commands, four error-class recovery wrappers, source attribution, closing line. English is the source of truth; es / fr / de / ko / ja currently mirror en and will get native review in the next translation pass.
  • Documentation truing — design doc updated with a post-implementation Status table mapping each item to SHIPPED / DEFERRED / PARKED; sections that drifted (state path, Whisper stage placement, lint-rule promise, no-account-yet flow) now carry inline corrections + deferral banners. New §"i18n implications" subsection covers namespace shape, code-owned vs translatable surface, translator workflow. SECURITY.md adds a fourth "Data leaves your machine only when" bullet covering the preflight call (one full stop, ~$0.0001, no transcript content, 24 h cache), a "What Bristlenose writes to your machine" appendix, and an HF Hub LFS-hash integrity caveat. docs/platform-text-map.md carries a CLI-only row for the new namespace. Man page (bristlenose/data/bristlenose.1, symlinked from man/bristlenose.1) gains an Options for doctor section, documents --no-fetch / --fetch / --yes / --static / --dev (the last three were pre-existing gaps), records state.json under FILES, adds exit code 2, and the BRISTLENOSE_SKIP_PREFLIGHT env var. scripts/bump-version.py now refreshes both version and ISO date in the .TH line on every bump (no more stale "March 2026" on a May release).
  • Deferred to follow-up (captured in .claude/plans/cli-just-works.md Decisions block + TODO.md): the no-account-yet branch (numbered URL flow + getpass + Keychain persist) — material UX work depending on the front-load collector; pipeline-events.jsonl Cause entries on preflight abort; whitelist enforcement for read-only commands; Keychain-source attribution refinement.

0.15.410 May 2026

  • SPA: auto-refetch on pipeline completion — new GET /api/projects/{id}/last-run endpoint and LastRunStore (module-level, single timer, 3 s poll, visibility-paused, in-flight skip, mount baseline). All four content tabs (QuotesTab, SessionsTab, ProjectTab, CodebookTab) accept refreshKey?: number; islands re-fetch when the store detects a new run_id. announce(t("announce.pipelineCompleted")) fires on run_id transition (skipped on first-poll startup-seed). Closes the gap where the browser SPA went silent after the pipeline finished — users now see fresh content within ~3 s without reloading. Import-then-publish race fixed on the server side
  • SPA: trust-UX layer on top of auto-refetch — manual refresh button in the NavBar (serve-mode only; hidden in the desktop app's embedded mode, where the OS toolbar owns chrome and 3 s auto-poll carries the trust signal alone). Refetch overlay (opacity dim + pointer-events-none) on Quotes / Sessions / Dashboard while a refetch is in flight, so the screen no longer snaps silently. Post-zero-quotes empty state on the Quotes tab: "Analysis finished but found no quotes. Check the source files or try a different LLM provider." Pre-pipeline empty state deliberately out of scope here — that surface belongs to the upcoming generic-failure-surface work and the desktop WelcomeView
  • Desktop: Export downloads route via WKDownload + NSSavePanel — sandbox-safe Export. Previously the browser/Vite serve path leaned on a synthetic <a download> click that the App Sandbox silently dropped — users got "Export" feedback but no file. The desktop shell now intercepts the Export request, hands the response to WKDownload, and prompts the user via NSSavePanel for a user-chosen destination. Six locale desktop.json files updated for the save-dialog strings. Edge case: round-tripping the locale JSON through json.load/json.dump rewrites every \u escape (or every literal Unicode, depending on ensure_ascii); fixed via targeted text-replace with per-value json.dumps(value, ensure_ascii=True). Gotcha now captured in CLAUDE.md
  • Desktop: surface structured Cause from events log in the failure pillEventLogReader now exposes the categorised Cause from pipeline-events.jsonl (10 categories: MISSING_INPUT, MISSING_BINARY, LLM_ALL_FAILED, etc.). The failure pill in the project sidebar renders the category-appropriate glyph + short label instead of the generic "Failed" badge. Pairs with the schema-coupled PipelineSummary rollup that landed earlier in this version. New Pipeline.run integration test exercises the transcribe-all-fail abandon path end-to-end. Review-pass fixes: tightened pytest.raises assertions to specific exception types, copy-button now confirms with brief visual feedback, "human" category label refined

  • CLI: glyphs + colours sourced from MessageKind taxonomy — the _print_step / _print_warn_step / _print_error_step / _print_cached_step family in bristlenose/pipeline.py now delegates to a single _print_stage(message, kind, elapsed) helper that consults bristlenose.ui_kinds.cli_prefix(kind) for the leading glyph + colour. New _say(kind, message) helper in bristlenose/cli.py replaces ~20 ad-hoc tinted console.print("[colour]...[/colour]") calls so glyph/tint comes from one table (CLI / popover / toast all in sync). Doctor + status icon dispatch also routes through cli_prefix. Decorative [dim]…[/dim] annotations untouched. No user-visible behaviour change beyond a few borderline-coloured lines now showing the correct kind glyph

  • Pipeline: structured per-stage failure summaries + abandon path for empty-data runs — every terminus event (run_completed / run_failed / run_cancelled) now carries an optional summary field with a PipelineSummary rollup (per-stage attempted / succeeded / failed counts plus categorised Cause per failure). When every transcription session fails or every quote-extraction LLM call fails, the pipeline now raises PipelineAbandonedError instead of silently writing a fake-empty report — the run terminates cleanly with no report file on disk. Two new Cause.category values (MISSING_INPUT, MISSING_BINARY); bare-name FileNotFoundError (e.g. 'ffmpeg' under sandbox) now categorises as MISSING_BINARY instead of UNKNOWN. Backward-compatible: legacy events log lines without the new summary field deserialise cleanly with summary=None. Schema-coupled with the upcoming desktop diagnostic-pill work but not runtime-coupled. See docs/design-pipeline-resilience.md 2026-05-07 changelog entry
  • Sidecar: prepend bundled FFmpeg dir to $PATH so mlx_whisper resolves under macOS App Sandboxmlx_whisper.audio.load_audio shells out to bare "ffmpeg" via subprocess.run, bypassing our bundled_binary helper. Under the sandbox the inherited PATH doesn't include Homebrew, so transcription failed with [Errno 2] No such file or directory: 'ffmpeg'. New prepend_bundled_to_path() in bundled_binary.py runs from bristlenose/__init__.py so the bundled directory is on PATH before any submodule imports. No-op for CLI / pip / Homebrew installs (no bundle dir to prepend). Same fix transparently covers faster_whisper and any other transitive bare-name shellouts
  • Sidecar: avoid /bin/ps exec under macOS App Sandboxrun_lifecycle._ps_start_time() now reads process start time via libproc proc_pidinfo(PROC_PIDTBSDINFO) on Darwin (same call Activity Monitor uses) instead of subprocessing /bin/ps -o lstart=. The exec was blocked by the sandbox and crashed the bundled sidecar at startup before serving a single byte. Linux unchanged (no sandbox blocker; keeps the /bin/ps fallback). Third libproc swap in the family (proc_pidpath, proc_listpids+proc_pidfdinfo, now proc_pidinfo)
  • Sidecar: serve React bundle via in-memory read_bytes under macOS App Sandbox — Starlette's StaticFiles (FastAPI mount path) opens files with mmap, which the App Sandbox can reject for bundled assets read from inside the .app. The serve-mode static handler now reads the React bundle into memory and returns it directly, bypassing the mmap path. Linux/CLI users see no change (path-existence + size-cap guards keep the read bounded). New regression coverage in tests/test_static_serving.py
  • Desktop: i18n sweep — LLM helpers, transcription settings, build-info, default project name — extracted hardcoded English literals missed by earlier passes: LLMProvider.swift activation toggle, status labels, console-link labels, Ollama description; TranscriptionSettingsView Backend / Model / Auto / hint / model-recommended interpolated label; BuildInfoSheet Close button; default "New Project" name. All six locale desktop.json files updated (en/es/fr/de/ko/ja)
  • Desktop: delegate locale to System Settings — removed the in-app language picker on macOS. Locale selection now flows through System Settings → Apps → Bristlenose, matching the macOS norm. I18n.swift reads Bundle.preferredLocalizations on every launch (Apple's BCP 47 matcher); INFOPLIST_KEY_UIPrefersShowingLanguageSettings=YES ensures the per-app language section is visible even for users with a single preferred language. Web/CLI serve picker unchanged. See docs/design-locale-negotiation.md

0.15.34 May 2026

  • Desktop: provider-switching hardening end-to-end — the "Change provider" flow now actually activates the new provider across Keychain, sidecar env injection, and UI. Four fixes landed together: Ollama choice persists to UserDefaults before the consent log writes (defeats a race where a crash between the two left the app in an inconsistent state); the Change-provider button activates the row first then falls back to a selector if activation didn't take; sidecar API-key injection is scoped to the active provider only (was leaking other providers' keys into the child env); LLM Settings lazy-loads Keychain reads to the selected row only (was prompting on every row render). Desktop-only — no PyPI surface change
  • Prompt-injection sentinel-tag boundary (Phase A) — adds a defence-in-depth boundary against prompt-injection attempts hidden in interview transcripts. Sentinel tags wrap the user-supplied content so the LLM can structurally distinguish data from instructions. Phase A is the boundary itself; later phases will tighten the prompt contract around it
  • Sidecar bundle trim (S1 + S2 + S3) — three rounds of PyInstaller bundle slimming: S1 excluded mlx_whisper.torch_whisper from the bundle (PyTorch was being pulled in transitively); S2 introduced a dedicated build venv so the bundle no longer carries the contributor's full .venv graph; S3 evicted torch and onnxruntime and switched mlx-whisper packaging to collect_all so its assets/ ship correctly. Net: significantly smaller sidecar, faster TestFlight uploads
  • Doctor: mlx-whisper recognised as a complete backend on Apple Siliconbristlenose doctor no longer reports a missing whisper backend on Apple Silicon when only mlx-whisper is installed (the bundled-sidecar configuration). Other platforms unchanged
  • Desktop: log-tail trust on sidecar exit-1-after-success — when the sidecar exits with code 1 after a successful pipeline run (rare but observed during sandbox shutdown), the desktop app now trusts the log tail rather than misclassifying the run as failed
  • Desktop: sandbox-on Debug i18n + resizable window — Debug builds with the App Sandbox entitlement enabled now load locales correctly and the window resizes properly on macOS Tahoe (was locking vertical resize)
  • Frontend dependency updates — minor/patch bumps to i18next, react-router-dom, eslint, eslint-plugin-react-hooks, typescript, typescript-eslint, vite, vitest (#91); react-i18next 16.6.6 → 17.0.6 (#90). The v17 breaking change in transKeepBasicHtmlNodesFor doesn't apply — no usage in this codebase
  • Test: relax maxHeight assertion for jsdom 29 compatibility — jsdom 29 normalises style.maxHeight = "0" to "0px" on read-back (matching real browsers); the previous toBe("0") assertion in AnalysisPage.test.tsx was leaning on jsdom 27's non-spec echo-the-input behaviour. Switched to toMatch(/^0(px)?$/) so the assertion passes on both versions, unblocking the upcoming jsdom 27 → 29 dependabot bump (#89, pending)
  • Tooling: /new-feature skill grew optional flags for self-contained branch handoff (handoff doc + invocation in one paste-able command)

0.15.22 May 2026

  • Bundled FFmpeg/ffprobe discovery for sandboxed sidecar — new bristlenose/utils/bundled_binary.py resolves FFmpeg and ffprobe from a bundle-relative path before falling back to $PATH. Lets the sandboxed desktop sidecar find ship-with-app binaries that aren't on the user's shell PATH. audio.py, video.py, and clip_backend.py route through the helper; bristlenose doctor reports the resolved path. CLI/PyPI users see no behaviour change — $PATH lookup is still tried
  • bristlenose/server/lifecycle.py — new module factoring out serve-mode lifecycle handling (parent-death detection, sandbox-friendly bind(0), graceful shutdown). Used by Track A A6 sandbox-native sidecar work; CLI serve callers get the same lifecycle path
  • PipelineRunner: SidecarMode.resolve migration (PR #96) — desktop sidecar invocation goes through the resolved-mode path, with logged mode + clearer error copy when the resolution fails
  • TLS sidecar redirect to certifi under sandbox (PR #97) — bundled sidecar uses certifi for TLS root certs when the system trust store isn't reachable from inside the App Sandbox. Desktop-only impact
  • Frontend size budget: re-baselined to live-SPA gzipped (lazy locales excluded from the budget figure since they're loaded on demand). Budget remains 320 kB
  • Docs: manual + README reconciled with shipped bundled-binary-helper; pricing reframed per hour of audio rather than per study; HANDOFF.md branch-handoff convention surfaced at worktree root via gitignored symlink

0.15.11 May 2026

  • Desktop alpha-readiness landed (Sprint 2 Track C C2–C5) — sandbox-safe API-key injection (Swift Keychain → env vars; Python no longer execs /usr/bin/security), libproc-based zombie cleanup (proc_listpids + proc_pidfdinfo + proc_pidpath — replaces lsof and /bin/ps, both blocked by App Sandbox), os.Logger with privacy redaction throughout ServeManager, key-shape stdout redactor (Anthropic / OpenAI / Google formats) as defence-in-depth against accidental key leakage in subprocess output, SidecarMode.resolve + three Xcode schemes for the dev escape hatch, privacy manifests for host + sidecar (TestFlight unblock), per-binary signing infrastructure, supply-chain provenance (THIRD-PARTY-BINARIES.md + auto-regen script). Desktop-only — no PyPI surface change
  • Desktop first-run experience (Sprint 2 Track B Branch 1)BootView unified boot/loading/failed surface, WelcomeView two-variant pattern (.firstRun + .noSelection), Beat 3 round-trip API key validation in LLMSettingsView, Beat 3b OllamaSetupSheet state machine (idle / installingOllama / waitingForDaemon / downloadingModel / finishing / failed) for local AI install, I18n.findLocalesDirectory() worktree-aware #filePath derivation, boot.* and welcome.* translation blocks added to all 6 desktop locale files (English drafts; native review owed for es/fr/de/ko/ja). Desktop-only
  • bristlenose serve fall-back to bundled theme CSS — projects that have never run the pipeline have no per-project rendered CSS at <output_dir>/assets/. The SPA's stylesheet link would 404 and the entire UI rendered as raw HTML inside the WKWebView. Server now registers a typed CSS route that prefers per-project CSS if it exists and falls back to the bundled theme otherwise
  • bristlenose doctor --self-test — new flag that verifies sidecar bundle integrity (spec → bundle file presence). Catches the "data files declared in .spec but missing from the built bundle" class of bug. Runs in desktop/scripts/build-all.sh step 2a
  • bristlenose serve fail-loud on missing React bundle — returns a "Build incomplete" 500 page instead of silently falling back to the static render. The static render is officially vestigial scaffolding now, not a fallback
  • Japanese (ja) locale completion — bulk-translated ~614 stub strings across 8 namespace files in bristlenose/locales/ja/, seeded glossary.csv with 83 ja rows. Alpha gate cleared
  • i18n: tag sidebar — extracted hardcoded strings, added 11 keys across all 6 locales

0.15.026 Apr 2026

  • Pipeline resilience: Phase 1f / 4a-pre — run-level event log + honest run state — pipelines now write a single-source append-only pipeline-events.jsonl recording how each run ended (started / completed / cancelled / failed) with a structured Cause object capturing category, code, message, provider, stage, signal. Replaces the inference path the desktop app previously used (which mis-classified interrupted runs as .ready). Survives crashes, recovers cleanly from torn writes, refuses concurrent runs against the same project. See docs/design-pipeline-resilience.md §"Run outcomes and intent"
  • Structured failure causes — exceptions categorised into 10 cases: auth / network / quota / disk / whisper / unknown (existing) plus user_signal / api_request / api_server / missing_dep (new). Same case names on Python and Swift sides for lossless JSON round-trip; word-boundary regex matchers anchor the categoriser to avoid false positives (e.g. "credit" no longer matches "credentials")
  • Cost as honest estimate — pipelines now stamp cost_usd_estimate + price_table_version + input_tokens + output_tokens onto every terminus event. UI surfaces via format_cost_estimate() as "~$0.46 (est.)" — never bare dollars. Token counts are real (provider returns them); USD is best-effort. SessionRecord extended additively with the same fields
  • Python-side PID file with start-time check<output>/.bristlenose/run.pid containing (pid, start_time, run_id) for cross-process liveness. Defeats PID reuse on busy macOS; mode 0o600 + O_NOFOLLOW. Lives inside the project folder so App Sandbox doesn't need extra entitlements
  • Stranded-run reconciliation — if a prior run died mid-flight (kill -9, OOM, hard reboot), the next bristlenose run notices the stranded run_started event and synthesises a run_failed for it before starting a new run. Honest fallback: cause is unknown, message is "Analysis stopped unexpectedly."
  • Desktop: EventLogReader.deriveState — Swift consumer of the events log. New PipelineState cases .partial(kind, stagesComplete) (transcribe-only completed) and .stopped(stagesComplete) (user cancelled) join the existing set. UI verb wiring (Resume / Retry / Re-analyse…) deferred to a separate desktop UX iteration
  • Desktop: BristlenoseTests target wired upxcodebuild test now runs 90 Swift tests (Tab, I18n, LLMProvider, KeychainHelper, ProjectIndex, plus new EventLogReaderTests). Pre-existing test files marked @MainActor at suite level to satisfy Swift 6 strict concurrency
  • Test totals: 2328 Python (4 new test files, 80 new tests) + 90 Swift, all green

0.14.618 Apr 2026

  • CI: e2e gate re-enabled as a blocking check — cleared the three P3 items parked during v0.14.5. Autocode-status 404 allowlisted in e2e/tests/network.spec.ts (kept REST semantics; frontend at CodebookPanel.tsx:609 already null-tolerant). Codebook-route console subresource 404 allowlisted in e2e/tests/console.spec.ts with a TODO(S3) pointer; root-cause fix deferred to the next frontend sprint. _BRISTLENOSE_AUTH_TOKEN=test-token wired into the main e2e workflow step so Node-side fetches can authenticate
  • Fix: Analysis page "Show all N quotes" toggle — the expand/collapse toggle on signal cards was rendered as an <a> without href, which fails a11y (not keyboard-focusable) and trips link validators. Converted to a proper <button type="button"> with minimal CSS reset in analysis.css. Discovered during the e2e gate-flip verification on this branch
  • Fix: Playwright config shell-quotesplaywright.config.ts interpolated ${BRISTLENOSE} and ${FIXTURE_DIR} unquoted, so worktree paths with spaces (like bristlenose_branch ci-cleanup) broke the webServer launch. Paths now quoted. Pre-existing bug, surfaced by the branch naming convention
  • Security docs: SECURITY.md corrected — prior text claimed the auth token was "random at startup" and "memory only" unconditionally. Reality: _BRISTLENOSE_AUTH_TOKEN in the process environment overrides the random token (required for CI fixtures and for uvicorn --reload session continuity across code saves). Spec updated to describe the actual behaviour; a future hardening task will gate the override behind BRISTLENOSE_DEV_MODE=test
  • Doctor: new Auth token checkbristlenose doctor now warns if _BRISTLENOSE_AUTH_TOKEN is set in the shell environment. Helps catch accidental bleed from a previous CI run or dotfile that would otherwise silently pin the serve token
  • CI: allowlist register — every deliberate suppression in the Playwright e2e gate now has an entry in e2e/ALLOWLIST.md and a // ci-allowlist: CI-A<N> marker in code. Three categories: infra (stack artefact, never fixable), by-design (intentional correct behaviour), deferred-fix (real bug with tracker link). Prevents silent accumulation of "we ignored that failure because" in the gate. Validator script + staleness gate deferred until ~10 entries

0.14.517 Apr 2026

  • CI: unblock release pipeline — v0.14.0–v0.14.4 releases all failed in CI for three independent reasons that had compounded since early April. No user-facing changes; cutting this release to prove the pipeline is green end-to-end before resuming normal feature work
  • Fix: eslint-plugin-react-hooks peer-dep mismatch — 7.0.1 capped the eslint peer at major 9, but the project ships eslint 10. npm install (local) is permissive; npm ci (CI) is strict and rejected it. Bumped to 7.1.0 which accepts eslint 10
  • Fix: pin jsdom to 27.x — a dependabot auto-merge bumped jsdom 27 → 29, which stopped shimming Web Storage API methods. Combined with Node 25, 140+ frontend tests failed at localStorage.*. Reverted; reattempt once jsdom 29 + Node interaction has a proper polyfill
  • Fix: stale Vitest mocks after api.ts and i18n changesuseKeyboardShortcuts.test.ts missed apiGet in its vi.mock factory (PlayerProvider now imports it); CodebookSidebar.test.tsx asserted on a raw template title that the component now passes through t("codebook.sentimentTitle"). Both updated
  • Fix: e2e installe2e/package-lock.json regenerated after lighthouse was added to e2e/package.json without running npm install. Same npm install vs npm ci divergence pattern as the eslint peer-dep issue
  • CI: exclude perf-stress.spec.ts from default Playwright discovery. The spec has a CI guard that refuses runs under the default trace: 'on-first-retry' config; it runs via the dedicated playwright.stress.config.ts instead
  • CI: e2e gate temporarily informational — three P3 e2e findings (console subresource 404 on /report/codebook/, noisy autocode status 404 when no job exists, perf-gate auth-token env wiring) parked to sprint 2. Re-enable the e2e gate once all three clear

0.14.416 Apr 2026

  • Pipeline resilience: input change detection — phase 2c of pipeline resilience. Detects when input files have been added, removed, or modified between runs. Stale sessions are quarantined and the affected stages re-run automatically. SHA-256 content hashing with size-based short-circuit for large files
  • Alembic migration infrastructure — replaced manual _migrate_schema() with Alembic for schema migrations. Existing databases auto-stamp to the baseline revision on first serve. New migrations go in bristlenose/server/migrations/
  • CI: multi-python matrix — test suite now runs against Python 3.10, 3.11, 3.12, and 3.13. Lint job split from test jobs for faster feedback

0.14.327 Mar 2026

  • Export: video clip extraction — starred and featured quotes extracted as trimmed video clips via FFmpeg stream-copy. Human-readable filenames (p1 03m45 Sarah onboarding was confusing.mp4), adjacent merge, async progress toast, clips_manifest.json audit trail. Two-backend architecture (FFmpeg now, AVFoundation future) with ClipBackend Protocol. 64 new Python tests
  • Fix: video player 401<video> elements can't send Authorization headers; /media/ routes exempted from bearer token middleware. Also fixed PlayerContext.tsx and Minimap.tsx using raw fetch() instead of apiGet()
  • Fix: safe_filename() path traversal — single-pass .. removal could reassemble from "..../". Now uses while loop
  • Refactor: pick_featured_quotes() — moved from dashboard.py to export_core.py for shared use by dashboard and clip extraction

0.14.224 Mar 2026

  • Localhost bearer token — defence-in-depth API access control for serve mode. Per-session secrets.token_urlsafe(32) validated via Starlette middleware on /api/* routes. Token injected into SPA HTML and desktop app WKWebView
  • Fix: TOC sidebar empty — left-hand contents navigation on the Quotes tab was broken by the auth middleware (raw fetch() lacked the bearer token). Switched to apiGet() helper

0.14.124 Mar 2026

  • French, German, Korean locales — machine-translated all 8 namespace files (common, settings, enums, cli, pipeline, server, doctor, desktop) for fr, de, ko. Cross-checked against Apple macOS glossaries and ATLAS.ti/MAXQDA research terminology. Native reviewer docs added for German and French
  • Contextual left-panel labels — desktop app toolbar and View menu items now show tab-specific labels (Contents on Quotes, Codes on Codebook, Signals on Analysis) instead of generic "Navigation". View menu items renamed from "Toggle X" to "Show X" per Apple HIG
  • Tags sidebar shortcut⌘⌥T toggles the tag sidebar in the desktop app
  • Keyboard shortcuts design doc — full inventory of desktop app shortcuts with conflict matrix

0.14.020 Mar 2026

  • Inspector panel — DevTools-style collapsible bottom panel in the Analysis tab. Heatmap matrices (section × sentiment, theme × sentiment, codebook group) live in a resizable pane that opens via click, drag-up, or the m keyboard shortcut. Signal card selection (blue wash) syncs bidirectionally with inspector source tabs — click a card to drill into its matrix, click a tab to highlight its card. Drag-from-collapsed grows the panel smoothly from the 28px handle bar. Tooltip repositioning flips above when near viewport bottom. Footer auto-hides to maximise matrix space. New files: InspectorPanel.tsx, InspectorStore.ts, useVerticalDragResize.ts, inspector.css. 4 feature commits, 696 new test assertions
  • Codebook sidebar — left-hand navigation panel now available on every tab, not just Quotes. The TOC sidebar becomes a codebook-tree sidebar showing tag groups, counts, and micro-bars for the current view. Tabs without a right sidebar get a symmetric spacer rail so the center column stays balanced. Inert rail layout ensures non-sidebar tabs render cleanly without sidebar state leaking
  • Finding flags on signal cards — each sentiment-based signal card carries an optional finding flag (Win, Problem, Niggle, Success, Surprising, or Pattern) classifying the signal's direction and weight. Flags are computed from sentiment valence, participant breadth, and intensity. Conservative thresholds mean cards that don't clear the bar stay unflagged. Four tunable thresholds ready for calibration via a future dev-mode HUD. Two-tone micro-bars visualise signal composition. Flows through all three output paths: static render, sentiment API, and tag API. 27 new tests. Design exploration: docs/design-finding-weight.md
  • Settings modal — gear-icon / ⌘, modal with sidebar navigation shell (ModalNav organism). Reusable across modals — Settings and Help share the same ModalNav component with their own sizing classes
  • Help modal — replaces the About page route and keyboard shortcut overlay with a sidebar-nav modal. Seven sections: Help, Shortcuts (keyboard grid with platform-aware badges), Signals, Codebook, and About group (Developer, Design, Contributing). Triggered by ⓘ navbar icon or ? key. /report/about/ URL gracefully redirects to /report/
  • About tab section components — modular React components for each About section (AboutSection, CodebookSection, ContributingSection, DesignSection, DeveloperSection, SignalsSection). Worktree launch paths fixed for serve mode
  • Activity chips persist across tab navigation — AutoCode progress toasts survive React Router navigation via a new ActivityStore. Action link uses real href for Cmd+click, label changed to "View Analysis", hidden when already on Analysis tab
  • New framework codebooks — Nielsen's 10 Usability Heuristics and Yablonski's Laws of UX join the built-in codebook library. Import either via Codebook → Add Framework
  • Fix: sidebar not showing on codebook tab when URL had a trailing slash
  • Fix: codebook grid fills available width — removed max-width cap that left empty space on wide screens
  • Fix: autocode toast — "View Report" link now opens the threshold review modal instead of always being visible; toast auto-dismisses on click

0.13.716 Mar 2026

  • Spanish locale — machine-translated all 102 UI strings across 8 locale files (3 frontend, 5 backend). Covers navigation, settings, enums, CLI messages, doctor, pipeline progress, and server errors. Ready for native-speaker review and polish

0.13.616 Mar 2026

  • Single tag focus mode — click a tag count in the sidebar to solo that tag (mixing-desk style). All other sidebar controls dim to 50%; the focused tag row stays at full brightness. "You are here" blue wash highlight matches the TOC sidebar's active link style. Click again or press Escape to exit and restore the previous tag filter
  • Fix circular dependency crashSidebarStore importing setTagFilter from QuotesContext at module scope created a circular dependency that caused "Cannot access uninitialized variable" in production builds. Refactored to pass setTagFilter as a callback parameter
  • Used/unused tag filter toggle — "Used" link in tag sidebar header filters to show only tags that appear on at least one quote, hiding zero-count tags for a cleaner view
  • Sidebar tag click-to-assign — select quotes then click a tag badge in the sidebar to apply that tag to all selected quotes. Flash animation on each newly tagged quote, toast notification when tagged quotes are off-screen
  • Tier 1 logging instrumentation — persistent log file with stage-level timing, session counts, and LLM token usage. PII hardening: file paths, API keys, and participant names are scrubbed from log output
  • Multilingual UI infrastructure — i18next integration with useTranslation hook, Python i18n module, browser language auto-detection on first visit. Translation keys for all user-facing strings (not yet translated)
  • Responsive signal cards — signal framework cards use CSS grid layout with narrow-screen stacking for readable display on mobile viewports
  • Cache system_profiler results — 24-hour TTL cache for macOS system_profiler output, avoiding the slow 2-3s startup penalty on every bristlenose doctor or bristlenose run

0.13.414 Mar 2026

  • Fix import FK constraint — delete ProposedTag rows (AutoCode proposals) before removing stale quotes during re-import, preventing FOREIGN KEY constraint failed errors on bristlenose serve startup

0.13.314 Mar 2026

  • Help modal polish — platform-aware keyboard shortcuts (⌘ on Mac, Ctrl on Windows/Linux), sans-serif keycaps with deeper shadow and min-width for visual consistency, typography tokens and spacing overhaul, entrance animation, dark-mode keyboard styling. Phase 3 adds custom tooltips with keyboard shortcut badges across the UI
  • Bulk actions on multi-selection — star, hide, and tag operations now respect click-based multi-selection: clicking a starred quote in a selection unstars all, clicking an unstarred one stars all. Shift+click range selection no longer triggers browser text selection. Tag commits apply to all selected quotes
  • Sidebar animations — push animation on keyboard shortcuts ([, ], \) and click, 4× faster open/close transitions, two-row header layout. Rail drag-to-open with 60px commit threshold and pointer-tracking overlay. Drag handle hover-intent (80ms delay) filters casual mouse traversal
  • TOC overlay clip-path animation — smooth reveal/hide for the table-of-contents overlay panel, with playground toggle for overlay style
  • Scroll spy Safari fix — two-phase click-intent override (immune phase 0–600ms, sticky phase >600ms) resolves Safari's smooth-scroll getBoundingClientRect() timing glitch. Minimap reordered to grid column 4 (between center and tag sidebar)
  • Pipeline error display — failed stages show red ✗, partially successful stages show yellow ⚠ in CLI output
  • Speaker badge fix — correct indent when speaker badge wraps to a new line
  • Scroll-margin fix — section headings in React SPA now scroll to the correct position, accounting for the sticky navbar
  • CLAUDE.md refactor — extracted frontend, pipeline, and LLM gotchas into dedicated child files (frontend/CLAUDE.md, bristlenose/stages/CLAUDE.md, bristlenose/llm/CLAUDE.md), keeping the root file focused on architecture and conventions

0.13.211 Mar 2026

  • Render refactor — broke up the 2,903-line render_html.py monolith into bristlenose/stages/s12_render/ package with 8 focused submodules: theme_assets.py (CSS/JS loaders, Jinja2 env), html_helpers.py (escaping, timecodes, badges, video map), quote_format.py (quote HTML, badges), sentiment.py (sparklines, rewatch/friction), dashboard.py (project tab, featured quotes, coverage), transcript_pages.py (standalone + inline transcripts), standalone_pages.py (codebook, analysis), report.py (orchestrator). No behaviour change — same HTML output, all 1,872 tests pass
  • Static render deprecatedrender_html() now emits DeprecationWarning. bristlenose serve is the canonical path; bristlenose render still works but is frozen
  • Updated ~99 documentation references across 34 files to point to the new submodules

0.13.110 Mar 2026

  • Responsive layout playground — dev-only responsive design tool (--dev mode) with device presets, type scale previews, and a HUD overlay for testing quote grid layouts across breakpoints. FAB toggle button, PlaygroundStore with useSyncExternalStore
  • Sidebar TOC overlay mode — left sidebar TOC switches to hover-triggered overlay on narrower viewports. useTocOverlay hook manages open/close transitions with mouse-enter/leave
  • Minimap component — document minimap for the quotes page, showing a scaled overview of quote sections with viewport indicator
  • Responsive quote grid CSS — CSS-only responsive grid for quote cards, adapting column count to available width

0.13.010 Mar 2026

  • Codebook-aware tag autocomplete — TagInput now groups suggestions by codebook section with colour-coded pills and section headers, matching the IDE-style autocomplete pattern. Highlighted suggestion takes priority over ghost text. Active pill uses white text, z-index fix prevents clipping under sidebar, deeper dropdown accommodates more suggestions
  • Sentiment tags unified into codebook framework — sentiment tags (positive, negative, neutral, mixed) are now a proper codebook framework (sentiment.yaml) imported through the standard codebook system, replacing the ad-hoc UXR sentiment group. Tag colours use the design-system colour sets. Importer handles migration of existing sentiment tags to the new framework
  • Tag provenance trackingQuoteTag.source column tracks "human" (manual adds) vs "autocode" (LLM-suggested). Provenance is preserved across bulk tag operations (snapshot before delete, restore on re-insert). Exposed in the quotes API via TagResponse.source. Case-insensitive duplicate guard prevents adding the same tag twice
  • Hidden-group tag UX — eye-toggled codebook groups show a grey closed-eye icon in TagInput autocomplete. Accepting a tag from a hidden group auto-unhides the group so the badge is immediately visible. allTagNames prop prevents invisible re-adds from hidden groups
  • Quick-repeat tag shortcut (r) — press r on a focused quote to re-apply the last-used tag without opening the tag input. Shown in help modal
  • Context expansion fix — fixed infinite re-render loop when clicking the up chevron to expand context on a quote card. FocusContext and QuotesContext updated to break the render cycle
  • Doctor offers MLX install on Apple Siliconbristlenose doctor now detects Apple Silicon Macs without MLX and offers interactive installation for GPU-accelerated transcription. Install helpers use sys.executable to target the correct Python. Skipped in non-interactive contexts
  • Sidebar UX accessibility — focus management, ARIA attributes, and prefers-reduced-motion support for sidebar drag-resize animations
  • Dashboard Cmd+click fix — stat cards now use <a> elements with real href attributes, so Cmd+click opens in a new tab as expected. Normal clicks still use React Router navigation

0.12.22 Mar 2026

  • Footer feedback restored in React serve mode — the footer once again exposes both "Report a bug" and "Feedback" in the served React app. "Feedback" now opens a React-native modal (sentiment picker + optional message), matching the legacy flow rather than reusing the keyboard-shortcuts help modal
  • Feedback submit + fallback parity — feedback sends {version, rating, message} JSON to the configured feedback endpoint when available over HTTP(S). On failure (or non-HTTP contexts), it falls back to copying a plain-text feedback payload to the clipboard and shows toast guidance
  • Health API contract extendedGET /api/health now returns links.github_issues_url and feedback.{enabled,url} alongside existing status and version keys (unchanged)
  • Export parity with serve mode — exported reports embed the same health.links + health.feedback shape so footer behavior is consistent between serve and export
  • Config via environment variables — optional server-side overrides: BRISTLENOSE_GITHUB_ISSUES_URL, BRISTLENOSE_FEEDBACK_ENABLED, BRISTLENOSE_FEEDBACK_URL

0.12.11 Mar 2026

  • Word-level transcript highlighting — during video playback, individual words in the transcript highlight in sync with the audio, karaoke-style. Whisper captures per-word start/end timestamps during transcription; these flow through the pipeline (session_segments.json → SQLite words_json → transcript API words field) and render as <span class="transcript-word"> elements with data-start/data-end attributes. PlayerContext.updateGlow() scans word spans at 4 Hz and toggles .bn-word-active on the current word. Three-tier rendering fallback: word spans (Whisper sessions) → html_text with <mark> quote highlighting (non-Whisper with quotes) → plain text. CSS uses color-mix(in srgb, accent 30%, transparent) with prefers-reduced-motion underline fallback. VTT/SRT-imported sessions gracefully degrade to segment-level glow only. Glow brightness rebalanced: paragraph glow dimmed 50%, word glow brightened 50% — the active word pops against a subtler segment background
  • Design doc: docs/design-word-highlighting.md — pipeline diagram, data flow, frontend architecture

0.12.01 Mar 2026

  • Dual sidebar for Quotes tab — left sidebar: table of contents with scroll-spy (sections + themes, active heading highlights on scroll). Right sidebar: tag filter with codebook tree, eye toggles for badge hiding (persisted to localStorage). 5-column CSS grid (rail | sidebar | center | sidebar | rail), drag-to-resize with snap-close thresholds, keyboard shortcuts ([ left, ] right, \ both, ⌘. tag sidebar). Quotes-tab-only — other tabs see no grid or rails. New components: SidebarLayout, TocSidebar, TagSidebar, TagGroupCard, TagRow, EyeToggle. New hooks: useDragResize, useScrollSpy. Module-level SidebarStore with useSyncExternalStore. Backend: tag-group-with-quotes API endpoint, admin panel registration. 845 Vitest tests (60 files), 1856 Python tests
  • Frontend CI — ESLint, TypeScript typecheck, and Vitest added to GitHub Actions (frontend-lint-type-test job on Node 20). ESLint step informational pending fix of 84 pre-existing errors

0.11.21 Mar 2026

  • Self-contained HTML export — download button in the NavBar bundles all API data as embedded JSON, blob-URL'd JS chunks, and a hash router for file:// — recipients open the report in any browser without Bristlenose installed. Optional anonymisation checkbox strips participant names before download. Read-only CSS + JS guards hide mutation UI in exported files. 26 Python tests, 20 frontend tests
  • About panel redesign — sidebar layout (Claude-settings style) with 5 sections: About (per-screen product guide), Signals (sentiment taxonomy, concentration metrics, academic references), Codebook (sections/themes, sentiment tags, framework codebooks with author refs), Developer (architecture, stack, APIs, contributing), Design (design system, dark mode, component library, typography)
  • Configuration reference panel — read-only grid in Settings tab showing all 63 configurable values across 12 categories with defaults, file badges, clickable env var names (copy to clipboard), and valid options
  • Morville honeycomb codebook — Peter Morville's User Experience Honeycomb: 7 groups (useful, usable, desirable, findable, accessible, credible, valuable), 28 tags with discrimination prompts and cross-codebook references. Codebooks now sorted by browse order via sort_order field

0.11.128 Feb 2026

  • Fix video player — double URL encoding broke playback for files with spaces (%20%2520). Removed Python-side _url_quote() from _file_to_media_uri(); JS encodeURIComponent() now handles encoding once. Also fixed subdirectory loss: merge_transcript.py now stores relative paths (e.g. interviews/show and tell 40.mov) instead of just the filename

0.11.028 Feb 2026

  • Full React SPA in serve mode — React Router (react-router-dom v7) replaces vanilla JS hash-based tab navigation. Single RouterProvider root with pathname routes (/report/, /report/quotes/, /report/sessions/:id, etc.). AppLayout wraps NavBar + Outlet. 8 page wrappers compose existing island components. Backward-compat shims (window.switchToTab, window.navigateToSession) delegate to React Router for any remaining callers
  • Player integrationPlayerContext manages popout video player lifecycle, seekTo(pid, seconds), glow sync via DOM class manipulation (not React state). buildGlowIndex keys by session ID from URL pathname. Progress bar via --bn-segment-progress CSS custom property. player.js bails out in SPA mode
  • Keyboard shortcutsFocusContext + useKeyboardShortcuts hook. j/k navigation, multi-select (Shift+j/k, x), bulk star/hide/tag, ? help modal, / focus search, Escape cascade (modal → search → selection → focus). Data-derived visible quote IDs replace DOM queries
  • React app shellHeader (logo, project name, subtitle), Footer (version, ? for Help), HelpModal (keyboard shortcuts overlay). Serve mode serves Vite-built SPA directly — no more _transform_report_html() marker substitution. Route extraction: app.py refactored to route modules
  • Vanilla JS retired from serve path_strip_vanilla_js() removes all 26 modules from the IIFE while keeping window.* globals (BRISTLENOSE_VIDEO_MAP, BRISTLENOSE_PLAYER_URL, BRISTLENOSE_ANALYSIS). Static render path unchanged
  • Video player links on sessions page and dashboard open the popout player
  • Importer finds source files in one-level subdirectories (mirrors ingest scan pattern), fixing video 404s when files are in interviews/
  • Speaker display names in sessions grid use normal font size (matching date/duration columns)
  • Word-level timing data plumbed through pipeline to transcript API (words field on segments)

0.10.321 Feb 2026

  • bristlenose status command — read-only project status from the manifest, shows stage completion with session counts, intermediate file validation, and -v per-session detail
  • Pre-run resume summary — one-line status message before pipeline output when resuming an interrupted run (e.g. "Resuming: 7/10 sessions have quotes, 3 remaining.")
  • Split speaker badges — two-tone pill with speaker code (left, mono) and participant name (right, body font). Settings toggle: "Show participants as: Code and name / Code only", persisted in localStorage. Applied across all surfaces: quote cards, session table, dashboard, transcript pages, user journeys, friction points
  • Em-dash removed from quote attribution — replaced with margin-left: 1rem gap between quote text and speaker badge
  • Always-on sticky transcript header — session selector dropdown always visible on transcript pages (not gated on journey data). Single-session pages show a plain label as page title
  • Serve-mode session routing — session links now navigate to standalone transcript pages (React island) instead of inline Jinja2 drill-down. Inline Jinja2 transcript pages and vanilla JS subnav stripped in serve mode
  • AutoCode frontend — complete lifecycle for LLM-assisted tag application: ✦ button on framework codebook sections triggers AutoCode run, progress toast with 2s polling and cancel support, threshold review dialog with confidence histogram and dual-threshold slider (accept/tentative/exclude zones), per-row override, proposed badges on quotes (pulsating dashed border, hover accept/deny with brightness flash animation). Tag colour pipeline carries colour_set + colour_index from CodebookGroup through to accepted user tags
  • Threshold review dialog — confidence-aware triage: 20-bin histogram with tag colours, three proposal zones, bulk accept/deny with max_confidence filter, per-quote override before committing
  • Activity chip and chip stack components — lightweight status indicators for background jobs with multi-stage progress, cancel button, and auto-dismiss on completion
  • Transcript page improvements — greedy slot layout for annotation span bars (no overlapping when multiple quotes span the same region), suppress repeated label+sentiment annotations (show only on topic change), speaker badges use bn-person-badge styling consistent with sessions table
  • Journey chain: full sequence with revisits — transcript sticky header now shows the complete user journey including revisited sections (e.g. Cat Nav → Prod List → Cat Nav → Checkout), not the deduplicated summary. Index-based tracking distinguishes repeated labels — clicking the second "Cat Nav" jumps to the second occurrence, scrolling highlights only the current one. Sessions table retains deduplicated labels unchanged
  • Journey label active state — new journey-label CSS atom with --bn-colour-hover pill background on the active step, extracted from transcript styles into the atomic design system. Fixed CSS specificity bug where all: unset on buttons suppressed the active background
  • Resilient transcript discovery — serve-mode importer now searches four locations in priority order (cooked → raw/output → raw/project → transcripts/project) instead of only transcripts-raw/, fixing empty transcript pages when pointing serve at non-standard output layouts
  • Generic analysis matrix and signals — reusable computation engine for cross-tabulating any labelled data (not just quotes), with API routes for serve mode
  • Man page and docs updated for status and serve commands
  • Fix: serve-mode navigation escape — transcript page back link pointed to /report/{filename}.html (raw static HTML without React islands) instead of /report/ (serve-mode route with React injection). Clicking "← Research Report" from a transcript now stays in serve mode

0.10.221 Feb 2026

  • Pipeline crash recovery — interrupted runs resume where they left off instead of starting over. Kill mid-analysis, re-run the same command, and only the unfinished sessions get LLM calls. Completed sessions are loaded from cache in milliseconds
  • Per-session tracking for topic segmentation and quote extraction — the manifest records which sessions finished within each stage, so a crash after 7 of 10 sessions only re-processes the remaining 3. Cached + fresh results are merged transparently
  • CLI resume guard — re-running into an existing output directory now detects the pipeline manifest and resumes automatically. No --clean needed, no "output directory already exists" error. --clean still available for full re-runs
  • Pipeline resilience design doc — CS foundations research (build systems, event sourcing, WAL, CAS, sagas) and phased implementation plan for crash recovery, data integrity, and incremental re-runs

0.10.119 Feb 2026

  • Desktop app API key onboarding — first-run setup screen prompts for Claude API key, stores in macOS Keychain via security CLI, sidecar picks it up automatically via _populate_keys_from_keychain(). Settings panel (⌘,) for viewing, changing, or deleting the key
  • .dmg packaging — build-dmg.sh archives via xcodebuild with ad-hoc signing, packages with hdiutil (or create-dmg for drag-to-Applications). build-all.sh chains sidecar + ffmpeg + whisper + dmg into one command
  • Serve mode after pipeline — desktop app auto-launches bristlenose serve after pipeline completes, opens report at http://127.0.0.1:8150/report/ with full React islands
  • Deployment target updated to macOS 15 Sequoia (was 14 Sonoma)
  • Codebook tag templates — pre-built tag sets via new API endpoints and UI

0.10.018 Feb 2026

  • Desktop app v0.1 — SwiftUI macOS launcher (desktop/Bristlenose/) with folder picker, drag-and-drop, pipeline output streaming, View Report in browser. 4-state UI (ready → selected → running → done), ANSI escape stripping, report path detection from both OSC 8 hyperlinks and Report: text fallback. Xcode 26 project, 840 KB .app, macOS 14+ deployment target
  • ProcessRunner — @MainActor ObservableObject that spawns Process(), reads stdout via Task.detached, streams lines to SwiftUI, extracts report file path
  • FolderValidator — scans directories recursively for processable file extensions (mirrors models.py extension lists)
  • Back-to-folder navigation on Done screen — re-validates folder state for re-render/re-analyse without starting over

0.9.417 Feb 2026

  • bristlenose serve command — FastAPI local dev server that serves the HTML report over HTTP with SQLite persistence, React islands, and live JS reload in dev mode. Auto-renders before serving, auto-opens browser, prints clickable report URL
  • React islands architecture — 5 islands (SessionsTable, Dashboard, QuoteSections, QuoteThemes, CodebookPanel) mount into static HTML via comment markers and re.sub at serve time; 16 reusable React primitives (Badge, PersonBadge, TimecodeLink, EditableText, Toggle, TagInput, Sparkline, Counter, Metric, JourneyChain, Annotation, Thumbnail, MicroBar, ConfirmDialog); 182 Vitest component tests
  • Codebook CRUD — React island replaces vanilla JS codebook: drag-and-drop tags between groups, inline editing of group titles and subtitles, tag merge with confirmation, create/delete groups and tags, pentadic colour system, MicroBar frequency bars, ConfirmDialog for destructive actions. 9 API endpoints, 36 Python tests
  • Dashboard island — Project tab as React composition: 8 clickable stat cards, compact sessions table, featured quotes with reshuffle, section/theme navigation with cross-tab anchor links. 43 API tests
  • Data API — 6 fire-and-forget PUT endpoints sync researcher state (hidden, starred, tags, edits, people, deleted-badges) from localStorage to SQLite. 94 tests (37 happy-path + 57 stress)
  • 22-table SQLAlchemy domain schema with instance-scoped people/codebook and project-scoped sessions/quotes/themes; assigned_by tracks pipeline vs researcher authorship; idempotent upsert importer
  • Desktop app scaffold — SwiftUI macOS shell with folder picker, pipeline runner, sidecar architecture (self-contained in desktop/)
  • Renderer overlay (dev-only, press D) — colour-codes report regions by origin: blue for Jinja2, green for React, amber for vanilla JS
  • Visual diff page (dev-only) — side-by-side, overlay, and toggle comparison of Jinja2 vs React sessions table

0.9.313 Feb 2026

  • Interactive dashboard — Project tab stat cards are clickable links to their target tabs (audio→Sessions, quotes→Quotes, sections/themes→Quotes anchors, AI tags→Analysis, user tags→Codebook); featured quote cards open video player or fall back to transcript; session table rows drill into Sessions tab; section/theme names switch to Quotes tab and scroll to the anchor
  • New --bn-colour-hover design token with light-dark() support
  • Speaker code lozenge attribution on featured quotes
  • Reusable JS helpers (scrollToAnchor, navigateToSession) and Python helpers (_timecode_html, _session_anchor)
  • Fix: logo dark/light swap on appearance toggle

0.9.212 Feb 2026

  • Sessions table redesign — speaker badges with colour-coded IDs, user journey paths below start dates, video thumbnail placeholders (96×54px, 16:9), per-session sentiment sparkline mini-bar charts
  • Appearance toggle — system/light/dark mode switcher in settings tab
  • User journeys — derived from topic-segmentation screen clusters, shown in sessions table and sortable journeys table
  • Time estimates — upfront pipeline duration estimate after ingest (~8 min (±2 min)), recalculated remaining time after each stage, Welford's online algorithm for per-metric running stats, hardware+config keyed profiles persisted to ~/.config/bristlenose/timing.json
  • Clickable logo — Bristlenose logo in report header navigates to project tab
  • Fix: llm_max_tokens truncation causing silent 0-quote extraction — providers now detect truncation via stop_reason/finish_reason and raise RuntimeError with actionable .env fix
  • Fix: sentiment sparkline bars now align with video thumbnail baseline (removed inline height override that capped sparkline container at 20px instead of the intended 54px)

0.9.111 Feb 2026

  • Moderator and observer names shown in Project tab stats row (Oxford comma lists, observer box only when observers exist)
  • Fix: clicking [+] to add a tag on a quote now tags that quote, not the previously-focused quote

0.9.011 Feb 2026

  • Tab navigation — tabs remember their position across page reloads via URL hash (#codebook, #analysis, etc.); browser back/forward navigates between tabs; deep-linkable tab URLs
  • Analysis tab — inline signal cards and heatmaps in the main report (previously a placeholder); BRISTLENOSE_ANALYSIS data injected into the report's script block alongside the standalone analysis.html
  • Codebook tab — fixed empty grid caused by _countQuotesPerTag function name collision between codebook.js and tag-filter.js in the concatenated JS bundle; codebook panel now renders correctly in the main report
  • Removed dead toolbar button handlers from analysis.js and codebook.js (replaced by navigation tabs)

0.8.29 Feb 2026

  • Transcript annotations — per-participant transcript pages now highlight which segments were selected as quotes, with margin labels showing sentiment, colour-coded span bars connecting multi-segment quotes, and a citation toggle to show/hide annotations; playback-synced glow on both transcript segments and report quote cards when video is playing
  • Gemini provider — --llm gemini for budget-conscious teams (~$0.20/study, 5–7× cheaper than Claude or ChatGPT); bristlenose configure gemini stores your key in the system credential store; interactive provider picker now includes Gemini as option [4]
  • Jinja2 template extraction — report renderer migrated from f-strings to Jinja2 templates (13 templates extracted across two phases); pure refactor, no output changes
  • Platform-specific credential language — doctor and configure now show the actual store name: "Keychain" on macOS, "Secret Service" on Linux, instead of generic "credential store"

0.8.17 Feb 2026

  • Hidden quotes — press h (or click the eye-slash button) to hide volume quotes you want to keep as evidence but need out of your working view; per-subsection badge shows count with dropdown of truncated previews; click a preview to unhide with highlight animation; bulk hide via multi-select + h; hidden state persists in localStorage and survives search, tag filter, and view switching
  • Codebook — standalone codebook.html page (opens in a new window via toolbar button) with interactive panel for organising tags into groups; drag-and-drop reordering within and between groups; inline editing of group names; add/delete groups with confirmation; toggle AI tag visibility per-tag; colour-coded tag badges with 24-colour palette; shared data model across report and codebook via localStorage
  • Toolbar redesign — unified round-rect button styling with 3-state border progression (rest → hover → active); tag filter and view switcher use dual-class pattern for consistent appearance
  • Python 3.14 compatibility — check_pii in doctor now catches Exception (not just ImportError) to handle pydantic v1 crash when importing presidio on Python 3.14

0.8.07 Feb 2026

  • Azure OpenAI provider — --llm azure for enterprise users with Microsoft Azure contracts; uses AsyncAzureOpenAI from the existing OpenAI SDK (no new dependencies); configure with bristlenose configure azure or AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT env vars
  • Install smoke tests — new CI workflow (install-test.yml) verifies install instructions work on clean Linux and macOS VMs; runs bristlenose doctor and bristlenose render from pre-built fixtures with no API key needed; weekly full-pipeline run with real API key catches integration regressions

0.7.16 Feb 2026

  • Bar chart alignment — sentiment and user-tag charts use CSS grid so bar left edges align within each chart; labels hug text with variable gap to bars
  • Histogram delete — hover × on user tag labels in the histogram to remove that tag from all quotes (with confirmation modal)
  • Surprise placement — surprise sentiment bar now renders between positive and negative sentiments
  • Quote exclusivity in themes — each quote assigned to exactly one theme (pick strongest fit)

0.7.05 Feb 2026

  • Multi-select — Finder-like click selection (click, Shift-click, Cmd/Ctrl-click) with bulk starring (s key) and bulk tagging; selection count shown in view-switcher label; CSV export respects selection
  • Tag filter — toolbar dropdown between search and view-switcher filters quotes by user tags; checkboxes per tag with "(No tags)" for untagged quotes; per-tag quote counts, search-within-filter for large tag lists, dropdown chevron, ellipsis truncation for long names

0.6.154 Feb 2026

  • Unified tag close buttons — AI badges and user tags now use the same floating circle "×" style
  • Tab-to-continue tagging — pressing Tab commits the current tag and immediately opens a new input for adding another tag (type, Tab, type, Tab, Enter for fast keyboard-only tagging)
  • Render command path fix — bristlenose render <input-dir> now auto-detects bristlenose-output/ inside the input directory

0.6.144 Feb 2026

  • Doctor fixes — improved Whisper model detection and PII capability checking

0.6.133 Feb 2026

  • Keychain credential storage — bristlenose configure claude (or chatgpt) validates and stores API keys securely in macOS Keychain or Linux Secret Service; keys are loaded automatically with priority keychain → env var → .env; bristlenose doctor now shows "(Keychain)" suffix when key comes from system credential store; --key option available for non-interactive use

0.6.123 Feb 2026

  • File-level transcription progress — spinner now shows "(2/4 sessions)" during transcription
  • Improved Ollama start command detection — uses brew services start ollama for Homebrew installs, open -a Ollama for macOS app, platform-appropriate commands for snap/systemd
  • Doctor displays "(MLX)" accelerator — when mlx-whisper is installed on Apple Silicon, doctor now shows "(MLX)" instead of "(CPU)"
  • Whisper model line fits 80 columns — shortened to "~1.5 GB download on first run"
  • Provider header fix — pipeline header now shows "Local (Ollama)" instead of "ChatGPT" when using local provider
  • Improved fix messages — doctor fix messages now use pipx inject for pipx installs, proper Homebrew Python path for brew installs (PEP 668 compliance)
  • Retry logic catches ValidationError — local model retries now also handle Pydantic schema validation failures, not just JSON parse errors

0.6.113 Feb 2026

  • Local AI support via Ollama — run bristlenose without an API key using local models like Llama 3.2; interactive first-run prompt offers Local/Claude/ChatGPT choice
  • Automated Ollama installation — offers to install Ollama automatically (Homebrew on macOS, snap on Linux, curl script fallback); falls back to download page if installation fails
  • Auto-start Ollama — if installed but not running, bristlenose will start it for you
  • Provider registry — centralised bristlenose/providers.py with ProviderSpec dataclass, alias resolution (claude→anthropic, chatgpt→openai, ollama→local)
  • Ollama integration — bristlenose/ollama.py with status checking, model detection, and auto-pull with consent
  • Retry logic for local models — 3 retries with exponential backoff for JSON parsing failures (~85% reliability vs ~99% for cloud)
  • Smart cloud fallback hints — fix messages for Ollama issues now check which API keys you have and only suggest providers you can actually use
  • Doctor integration for local provider — shows "Local (llama3.2:3b via Ollama)" status, helpful fix messages for Ollama not running or model missing

0.6.103 Feb 2026

  • Output directory inside input folder — bristlenose run interviews/ now creates interviews/bristlenose-output/ to avoid collisions when processing multiple projects
  • New directory structure — assets/ for static files, sessions/ for transcript pages, transcripts-raw//transcripts-cooked/ for transcripts, .bristlenose/ for internal files
  • Report filenames include project name — bristlenose-{slug}-report.html so multiple reports in Downloads are distinguishable
  • Coverage link fix — player.js no longer intercepts non-player timecode links
  • Anchor highlight — transcript page segments flash yellow when arriving via anchor link

0.6.93 Feb 2026

  • Transcript coverage section — collapsible section at the end of the report showing what % of the transcript made it into quotes (X% in report · Y% moderator · Z% omitted), with expandable omitted content per session
  • Transcript page fix — pages now render correctly when PII redaction is off (was failing with assertion error)

0.6.83 Feb 2026

  • Multi-participant session support — sessions with multiple interviewees get globally-numbered participant codes (p1–p11 across sessions); report header shows correct participant count
  • Sessions table — restructured from per-participant rows to per-session rows with a Speakers column showing all speaker codes (m1, p1, p2, o1) per session
  • Transcript page format — heading shows Session N: m1 Name, p5 Name, o1; segment labels show raw codes for consistency with the anonymisation boundary
  • Session duration — now derived from transcript timestamps for VTT-only sessions (previously showed "—")
  • Moderator identification (Phase 1) — per-session speaker codes ([m1]/[p1]) in transcript files, moderator entries in people.yaml, .segment-moderator CSS class for muted moderator styling

0.6.72 Feb 2026

  • Search enhancements — clear button (×) inside the search input, yellow highlight markers on matching text, match count shown in view-switcher label ("7 matching quotes"), ToC and Participants hidden during search, CSV export respects search filter
  • Pipeline warnings — clean dim-yellow warning lines when LLM stages fail (e.g. credit balance too low), with direct billing URL for Claude/ChatGPT; deduplication and 74-char truncation
  • CLI polish — "Bristlenose" in regular weight in the header line, "Report:" label in regular weight in the summary

0.6.62 Feb 2026

  • Cargo/uv-style CLI output — clean checkmark lines with per-stage timing, replacing garbled Rich spinner output; dim header (version · sessions · provider · hardware), LLM token usage with cost estimate, OSC 8 terminal hyperlinks for report path; output capped at 80 columns; all tqdm/HuggingFace progress bars suppressed
  • Search-as-you-type quote filtering — collapsible magnifying glass icon in the toolbar; filters by quote text, speaker, and tag content; overrides view mode during search; hides empty sections/subsections; 150ms debounce
  • Platform-aware session grouping — Teams, Zoom cloud, Zoom local, and Google Meet naming conventions recognised automatically; two-pass grouping (Zoom folders by directory, remaining files by normalised stem); audio extraction skipped when a platform transcript is present
  • Man page — full troff man page (man bristlenose); bundled in the wheel and self-installs to ~/.local/share/man/man1/ for pip/pipx users on first run; wired into snap, CI version gate, and GitHub Release assets
  • Page footer — "Bristlenose version X.Y.Z" colophon linking to the GitHub repo on every generated page

0.6.52 Feb 2026

  • Timecode typography — two-tone treatment with blue digits and muted grey brackets; :visited colour fix so clicked timecodes stay blue
  • Hanging-indent layout — timecodes sit in a left gutter column on both report quotes and transcript pages, making them scannable as a vertical column
  • Non-breaking spaces on quote attributions prevent the — p1 from widowing onto a new line
  • Transcript name propagation — name edits made in the report's participant table now appear on transcript page headings and speaker labels via shared localStorage

0.6.41 Feb 2026

  • Concurrent LLM calls — per-participant stages (speaker identification, topic segmentation, quote extraction) now run up to 3 API calls in parallel via llm_concurrency config; screen clustering and thematic grouping also run concurrently; ~2.7× speedup on the LLM-bound portion for multi-participant studies

0.6.31 Feb 2026

  • Report header redesign — logo top-left (flipped horizontally), "Bristlenose" logotype with project name, right-aligned document title and session metadata
  • View-switcher dropdown — borderless menu to switch between All quotes, Favourite quotes, and Participant data views; replaces old button-bar pattern
  • Copy CSV button with clipboard icon — single adaptive button that exports all or favourites based on the current view
  • Quote attributions use raw participant IDs (— p1) in the report for anonymisation; transcript pages continue to show display names
  • Table of Contents reorganised — Sentiment, Tags, Friction points, and User journeys moved to a dedicated "Analysis" column, separate from Themes

0.6.21 Feb 2026

  • Editable participant names — pencil icon on Name and Role cells in the participant table; inline editing with localStorage persistence; YAML clipboard export for writing back to people.yaml; reconciliation with baked-in data on re-render
  • Auto name and role extraction — Stage 5b LLM prompt now extracts participant names and job titles alongside speaker role identification; speaker label metadata harvested from Teams/DOCX/VTT sources; empty people.yaml fields auto-populated (LLM results take priority over metadata, human edits never overwritten)
  • Short name suggestion — short_name auto-suggested from first token of full_name with disambiguation for collisions ("Sarah J." vs "Sarah K."); works both in the pipeline and in-browser
  • Editable section and theme headings — inline editing on section titles, descriptions, theme titles, and theme descriptions with bidirectional Table of Contents sync

0.6.11 Feb 2026

  • Snap packaging for Linux — snap/snapcraft.yaml recipe and CI workflow (.github/workflows/snap.yml); builds on every push to main, publishes to edge/stable when Store registration completes
  • Pre-release snap testing instructions in README for early feedback on amd64 Linux
  • Author identity (Martin Storey) added to copyright headers, metadata, and legal files

0.6.01 Feb 2026

  • bristlenose doctor command — checks FFmpeg, transcription backend, Whisper model cache, API key validity, network, PII dependencies, and disk space
  • Pre-flight gate on run, transcribe-only, and analyze — catches missing dependencies before slow work starts
  • First-run auto-doctor — runs automatically on first invocation, guides users through setup
  • Install-method-aware fix messages — detects snap, Homebrew, or pip and shows tailored install instructions
  • API key validation — cheap API call catches expired or revoked keys upfront

0.5.01 Feb 2026

  • Per-participant transcript pages — full transcript for each participant with clickable timecodes and video player; participant IDs in the table link to these pages
  • Quote attribution deep-links — clicking — p1 at the end of a quote jumps to the exact segment in the participant's transcript page
  • Segment anchors on transcript pages for deep linking from quotes and external tools

0.4.131 Jan 2026

  • People file (people.yaml) — participant registry with computed stats (words, % words, % speaking time) and human-editable fields (name, role, persona, notes); preserved across re-runs
  • Display names — set short_name in people.yaml, re-render with bristlenose render to update quotes and tables
  • Enriched participant table in reports (ID, Name, Role, Start, Duration, Words, Source) with macOS Finder-style relative dates
  • PII redaction now off by default; opt in with --redact-pii (replaces --no-pii)
  • Man page updated for new CLI flags and output structure

0.4.031 Jan 2026

  • Dark mode — report follows OS/browser preference automatically via CSS light-dark() function
  • Override with color_scheme = "dark" (or "light") in bristlenose.toml or BRISTLENOSE_COLOR_SCHEME env var
  • Dark-mode logo variant (placeholder; proper albino bristlenose pleco coming soon)
  • Print always uses light mode
  • Replaced hard-coded colours in histogram JS with CSS custom properties

0.3.831 Jan 2026

  • Timecode handling audit: verified full pipeline copes with sessions shorter and longer than one hour (mixed MM:SS and HH:MM:SS in the same file round-trips correctly)
  • Edge-case tests for timecode formatting at the 1-hour boundary, sub-minute sessions, long sessions (24h+), and format→parse round-trips

0.3.731 Jan 2026

  • Markdown style template (bristlenose/utils/markdown.py) — single source of truth for all markdown/txt formatting constants and formatter functions
  • Per-session .md transcripts alongside .txt in raw_transcripts/ and cooked_transcripts/
  • Participant codes in transcript segments ([p1] instead of [PARTICIPANT]) for better researcher context when copying quotes
  • Transcript parser accepts both MM:SS and HH:MM:SS timecodes

0.3.631 Jan 2026

  • Document full CI/CD pipeline topology, secrets, and cross-repo setup

0.3.531 Jan 2026

  • Automated Homebrew tap updates and GitHub Releases on every tagged release

0.3.431 Jan 2026

  • Participants table: renamed columns (ID→Session, Session date→Date), added Start time column, date format now dd-mm-yyyy

0.3.331 Jan 2026

  • README rewrite: install moved up, new quick start section, changelog with all versions, dev setup leads with git clone
  • Links to Anthropic and OpenAI API key pages in install instructions

0.3.230 Jan 2026

  • Fix tag auto-suggest offering tags the quote already has
  • Project logo in report header

0.3.130 Jan 2026

  • Single-source version: __init__.py is the only place to bump
  • Updated release process in CONTRIBUTING.md

0.3.030 Jan 2026

  • CI on every push/PR (ruff, mypy, pytest)
  • Automated PyPI publishing on tagged releases (OIDC trusted publishing)

0.2.030 Jan 2026

  • Tag system: AI-generated badges (deletable/restorable) + user tags with auto-suggest and keyboard navigation
  • Favourite quotes with reorder animation and CSV export (separate AI/User tag columns)
  • Inline quote editing with localStorage persistence
  • Sentiment histogram (side-by-side AI + user-tag charts)
  • bristlenose render command for re-rendering without LLM calls
  • Report JS extracted into 8 standalone modules under bristlenose/theme/js/
  • Atomic CSS design system (bristlenose/theme/)

0.1.030 Jan 2026

  • 12-stage pipeline: ingest, extract audio, parse subtitles/docx, transcribe (Whisper), identify speakers, merge, PII redaction (Presidio), topic segmentation, quote extraction, screen clustering, thematic grouping, render
  • HTML report with clickable timecodes and popout video player
  • Quote enrichment: intent, emotion, intensity, journey stage
  • Friction points and user journey summaries
  • Apple Silicon GPU acceleration (MLX), CUDA support, CPU fallback
  • PII redaction with Presidio
  • Cross-platform (macOS, Linux, Windows)
  • Published to PyPI and Homebrew tap
  • AGPL-3.0 licence with CLA