Replanned 2026-09-27 around the revised spec. Source: .claude/plans/voice-lab-room.md
Status: Phases A–C complete (2026-08-27). Replanned 2026-09-27 around the revised spec; Phase G (prerequisite fixes) is next. Awaiting approval. Written 2026-08-27 · replanned 2026-09-27. Canonical design: Docs/mockups/voice-lab-spec.md (revised 2026-09-27) and the nine-screen prototype Docs/mockups/Voice-Lab.html. Review of the spec and every revision: Docs/reviews/2026-09-27-voice-lab-spec-review.html. Supersedes: this plan's own Phases D–F (a single LLM prose analysis over uploaded samples), and the 2026-04-10 mockup at .claude/handoffs/atticus/mockups/voice-lab-60F26437/mockup.html.
Section numbers such as §6.4 refer to the spec.
The spec replaces the design Phases D–F were built for. Five LLM-authored prose dimensions with a preserve / neutral / challenge stance give way to three things:
Mode.
Most measurement is deterministic. The model writes margin notes, judges whether a hedge is justified, and drafts Say it first suggestions. It never places a mark.
The spec left this to the plan. Recommended: retire.
VoiceDimension, MentorStance, VoiceStanceCopy, VoiceAnalysis, VoiceAnalysisParser, VoiceLabDimensionRow, and WorkspaceStore.voiceStance / setVoiceStance.
QuoteVerifier, the sample library, the importer, and drag-and-drop.
VoiceProfile.analysisJSON and dimensionStancesJSON stay in the schema, unread, with a doc comment saying so. The analysis column has never been written, because no model call ever produced an analysis. The stances column was never reachable from the UI. Dropping them would be a HIGH-risk migration with nothing to gain.
| Decision | Resolution |
|---|---|
| Which surfaces use the portrait | Composition (threads, drift), Critique (thread, Leave, and won't anchors), generation (actual-voice directive plus won'ts), and Discovery (one question per piece). The 2026-08-27 exclusion of Discovery is reversed by spec §7.1. |
Deterministic register gates (RegisterBaseline → VoiceCeiling) | Still untouched. The portrait feeds prompts and its own marks, never the ceiling's score. The reasoning in "Why the gates stay untouched" below still holds. |
| Register selector | Mode only. Project.stakes is never set (spec §7.1). |
| Step-back event | Send to Critique, Save, or Start new version (spec §7.2). |
| Backup folder | Out of this plan. It moves to its own spec (spec §8.4). |
| Model-judgment axes (Cool/Warm, Direct/Oblique, Earnest/Wry) | Fed by the pairs only. No LLM classifier in this plan. They stay pencil and can't host a hunch. |
| Spec Open (55): Direct/Oblique core vs. register | Stays core until validation data says otherwise. It is model-judgment and pencil-only, so it can't drive a thread either way. |
| Spec Open (65): two marks per draft | Two, plus Show them anyway. Revisit in Phase N. |
| Thread placement in Composition | Pinned section above the margin tab strip, not a fifth tab. The margin is at four tabs after a 2026-05 complaint about six. |
| Register filter / diff banner / "include my drafts" (2026-08-27 deferrals) | Moot. Mode tabs replace the register filter. "Learned from Thursday's draft" replaces the diff banner. Drafts feed the portrait by design, with generated text excluded. |
RegisterBaseline → VoiceCeiling ships today and actively regenerates drafts that score above the writer's register. Two reasons not to feed Voice Lab material into it:
RegisterScorer.Weights.sentenceComplexity is zero. A polished uploaded chapter sits far above a Discovery chat answer on lexical rarity too.
The current model holds one sample and actively deletes the others.
WorkspaceStore.writeVoiceSample. It currently runs for stale in profile.samples where stale.id != existing.id { modelContext.delete(stale) }. That was correct for a single-sample UI; in a multi-sample world it eats the writer's library on every edit. This is the single most dangerous line in the existing code for this plan.
title: String and workTypeRaw: String to VoiceSample, plus a VoiceWorkType enum (shortStory, essay, personalEssay, novelExcerpt, flashFiction, poem, other) with a computed wrapper over the raw string — the house convention for enum-backed columns, so new cases absorb via lightweight migration.
title from sourceFilename minus extension, else "Untitled sample"; workType = .other. Extend backfillLegacyVoiceSample() or add a sibling; idempotent, safe on every launch, following backfillLegacyDraftVersions as the pattern.
addVoiceSample(text:sourceFilename:title:workType:), removeVoiceSample(id:), renameVoiceSample(id:title:), setWorkType(id:_:). addVoiceSample appends; the existing updateVoiceSample is retained and re-documented as the onboarding single-sample replace path only.
voiceSampleCharacterLimit (20,000). Add a total cap — a sample count ceiling (suggest 12) so the analysis prompt has a bounded worst case. Refuse politely at the ceiling; never silently drop.
Deliverable: a sample library that can hold many samples without losing any.
Atticus/Views/VoiceLab/VoiceLabView.swift, replacing VoiceLabStubSurface in ContentView's .voiceLab branch. Delete the stub rather than leaving both (see the duplicate-file rule in the global guidelines).
AtticusTheme tokens — the mockup's #F3F0EA / #FCFAF5 / #365750 / #C97C44 / #1D2828 map to canvas / surface / primary / secondary / ink. Do not reintroduce raw hex.
Voice Lab + Built from N samples across M months of your work. Singular/plural correct; the "across M months" clause is omitted when all samples fall in one month rather than printing "across 1 months".
× remove. Removal is a real destructive action on writer material — confirm before deleting, and state that the profile will recalculate.
dropDestination), browse (existing .fileImporter), and paste. Reuses WritingSampleImporter and VoiceSampleInputCard where it fits; the drop path must run the same extraction and the same error surfacing as the picker path.
Deliverable: the Voice Lab room, managing a real sample library.
Built and fully tested before anything calls a model — the sequencing that synthesis-quality-gates.md used for RegisterScorer.
VoiceDimension enum with exactly five fixed cases: sentenceRhythm, wordChoices, tonalRange, sceneBuilding, structuralPatterns. Fixed, not LLM-chosen — this is what lets the writer's stance survive a re-analysis. The model fills in prose and evidence for known dimensions; it never invents the taxonomy.
MentorStance enum: preserve / neutral / challenge, defaulting to neutral.
VoiceAnalysis — a Codable/Sendable value type with schemaVersion: overall summary: String plus per-dimension description: String and evidence: [Evidence] (quote text + source sample ID + source title). Persisted as a JSON blob on VoiceProfile.analysisJSON, mirroring the voiceFingerprintJSON convention so the 5-dimension shape can iterate without schema migrations.
VoiceProfile.dimensionStancesJSON — a separate blob holding [VoiceDimension: MentorStance]. Separate on purpose: a profile rebuild overwrites the analysis, and the writer's stances must not go with it. Losing a writer's deliberate "Preserve my long sentences" because they added a sample would be a bug they would not forgive.
VoiceAnalysisParser — tolerant tag parser in the house style (<summary>, <dimension name="…">, <description>, <evidence sample="…">), modeled on DiffSummaryParser: degrade to nil or to a partial analysis rather than throwing, and tolerate a missing wrapper.
QuoteVerifier — the load-bearing guard. Every evidence quote must be a verbatim substring of the sample it is attributed to, after normalizing whitespace, smart quotes, dashes, and ellipses. Non-matching quotes are dropped, not displayed. A dimension that ends with zero verified quotes renders its prose alone.
Rationale: this is the writer's own prose. A quote that is subtly paraphrased — the failure mode LLMs are most prone to — is immediately visible to the one person who wrote it, and it destroys trust in the whole profile. Reuse or mirror the existing substring-anchoring logic in EditorHighlightResolver / the critique quotedSubstring degradation pattern rather than writing a third one.
Deliverable: a verified analysis contract that cannot display prose the writer did not write.
Where the build departed from the plan above, and why.
VoiceDimension to render, so the pure contract landed first. No scope change.
VoiceSample.addedAt records when a sample was added to Atticus, not when it was written — a chapter drafted in 2019 and uploaded today would make "across 14 months of your work" false. Restoring the mockup's phrasing needs a writtenAt field on VoiceSample; until then the header reports only what we know. Guarded by testHeaderSubtitleDoesNotClaimATimeSpanWeCannotKnow.
VoiceStanceCopy landed early (planned for Phase E). VoiceLabDimensionRow needs the consequence sentence to render the stance control, so all 15 strings are written and tested now. The file also carries directive(dimension:stance:) — the prompt-side instruction Phase E consumes — deliberately beside the writer-facing copy so the promise and the instruction cannot drift apart.
VoiceLabDimensionRow renders it only when handed an onStanceChange; the room passes nil until Phase E wires the directive, so the writer is never shown a setting that changes nothing. WorkspaceStore.setVoiceStance and the persistence are complete and tested — only the wiring is withheld.
⋯ menu alongside the mockup's ×. The mockup shows only removal, but renaming and retyping have to be reachable by keyboard, which a context menu alone is not.
Suite: 730 tests, 0 failures (was 671 before this work).
These are bugs against the spec's generation policy (§7.4), and they exist today. They land first because every later phase measures drafts, and a draft measured before this phase includes Atticus's own sentences.
AuthorshipLedger: a per-project set of normalized sentences Atticus wrote. It is stored as a JSON blob on Project (LOW risk: a new nullable column). Every generation path records its output: DraftGenerator, applyRevision, applyReviewChanges, and, later, thread Say it first. A sentence counts as Atticus's while its normalized text matches exactly. Once the writer edits it, it is theirs. Sentence-level text matching survives paragraph splits, merges, and reordering, which ParagraphOrigin's paragraph granularity cannot.
VoiceFingerprintBuilder's sources exclude ledger sentences. Today, applied revisions pass through commitBuffer (CompositionViewModel.swift:1270-1283) and come back as .writerOriginal. Leave ParagraphOrigin as it is. The ledger is the authority for voice measurement, and a note at commitBuffer says so.
canRequestRevision (:1949) and canRequestWholeDraftRevision (:2589) return false when project.writingType.isStudentWork, and the Discussion prompt for student work asks a question in place of offering a <revision>. Behavior change: students lose passage rewrites they can reach today, which spec §7.4 requires. Flag it in release notes.
applyRevision and applyReviewChanges go through the text view's undo manager (shouldChangeText / replaceCharacters / didChangeText), not setAttributedString, so ⌘Z reverts an applied revision. Threads depend on this (§6.4).
SentenceSplitter, built on NLTokenizer(.sentence) with post-rules ported from the prototype: no split after an abbreviation or initial, ellipses handled, closing quotes and brackets attached. It also reports an in-progress trailing sentence. RegisterScorer.sentences(in:) stays as it is, so the VoiceCeiling numbers don't shift.
Deliverable: Atticus can tell its sentences from the writer's, and students get no rewrites.
VoiceAxis: ten fixed cases, each carrying its poles, its group (core / register), and its detection kind (detected / modelJudgment), following spec §8.1. Fixed for the same reason VoiceDimension was: writer decisions key off it and must survive recomputation.
RegisterScorer lexical rarity, reused as it is.
VoiceFingerprintBuilder.minimumContentWords works.
VoiceEvidence @Model rows cascading from VoiceProfile (LOW risk: new table). Each row holds:
write / reach) and source (pair, ownSentence, paragraph, page, upload, draft, admired);
Mode?, and the position or lean;
VoiceDecisions: a separate JSON blob on VoiceProfile, following the same separate-blob rule as stances. It holds:
PortraitBuilder: a pure function from evidence plus decisions to the portrait. It computes:
HunchSelector (§6.1): verdict Work, a mode heard, a detected axis, the largest gap. It returns nil when no gap qualifies.
VoiceSample.modeRaw column (LOW). Backfill it once from workType: short story, novel excerpt, and flash fiction map to narrative; essay to analytical; personal essay to reflective; poem to artistic; other to nil, meaning not sure. workTypeRaw stays in the schema, unread.
Deliverable: give it evidence and it returns a correct portrait and hunch, with no screen yet.
VoiceDimension.swift, VoiceStanceCopy.swift, VoiceAnalysis.swift, VoiceAnalysisParser.swift, VoiceLabDimensionRow.swift, the stance store methods, and their tests. Before deleting, move anything QuoteVerifier imports from VoiceAnalysis (such as Evidence) into QuoteVerifier.
analysisJSON and dimensionStancesJSON as retired and unread. No column drop (see the retirement decision above).
Deliverable: one model of voice in the codebase.
VoiceLabView is restructured around them. The samples panel becomes Door II's tray.
QuoteVerifier; a note that fails is dropped.
SentenceSplitter dots, accessible count text, and the ceiling copy. Overruns are stored at zero weight. Alternates appear where they exist. Axis-neutral sparks (Principle 7) need rewriting: the analytical note and the "polite" spark. One model note on Keep this page.
VoiceDecisions, and returning resumes at the first unfinished exercise. Nothing is thrown away.
populatedAt is set on the first evidence from any door, so the rail badge keeps its contract.
Deliverable: a writer can sit, bring pages, or bring an admired paragraph, and all three feed the portrait.
Deliverable: the sitting ends honestly, on a hunch or on "nothing yet."
CompositionViewModel posts draftSteppedBack(draftID, version) from sendToCritique(), Save, and Start new version. There is one path, and it never runs on view appearance.
Mode. Skip projects marked "not in my voice" or answered "occasion's voice" (Phase M). Show "Learned from Thursday's draft" on the portrait.
Draft (LOW: a new column) that withdraws that draft's evidence.
VoiceDecisions.
Deliverable: a hunch is confirmed or withdrawn on real writing, and the writer decides what it means.
Gate: the first Phase N item (Sarah's writer validation) runs here. Phase M starts only after it passes.
EditorHighlightResolver. This is the riskiest UI work in the plan; see "Custom editor revert context." It is built as temporary attributes on the existing NSTextView, not a new editor.
CritiqueService.Request gains voiceConstraints, rendered as a prompt block holding the active thread, Leave decisions for the project's mode, and won'ts. A post-parse filter drops any finding whose suggested text adds an epistemic hedge while a Hedged → Committed thread is active, or reintroduces a won't pattern. Both the Critique room and inline Composition critique pass it.
MentorContextStore), and it sets or reads Modifier.voiceLoaded / .formallyConventionalized. An occasion's-voice answer pauses the thread for that piece and keeps the piece out of the portrait.
VoiceDirectiveBuilder built from the portrait's write marks for the project's mode, plus won'ts. It never uses reach marks (§7.4.3). It replaces project.seedText as writerProfile in DraftGenerator and the revision paths, in a cache_control: ephemeral block. It emits nothing when the portrait is empty. VoiceCeiling still runs.
VoiceDrift remains disabled, and its header comment points here.
Deliverable: the writer's decisions reach every surface that comments on or writes their prose.
domain-models.md: VoiceEvidence, VoiceDecisions, the ledger, VoiceSample.modeRaw, and the retired columns;
services.md: detectors, PortraitBuilder, and the step-back event;
ui-views.md and shared-views.md. Also correct the stale claim at RegisterScorer.swift:30 that "Phase D calibration confirmed" the weights.
// TODO(lens-shift) marker at ConceptViewModel.swift:1500, which replan.md gates on Voice Lab landing.
| Change | Risk |
|---|---|
Project.authorshipLedgerJSON (nullable) | LOW |
VoiceEvidence table | LOW |
VoiceProfile.decisionsJSON (nullable) | LOW |
VoiceSample.modeRaw (nullable) + one-time backfill from workTypeRaw | LOW. Additive: the source column is untouched |
Draft.notInMyVoice (Bool, default false) | LOW |
Retiring analysisJSON / dimensionStancesJSON | None. The columns stay and are only unread. No DROP |
No HIGH-risk migration is planned.
| Call | When | Bound |
|---|---|---|
| Paragraph edit notes | Keep it | 1 per sitting |
| Short-page note | Keep this page | ≤ 5 per sitting |
| Justified-hedge judgment | Step-back, only while a gap is listening or threaded | 1 per step-back |
| Say it first | Per thread mark | 2 per draft, plus Show them anyway |
Portrait computation, uploads, and draft learning make no model calls. This replaces the old plan's unbounded "rebuild on every sample change" analysis.
RegisterScorer corpus are English. Non-English samples should produce "not heard" instead of wrong marks. Detect the language with NLLanguageRecognizer and skip the sample.
Docs/mockups/voice-lab-spec.md, Docs/mockups/Voice-Lab.html: the design
Docs/reviews/2026-09-27-voice-lab-spec-review.html: spec review and code facts
Atticus/Rooms/Composition/CompositionViewModel.swift: commitBuffer (:1270), applyRevision (:2394), sendToCritique (:1817), revision gates (:1949, :2589)
Atticus/Rooms/Composition/CompositionEditorView.swift: single highlight (:317), non-undoable model push (:146)
Atticus/Rooms/Critique/CritiqueService.swift: Request (:15), userMessage (:156)
Atticus/Workspace/MentorContextStore.swift: reflective-offer flow (:155)
Atticus/Synthesis/VoiceFingerprintBuilder.swift, VoiceDrift.swift, RegisterScorer.swift
Atticus/Services/QuoteVerifier.swift: kept
NSTextView that already had one custom-editor attempt reverted.
I'm confident in the sequencing, the ledger, deterministic measurement, the retirement call, and keeping migrations additive. The doubt sits in three empirical places: whether lexicon detectors plus provisional bands produce a portrait the writer recognises; whether the mirror's median-instance rule reads as fair; and how much the editor work costs. Phase N answers the first two, and it is placed early enough that a bad answer stops the plan before Phase M.