Skip to content

refactor: one home per rule — the day's one read model, one Decode Start, one Launch Mode, and nine more deepenings - #638

Merged
spokvulcan merged 47 commits into
mainfrom
refactor/deepen-modules
Oct 11, 2026
Merged

spokvulcan merged 47 commits into
mainfrom
refactor/deepen-modules

Conversation

@spokvulcan

Copy link
Copy Markdown
Owner

Summary

The twelve deepenings from the 2026-10-10 architecture review are all in this PR. Each rule that was written in several places now has one home, behind a small interface that the tests go through.

 the step under way
-  4 rules: engine focus, Moments, Now Card, Day Line (a started step in a block read differently on each)
+  Timeline.underWay: started step › meeting › block › unstarted task; every surface reads it
 a step's life
-  plan + 7 string-keyed maps ("id@epoch", "id@epoch+minutes"), rewritten by hasPrefix surgery
+  Step: its slot and its life (bound to its start, its end or its task); moving it is one operation
 the owner's changes from Today
-  Today wrote Reminders itself; cues and cards went through the engine; the runtime held the date rules
+  runtime.act → Day Engine → AgendaMutation → Agenda.apply (date and series rules in one place)
 today's occurrence of a repeating task
-  the engine matched the done copy by title and re-keyed its state; Undo reopened an already open series
+  the Agenda answers it; take-back only of ticks the Agenda made; every other untick is a plain reopen
 KV Scheme conversion
-  6 decode starts, 4 mechanisms (27e49510 was the one that forgot)
+  DecodeStart.make(handover:) converts once, where its Decode Handover needs it
 "can this snapshot seed a prefill?"
-  spelled 5 times; restore and extend written 3 ways; only the keyed turn dropped a bad snapshot
+  PrefillPlanner.usableRestore + ResidualPrefill (one failed-restore policy)
 a turn's restore, in every report
-  a typed outcome flattened to restoreMode: String + 4 loose values, re-projected 5 times (2 drifted)
+  TurnRestore → one TurnReport per claim (lookup, memory phases, one leafStore, reserve, trace)
 what kind of process is this?
-  4 predicates in 10 files; 17 harness flags written twice; setup() skipped 3 of 17 harnesses
+  LaunchMode + one Harness table
 hotkeys
-  ids typed at registration and again at rebind; an unknown id disabled rebinding silently
+  HotkeyTable: one row per hotkey; it follows its setting; the pane reads the same rows
 voice capture
-  every surface restated the recipe (onboarding had no Learned Words); the Live Preview copied the pipeline
+  VoiceCapture.makeSession(): every stage required; the session owns the take end to end
 which models are loaded
-  derived twice; Offload freed 3 of 6; a model's folder was rebuilt by hand at about 8 sites
+  ModelResidency + ModelDefinition's one folder rule (usableDirectory)
tesseract/
├── Core/LaunchMode.swift, VoiceCaptureSession.swift (VoiceCapture)    10, 09
├── App/Harness.swift, HotkeyTable.swift, ModelResidency.swift          10, 11, 12
└── Features/
    ├── Companion/Day/Timeline.swift, DayFacts+Time.swift, WaitingOnYou  01
    ├── Companion/Engine/Step.swift                                     02
    ├── Companion/Agenda/Agenda+Changes.swift, Agenda+Occurrences.swift  03, 04
    ├── Agent/DecodeStart.swift                                         05
    └── Server/ResidualPrefill.swift, TurnRestore.swift, TurnReport.swift  06, 07   (08: PrefixCacheManager keeps its own call order)

Two fixes surfaced while reviewing the branch, each in its own commit:

  • AgentRunController: an older run's settling lowered a newer run's busy flag and handed its queued message back to the composer. This made DayThreadPicturesTests flaky.
  • The gate scripts (scripts/lib/checkout.sh): dev.sh's loaded-model gates no longer exit 141 on SIGPIPE, and no script touches another worktree's DerivedData.

Declared behaviour changes (each pinned by a test; details in each commit and ADR)

Companion (ADR-0091, ADR-0092):

  • Now Card: a step the owner started is the step even inside their block, over a meeting it runs into, and over an earlier unstarted task. A meeting inside a block is the step.
  • Day Line: it tints one row, the step under way.
  • Jarvis Panel: it lists the plan's steps as they stand, and won't offer to start a started one.
  • A step taken off the plan and put back starts fresh.
  • A step started from Today before a meeting is timed against the minutes it has.
  • Undo of a tick leaves Reminders as it was. For a repeating task's occurrence ticked off by the app, the done copy is deleted and the series is put back.
  • Every other untick is a plain reopen, even of a one-off that shares a series' name.
  • A ticked repeating must-do reads as done in Today's Chat.
  • A repeating reminder is never given a new time.

Server (ADR-0093, ADR-0094, ADR-0069 and ADR-0087 amendments):

  • Failed restores: one in the boundary route or the Speculative Canonical Prefill now drops the snapshot. That logs one extra restore/unrestorable-snapshot line.
  • Speculative chunks evaluate checked, so an MLX failure logs prefill-threw instead of ending the process.
  • A fallback turn's leaf reports handoff/live, not copy. The trace records restoredOffset=0 when nothing was restored.
  • An MTP copy reports copyReason=mtp.
  • A rewind after a waited handoff reports its copyWaitMs.

Composition (ADR-0095, ADR-0096, ADR-0081 amendment):

  • Harnesses: every harness skips the owner's background work and opens no window. A test host or scratch launch never runs a harness.
  • Onboarding's takes gain Learned Words, the Capture Dump and the Proofread Pass.
  • The Models section reads one open-take fact. A voice-session take shows Listening/Transcribing, and any surface's pass shows Proofreading.
  • "Insert anyway" keeps the take's length and its own app.
  • Offload also frees the recall embedder, and frees the proofreader at once.
  • A panel's Escape during its own proofread no longer cuts off dictation's transcription.
  • scripts/dev.sh clean removes this checkout's DerivedData only.

Evidence

Unit tests, scripts/test.sh:

  • Before (main fa438b35): the whole target green.
  • After (this branch): 3,705 tests, 3,698 passed, 0 failed, 7 skipped. scripts/check.sh fa438b35 passes over the whole branch, and so does the tesseract-ios build.

The new tests that fail without their fix include:

AgentRunControllerTests.anOlderRunSettlingLeavesTheNewerRunItsFlagAndItsSettle
  main:  run.isGenerating → false, settles → ["settled"]   (older run cleared the newer run's flag)
  here:  true, []                                         then one settle when the newer run ends
DictationCoordinatorTests.insertingARejectedTakeAnywayKeepsTheTakesOwnApp
  main:  records Terminal (the session's current app)      here: Notes (the take's own)
DayEngineRepeatingTaskTests.takenBackTheOccurrenceLeavesRemindersAsItWas (undo / by task / by copy × today / overdue)
DayEngineRepeatingTaskTests.aOneOffOfTheSeriesNameIsOnlyEverOpenedAgain

Loaded-model gates (MacBook Pro M3 Max, 48 GB, Debug), using ADR-0078's method: diagnostics masked for ids and timings, other-task lines compared as multisets.

gate main fa438b35 this branch
prefix-cache-e2e, qwen3.5-4b-paro 38/38 38/38: check and decode lines identical, 0 of 1,959 in-order diagnostics lines differ (merged build against the server track's, which matched main)
the same e2e, qwen3.8-27b + DFlash2 38/38 38/38: the 32 acceptance lines and every check line identical to main's
the same e2e, qwen3.8-27b, turbo8v4 (server track) 37/38 (requestP3, a main bug) 37/38, the same failure, 0 of 2,018 lines differ
hybrid-cache-correctness 12/12, bitwise 12/12, every restore bitwise (maxAbsDiff=0.0)
dflash2-bench --bench-check (server track) 145/321 accepted, 46 rounds identical streams and round accounting

The only diagnostics differences, every run, are the Speculative Canonical Prefill's scheduling race. It runs both ways and follows the machine's load: a fresh main run logged not-idle 20 times against 25 in the earlier main runs.

Merge Danger

Door: two-way, with two one-way edges:

  • day-state.json is written in the Step shape. Main's code still reads its slots, but a revert in the middle of a day forgets that day's cue and start marks.
  • Taking back a repeating task's tick deletes the completion copy Reminders made. It does this only for a tick the app made this session, and only while the copy and the series are as the tick left them.

Blast Radius: wide. It covers:

  • the Companion's day: Today, the Now Card, the Day Line, cues, and Agenda writes;
  • every voice surface;
  • global hotkeys;
  • launch (windows, harnesses);
  • the Models menu and Offload;
  • the server's decode and restore paths.

The gates above cover the server. The rest is unit tests through the new interfaces.

Left for later

Each item is recorded in its ADR:

  • The drive's keyed/unkeyed branches that depend on Request Keying stay (ADR-0069).
  • The Later branch for a repeating reminder still lives in the engine; removing it would put one-offs in the plan (ADR-0080).
  • The Today composer's Undo banner still goes through Capture (ADR-0080).
  • scripts/bench.sh keeps its own list of harness log folders.
  • --rotated-checkpoint-parity still loses to --benchmark (ADR-0095).
  • Whisper keeps its stricter model contract (ADR-0081).
  • CapturePanelController and CompanionVoiceSessionController still have no suites.

Docs impact

  • New or changed domain term → updated CONTEXT.md (Timeline, Step, Step under way, Meeting, Block, Occurrence, Time to leave, Usable Restore, Residual Prefill, Decode Start, Decode Handover, Turn Restore, Turn Report, Launch Mode, Harness, Hotkey Table, Voice Capture, Model Residency, Usable Directory)
  • Structural change → updated ARCHITECTURE.md
  • Durable decisions → ADR-0091 to ADR-0096, plus amendments to ADR-0034, 0053, 0069, 0073, 0078, 0080, 0081, 0082, 0083, 0085, 0086, 0087
  • Test workflow change → updated docs/testing.md and docs/prefix-cache-testing.md

"What kind of process is this?" was answered by four predicates in ten
files (isRunningTests, usesScratchData, isHarnessLaunch and raw
CommandLine.arguments checks), and the 17 harness flags were written
twice: in TesseractApp.harnessFlags and again in the init's dispatch
chain with their labels and log subdirectories. setup() skipped the
owner's background work for only 3 of the 17 harnesses (the DFlash2 guard
landed after a Companion moment loaded a second model beside a ruler run
and deadlocked MLX), and harness launches still opened the main window.

The Launch Mode (Core/LaunchMode.swift) decides once, from the arguments
and environment, whether the process is the owner's launch, a scratch
launch, the test host or a harness, and answers what it may do: keep off
the owner's data, run the owner's background work, which window to open
at launch, post notifications, enforce a single instance, migrate the
owner's data, play sounds. Callers ask an answer, never the arguments.
One harness table (App/Harness.swift) holds each harness's flag, label,
log subdirectory, failure exit code and run; its order is precedence, so
--paro-parity-bench still wins over --benchmark, which still exits 0 on
failure. Shared code (StorageEnvironment) asks the same decision from the
environment alone, which decides first.

Behaviour changes, pinned in LaunchModeTests:
- every harness skips setup()'s background work (was 3 of 17);
- a harness opens no window and restores none, so it installs no menu
  bar item and never opens the Welcome window on an un-onboarded Mac;
- a harness no longer runs the Companion's retired-data clean at launch.

Docs: ADR-0095, an ADR-0073 amendment, CONTEXT (Launch Mode, Harness),
ARCHITECTURE and docs/testing.md.
One step's life was spread over the plan and six string-keyed
collections in DayState (cuedSteps, startedSteps, smallStarts,
startedMinutes, putOff, cutShort) plus cueOnPanel, keyed "id@epoch" for
a start's marks, "id@epoch+minutes" for an end's check-in and by task id
for what was put off or cut. Every new per-step fact had to be threaded
through the decoder, relaunched, rolledOver, follow, the Companion going
off and a card taking the panel; the repeating must-do fix rewrote the
maps by string-prefix surgery; Today rebuilt the engine's key from a
Timeline row; tests filled three or four maps to set up a started step.

The plan now holds Steps (Engine/Step.swift): a task's slot and its
life, each part bound to the start, the end or the task, the binding
read off the old keys. Moving is one operation (Step.reslot, move,
resize) that resets what the old start or end held and keeps what the
task carries; the cue on the panel is a CueOnPanel (task and phase), and
taking it down unanswered is DayState.uncuePanel. A day-state.json saved
with the maps is read once into its steps; only steps are written.

Behaviour change (ADR-0091), pinned by
aStepTakenOffThePlanAndPutBackStartsAfresh: a task taken off today's
plan and put back is a fresh step, its put-off count begun again (the
map kept it by task for the rest of the day). The marks of a slot a step
has left are no longer kept; only the cue reaction's secondsToReact read
them, so a re-plan that moves a step while its cue is still on the panel
leaves that one reaction without a time. Trace vocabulary and values are
otherwise unchanged.
Each hotkey id ("tts", "agent", "capture", "appshot", ...) was written at
registration in the container and again at rebind in App Bindings, and
HotkeyManager.updateRegisteredHotkey returned silently on an unknown id,
so a typo turned live rebinding off while the five rebind tests (which
only recorded effect calls) still passed. Six updateXHotkey effects were
one-line pass-throughs, the Hotkeys pane restated three defaults the
Settings Catalogue owns, and the ADR-0082 interrupt rule lived untested
in two container closures.

The Hotkey Table (App/HotkeyTable.swift) has one row per global hotkey:
the setting that holds its combo, its default read from the catalogue,
its label and help, and what its press, release, spoiled press and ⇧ tap
do, by the name of an action the container supplies. HotkeyTable binds
every row at launch through the registrar seam (HotkeyManager in the
app, an in-memory peer in the tests) and re-binds a row when its setting
changes to a different combo. The pane lists the same rows. The interrupt
keys are marked on their rows and one named action applies the rule.
App Bindings loses the hotkey subscriptions and currentDictationHotkey;
HotkeyManager loses registerHotkey, updateRegisteredHotkey,
unregisterHotkey (already dead) and its id constants. A press now reads
whether a voice session is active without building one (and the chat
session with it).

Behaviour change, pinned in HotkeyTableTests: every row of the Hotkeys
pane offers "Reset to <default>" once its combo differs from the
catalogue's default; before, only Fix Last Take, Capture to Reminders and
Appshot did.

Tests: HotkeyTableTests (every row bound from its setting, each key does
what its row says, a changed setting moves its key, an unchanged combo is
never re-bound, the interrupt keys in a voice session, defaults from the
catalogue) replace the five AppBindingsTests rebind tests.

Docs: CONTEXT (Hotkey Table; App Bindings loses hotkey rebinding),
ARCHITECTURE, docs/testing.md, an ADR-0082 amendment.
…ry generation path

Six decode starts each decided where the KV Scheme converts, by four
mechanisms, and each handed the decoded array to its owner, badged a
speculative arm and started the token loop by hand; 27e4951 was a start
that forgot to convert. The Decode Start (ADR-0093, the module ADR-0087
decision 8 names) is now the one place: a path names its Decode Handover
(standard, speculative, preparing, or the Prefill Strategy route), and the
handover fixes the Model Session verb, where the scheme converts and which
part of the KV plan each verb still applies. It reads the converted array
back, gives it to a leased leaf's cache or a new owner, emits the badge
right before prefillFinished (where every speculative path emitted it) and
starts the loop through one iterator enum, absorbing KeyedDecodeIterator and
SpeculativeDecodeIterator.startGeneration.

No verb is added (ADR-0016) and engagement stays in Speculation.plan
(ADR-0079). RequestFacts.decodeParameters goes, the scheme string is read
back in one place (KVScheme(parameters)), and the logit seam loses its
pathQuantizesKVUpFront flag: its one carve-out lives in PrefillStrategy, the
only place it applies.

Behaviour is unchanged: every path keeps its conversion point, error
scopes, progress-event order and loop-start position. The loaded-model
gates on qwen3.5-4b-paro, qwen3.8-27b with DFlash2, and qwen3.8-27b under
turbo8v4 match main check for check and acceptance line for acceptance
line, hybrid-cache-correctness matches bitwise, and the masked diagnostics
differ only in the Speculative Canonical Prefill's scheduling race.

DecodeStartTests is one table of handover x scheme x penalties over the toy
Model Session; it replaces the per-path wiring tests.
The Agenda's snapshot and the day's state were projected into DayFacts
three ways (the engine, a SwiftUI body on Today, the Jarvis Panel from a
card's placements), and every reader rebuilt the facts and the timeline
and copied bits of state and settings. On top of them "the step under
way" had four rules that disagreed: the engine's focus and the menu-bar
clock said a step the owner started, the Now Card showed any event first,
the Day Line tinted every row spanning now, so a step started inside the
owner's own block was the step for the engine and the block on the Now
Card. Free and busy time was computed three ways, "a time to leave still
applies" was written eight times, and the evening window, the Area name
and Waiting on You twice each.

Timeline(snapshot:state:) (Features/Companion/Day/, moved out of Today/
with DayFacts, whose init(snapshot:state:) is now the one projection)
answers what every surface asks: the Day Line and tomorrow, the step
under way (UnderWay: a started step, then a meeting, then a block, then a
task whose slot is now), the next step, when to leave, free time and the
Inbox's offers, Waiting on You, the menu bar's clock. Today reads it in
one call (CompanionRuntime.timeline(at:)), NowCardBuilder decides on it,
the panel lists a plan's steps from it, the engine reads its focus, next
step and tomorrow from it. DayFacts+Time classifies time once (a meeting:
other people or a place; a block of the owner's own; a time to leave that
stands while its event does), and the Day Line's free time, a step's
boundaries and the card parser's placement check are named compositions
of it, each keeping its own grid and strictness. The evening window
(DaySettings.isEvening), the Area name and Waiting on You (WaitingOnYou)
have one home each.

Behaviour changes (ADR-0091), each pinned by a test:
- A step the owner started inside a block of their own is the step on the
  Now Card, as it was for the engine and the menu bar
  (aStepStartedInsideABlockReadsTheSameEverywhere).
- The Day Line tints one row, the step under way (it tinted every row
  spanning now, a tomorrow row in the small hours too).
- A meeting under way inside a block is the step on the Now Card,
  whichever started first (aMeetingInsideABlockIsTheStepTheDayIsOn).
- The Jarvis Panel lists a Morning Plan's steps from the day's plan as it
  stands, not the card's copy; a step already started is not offered to
  start again (thePlansStepsAreTheTasksPlannedAmongTheEvents).

Tests: TimelineBuilderTests became TimelineTests over a DayFixture
snapshot and state, with the step under way's decision table and the one
test reading a started step inside a block on the Now Card, the Day Line,
the menu bar and the engine; NowCardTests builds every card from a
snapshot and state; the freeGaps and InboxSlot.suggest tests on the old
seams are gone, their rules tested through the Timeline.
… session

The Voice Capture Session was deep, but every dependency it took was
optional with a nil default, so each surface restated the whole recipe:
the Onboarding Tour built its Voice Input without the Learned Words, the
Capture Dump or the Proofread Pass (ADR-0085 §2 says every caller gains
them); the proofread closure and the language read were hand-built three
times; the Live Preview re-composed the cleanup → Learned Words pipeline
in DictationCoordinator and reached past the session to the mic and the
recognizer, so ADR-0086 §3 was enforced by two copies; "Insert anyway"
re-implemented history, catch counting and the paste; and "is a take
open" was a hand list of surfaces (#636 had to add Today's).

Voice Capture (Core/VoiceCaptureSession.swift) is built once in the
container from the shared mic and recognizer, the Capture Dump and its
switch, the Learned Words, the Proofread Pass and the language, every one
required, and is the only way to make a session. The session owns the
take: one text pipeline for both the Live Preview (start(preview:) runs
the pump) and the final pass, the Proofread Pass as the .proofreading
phase, "insert anyway" (commitRejectedTake through the surface's own
commit), and whether its take is open (phase), which Voice Capture
answers across every session for the Models section. Surfaces keep their
commit, presentation, sounds and errors. One hold-to-talk modifier and
one "spoken words join typed words" rule replace the copies.

Behaviour changes, each pinned by a test:
- the Onboarding Tour's takes gain the Learned Words, the Capture Dump
  and the Proofread Pass (AgentVoiceInputControllerTests);
- the Models section reads one open-take fact: a voice session's take
  shows Listening/Transcribing (was "Voice session" throughout), and any
  surface's Proofread Pass shows Proofreading, not only dictation's
  (VoiceCaptureSessionTests);
- "Insert anyway" commits like any take: its history entry keeps the
  take's length instead of 0 s (DictationCoordinatorTests);
- spoken words join the agent composer's text after its trailing spaces
  and newlines are trimmed, like every other field (AgentVoiceInputControllerTests).

Tests: VoiceCaptureSessionTests run through the module (plus the preview
and the take reading the same, insert anyway, one open take across
surfaces); AgentVoiceInputControllerTests (every stage, the joining rule)
lose the no-dependencies case; DictationCoordinatorTests build their
coordinator the container's way; MenuModelsSectionTests lose the two
hand-list tests from #636.

Docs: ADR-0096, amendments to ADR-0034, ADR-0085 and ADR-0086, CONTEXT
(Voice Capture; Voice Capture Session, Voice Input and Proofread Pass
updated), ARCHITECTURE, docs/testing.md.
…nd frees them

"Which models are loaded" was derived twice — by the menu's Models
section (a 113-line derivation in the container) and by LoadedModels on
the Models page — with different reads of the language model's state;
the page read a @published flag on the container for the embedder. The
arbiter mirrored residency for 2 of the 6 models while Offload freed 3,
and the recall embedder was never unloaded. "The usable directory for a
model" was spelled out at each loader (isDownloaded ? modelPath : nil).

Model Residency (App/ModelResidency.swift) takes one row per model's
holder (DependencyContainer+ModelResidency.swift): the model it holds,
whether it is loaded and what it is doing, and how to free it. The
menu's Models section, the Models page's in-memory mark and Offload
Model all read it. Offload frees every holder that lets go of its model
at once; the language model goes after the running generation through
InferenceArbiter.offloadLLM, and dictation's Whisper stays (ADR-0025).
The arbiter loses ModelSlot, loadedSlots and its voice closures; the
embedder gets its own holder (RecallEmbedder) with a loaded mirror kept
by its own load and unload. ModelDownloadManager.usableDirectory(for:)
is the one answer every loader asks (the proofreader, the embedder, the
arbiter, the vision probe), and sizeOnDisk(of:) the one size read. The
usable directory lives with the download manager rather than the
residency module: it already owns where a model's files go and whether
they are complete, and the phone shares it.

Behaviour changes, pinned in ModelResidencyTests:
- Offload Model also frees the recall embedder (unloading it is safe: a
  running embed keeps its own reference; the next recall loads it again
  in about 0.4 s), and frees the proofreader at once instead of after
  the language model's generation;
- the Offload item is enabled while any model it can free is loaded, the
  proofreader or the embedder alone included;
- Clear Disk Cache offloads the same way before it wipes, so it frees
  the proofreader and the embedder too.

Tests: ModelResidencyTests (rows are what holders report, the draft
listed only while loaded, the page's mark, Offload frees everything
unloadable and leaves Whisper, when Offload is offered, the usable
directory) replace LoadedModelsTests.

Docs: an ADR-0081 amendment (Model Residency; As built moves), CONTEXT
(Model Residency; Inference Arbiter updated), ARCHITECTURE,
docs/testing.md.
…Residual Prefill extends it

Whether a snapshot can seed a prefill was spelled five times (the planner,
the Leaf Admission Builder, the boundary route, the speculative pass, salvage)
and two producers restored and extended a prefix their own way, the
speculative pass on raw context.model outside the session verbs (ADR-0078
follow-up 4). Only the keyed turn dropped a snapshot whose restore threw.

PrefillPlanner.usableRestore is now the one rule (ADR-0087 decision 7,
ADR-0094): the restore point or why it is unusable, for a residual that may
carry an Image Span or text only; all five callers read it. ResidualPrefill
does everything after it for the boundary route and the speculative pass:
restore through the Backing Leaf, reserve the path, prefill a range through
session.prefill from the seeded Position Anchor, match a request-local view.
Its failed-restore policy drops the snapshot through the manager in one
MainActor hop (where the keyed turn re-plans its checkpoints) and logs the
one unrestorable-snapshot line, for all three restorers.

Declared behaviour changes, each pinned by a test: a failed restore in the
boundary route or the speculative pass now drops the snapshot and logs
restore/unrestorable-snapshot before the producer's unchanged line; the
speculative pass prefills each chunk through the Model Session's prefill
verb, so an MLX failure throws (speculativePrefill/prefill-threw) instead of
ending the process and the pool is cleared between chunks. It enters as many
sessions as before. The e2e's diagnostics match the previous commit apart
from the speculative scheduling race.
Today's buttons wrote Reminders themselves (a tick on the Day Line, the
Now Card's Done, the Inbox's ring went straight to the Agenda), while a
card's Done and a cue's Did it went through the engine. The runtime,
whose header said it only does the I/O, held the rules for dates (the
owner's tomorrow, a time of day kept on a move), the engine held the
rules for series (Later or Let go on a repeating leftover skips to
tomorrow, Move to Today leaves a repeating reminder alone), and views
read the real clock.

Every owner action now comes in through runtime.act and is decided by
the engine: a tick on Today is CardAction.tick, as a card's Done is
itemDone. The change it asks of Reminders is an AgendaMutation, and the
Agenda makes it (Agenda.apply, Agenda+Changes.swift) with Reminders'
rules for dates and series in that one place; the runtime only performs.
Today's minute, the leftovers' Another Day, the composer's confirmation
and the Jarvis Panel's words read the runtime's clock. DayWalkTests walks
the day over the in-memory Agenda, which makes each change the engine
asks for, instead of a hand-written copy of it. The panel keeps what it
shows and the engine which cue it put there (so one taken down
unanswered is cued again); the panel's unused shownCardID is gone.

Behaviour is unchanged where the owner can reach it. The trace keeps its
labels (agenda.changed source "today" for a tick on Today). One trace
change in a path Today never offers: Move to Today on a repeating
reminder now records card.reaction "today" while the Agenda leaves the
series alone (the engine used to drop it silently).

Tests: the series rules moved from engine tables to the Agenda through
the in-memory store (TodayRedatingTests:
aRepeatingLeftoverSkipsToTomorrowInsteadOfLosingItsSeries,
moveToTodayLeavesARepeatingReminderAlone), and a tick on Today is pinned
end to end (aTickOnTodayGoesThroughTheEngineToReminders).
The Cache Claim's typed check-out outcome was flattened at plan
application into a `restoreMode` string plus four loose values (the
copy, the wait, the handoff, a fallback flag), and each report projected
them again its own way: the `restored` memory phase, the `lookup` event,
the generation value, the Leaf Store's context, and the `leafStore`
report the drive patched afterwards. The copies had already drifted: the
stored leaf's source string-matched "failedCopy" as a copy, and the
trace re-derives the restored offset from the lookup reason.

Plan application now yields one `TurnRestore` (cold, handoff, copy, or
failed then cold), and every report reads it: its `mode` is the wire
string, its `memoryFacts` the restored phase's facts, and the lookup
line, the `leafStore` fields, the trace's emitted-path fields and the
reserve's leaf source all take it in place of the string. No line
changes here; the two drifted projections are fixed in the next two
commits, each declared on its own. `TurnRestoreTests` tables the
projections without running a completion.

The per-turn report grown from the trace accumulator, the claim
reporting rewinds into it, the unkeyed trivial report and one home for
the handoff scope are left undone and recorded in ADR-0069's amendment.
Since a planned restore that yields no cache runs the turn cold
(ADR-0069's 2026-10-03 amendment), its leaf is no copy of a body the
tree already counts. The stored-source rule still string-matched
`failedCopy` as `copy`, so the `leafStore` event, the
`recordingRequest` phase and the trace said `copy`, and the
Active-Inference Reserve did not price the next lane for a deep-copied
leaf (capturesByCopy=false on an image turn that copied its live cache).

Declared one-row change: on a `failedCopy` turn whose leaf is stored on
the live or direct path, the source now reads `handoff` when the capture
moved the generation's objects and `live` when it deep-copied them, as a
cold turn's does. Nothing else changes; `copyReason` and `copyRefusal`
keep the claim's answer. Pinned by
TurnRestoreTests.aFailedRestoresLeafReadsLikeAColdTurns and by the
fallback suite's text (handoff) and image (live) turns.
The trace record derived the restored offset from the lookup reason
alone, so a hit the turn never restored recorded the hit's offset beside
`hitTokens=0`: a turn whose planned restore fell back cold, and a hit
the plan ran cold because the usable-restore rule refused it (one
covering the whole prompt). Those records also carried a rewind block
and counted as hits in the cache corpus.

Declared one-row change: the accumulator's rule now reads the Turn
Restore, so the restored offset is the hit's offset on a handoff or a
copy and 0 when the generation began from an empty cache. Only the
Completion Trace Log changes, on those two kinds of record; no
diagnostics line does. Pinned by
CompletionTraceAccumulatorTests.restoredOffsetIsWhatTheTurnRestored
and aFallbackTurnsRecordRestoredNothing.
Reminders keeps a repeating reminder as one item for its series. Ticked
off, the occurrence stays behind as a done copy with an id of its own,
and the series moves on under its id. The Agenda passed that straight
through. So the engine matched the copy by title and moved what the day
held of the task to the copy's id (followDoneOccurrences,
DayState.follow), Tomorrow took today's star off the series by hand, and
taking a tick back reached the wrong reminder.

The day now holds a repeating task by its series' id, and the Agenda
answers for its occurrence that day. Agenda+Occurrences holds one rule
that matches a copy to its series, the day's occurrence (open, or done
and when), and taking an occurrence back. The must-do, a step's run, the
Day Line's row and Today's Chat ask it. Taking an occurrence back deletes
the copy first, then puts the series back on its date, so a failure on
the way leaves at most one open reminder. The in-memory store also names
an event by its start, as EventKit does, so a moved event gets a new id
in the tests too. EventKit's behaviour was read from Apple's guide and
headers (ADR-0092). The copy is undocumented, so it is matched by what
it is.

Behaviour changes, each pinned by a test:
- The Undo of a tick on a repeating task leaves Reminders as it was: the
  series back on the occurrence's date, the copy gone, one open
  reminder. Before, it reopened the series, which was already open, and
  the occurrence stayed done
  (takenBackTheOccurrenceLeavesRemindersAsItWas, "undo").
- Unticking a repeating task done today, by the task (a cue's Undo,
  Today's ring) or by its copy, does the same. Before, the series was
  left as it was, or the copy was reopened beside it, leaving two open
  reminders ("the task", "the copy").
- So a must-do taken back is no longer done
  (aMustDoTakenBackIsNotDoneAfterAll).
- Today's Chat's Morning Plan shows a repeating task ticked off as done.
  Before, it showed the series open, and its ring ticked off tomorrow's
  occurrence (theMorningPlanShowsTodaysOccurrenceDone).
- In the in-memory store, a moved event gets a new id, as on the Mac
  (AgendaToolsTests). Only the tests see this.

What the 2026-10-10 amendment promised still holds, now pinned through
the Agenda over the in-memory store as well as from snapshots
(DayEngineRepeatingTaskTests).

Docs: ADR-0092; ADR-0080 amended (the 2026-10-10 mechanism superseded);
CONTEXT.md (Occurrence; Agenda, Must-do); ARCHITECTURE.md;
docs/testing.md.
A step's marks belong to its slot (ADR-0091). So when a new plan moves a
step while its cue is still on the panel, the owner's answer to that cue
is traced without secondsToReact. The step value's commit (ab85996)
listed this change, but no test held it.
…ew follow-ups

A two-axis review of the branch found one bug and two behaviour changes
the spec didn't allow:

- A session's start that failed after advancing its epoch left the
  phase at the previous take's .transcribing: that take now returns
  superseded and never reset it, so the Models section read
  "Transcribing" until the surface's next take. The failed start now
  sets the session idle (VoiceCaptureSessionTests).
- Clear Disk Cache had come to offload every model through Model
  Residency. It frees the voice and the language model again, as it
  always has; the voice's part of Offload is one helper, guarded as the
  arbiter guarded it (a voice still loading finishes).
- The Hotkeys pane had come to offer "Reset to <default>" on every row.
  A row says whether Settings offers its reset, and the same three do
  (HotkeyTableTests).

Follow-ups: ModelResidencyTests check the app's real holders list every
model; LaunchModeTests pin the kept precedence of the rotated-checkpoint
parity harness after --benchmark; the holders' state cascades share one
ModelActivityRow.State.of; View.holdToTalk moves to its own file;
App Bindings' input is isLLMLoaded (no slots left); a Commit is called
words, not take, beside onTake; the duplicate test-host check in
TelemetryEnvironmentTests goes (LaunchModeTests owns it). Docs: the
scratch launch no longer marks onboarding done (docs/testing.md), the
usable directory's reach is stated exactly (Whisper keeps its contract,
the voice engine its catalog spec), ADR-0096's As built names the
residency files, ADR-0095 names the harnesses that win over
--benchmark, and the Operation Guard is the session's.
No caller has read it since the Agenda landed in #601; the tools speak
AgendaTime.clock and the day forms.
Plan application held the manager's order by comment: plan checkpoints
only after Snapshot Resolution and only with the hit's alignment
(`alignTo: resolved.alignmentLookup`), then, after a failed restore,
drop the snapshot and plan again in the same hop through a generic
closure. About ten members were non-private only so tests could call
them, and `LookupResult.restoreCache()` was a dead duplicate of the
session's restore verb.

A keyed turn's `resolve(planningFrom:for:)` now resolves and plans in
its own hop and returns a `PlannedResolution`; dropping the turn's
unrestorable snapshot returns the re-plan from the same hop, so the
failed-restore policy loses its generic follow-up. Checkpoint planning
and the manager's own steps (promote, restored refs, chain-prefix
bodies, the alignment rule, eviction, pressure, the Budget Floor) are
private, and the suites drive the ones they need through `testSeam()`,
listed in scripts/test-only-code.allow. The dead restore is deleted.
Same hops, same order, no diagnostics line changes (ADR-0087's
amendment records what stays with its one caller).
…commit

The gates ran on c25f74a against main (fa438b3), as ADR-0078's As built
does: the 4B and 27B prefix-cache e2e (bf16 and turbo8v4) with their
diagnostics diffed under ADR-0093's masking, hybrid-cache-correctness,
the Completion Trace Log records of each run, and the DFlash2 bench's
full-stream run from each commit's build. A fresh main run settles that
the Speculative Canonical Prefill's scheduling race follows the
machine's load, not the change. ADR-0094 holds the record; ADR-0069's
and ADR-0087's amendments and ADR-0093 point at it.
…ates stop exiting 141

dev.sh's find_app and bench.sh's resolve_app read BUILT_PRODUCTS_DIR with
an awk that exits at the first match. xcodebuild, still writing, then dies
of SIGPIPE, and set -o pipefail made that the script's exit: the
loaded-model gates (scripts/dev.sh prefix-cache-e2e, hybrid-cache-
correctness) failed with 141 depending on timing. Both scripts also took
"the" DerivedData as the first tesseract-* folder in a glob, so with
several worktrees a stale-module clean could empty another checkout's
precompiled modules, and the configuration stamp landed in another
checkout's folder.

scripts/lib/checkout.sh holds the two answers once: checkout_derived_data
(the folder whose info.plist names this project, test.sh's rule, which it
now sources) and built_products_dir (awk reads xcodebuild's whole output).
test.sh, dev.sh and bench.sh source it.

Behaviour change: scripts/dev.sh clean removes this checkout's DerivedData
only; it removed every checkout's, other worktrees' builds included.
ADR-0064 decision 7 scopes the handoff to text-only key spaces over
unquantized KV, off the MTP path, and three places spelled it: the
check-out's image and quantized rungs, the Leaf Admission's move of a
finished turn, and the Leaf Store's copy reason. A fourth reader would
have spelled it again.

`CacheClaim.HandoffExclusion.of(textOnly:kvBits:arm:)` is now the rule:
the first reason a turn is outside the scope, or nil when its cache may
move. The check-out asks it without an arm (MTP engages only on a turn
that restores nothing), the admission with the decoding arm, and the
Leaf Store reads its copy reason. No line changes; an MTP turn's copied
leaf still reports no copy reason, which the next commit declares.
The handoff scope leaves the MTP path out (ADR-0064 decision 7), so an
MTP-decoded turn's live leaf is copied, yet the Leaf Store gave no
reason for the copy: the one copied leaf with no label. With the scope
as one rule, the Leaf Store reads it with the turn's decoding arm and
reports the rule's reason.

Declared one-row change: on a turn the MTP drafter decoded, the
`leafStore` event gains `copyReason=mtp`, the `recordingRequest`
phase's `leafCopyReason` reads `mtp` where it read `none`, and the
trace's emitted-path `copyReason` reads `mtp`. No other turn changes.
Pinned by the MTP row of CacheClaimTests.theHandoffScopeIsOneRule; no
toy session engages MTP end to end, so the pin is the rule's.
… leafStore

The Turn Restore was one value, but each output took it from wherever it
happened to be (the generation value, the Leaf Admission's context, the
leafStore report the drive patched), the trace tally was threaded from
the drive into the Leaf Store phase, and the one-leafStore rule was a
flag the drive had to set on the claim before the phase (#589), because
the claim built a leafStore report of its own for a rewind.

Each request's Cache Claim now keeps a Turn Report. Plan application
files the Turn Restore in it once; the reserve's source, the phase's
report, the recordingRequest phase and the trace record read it there.
It carries the trace tally, and it owns the turn's one leafStore event:
the phase begins it as its first step and logs its report through it,
and the claim reports each Leaf Rewind to it instead of building a
report, so the claim no longer knows the Leaf Store phase exists.

No line changes: every line is written at the step it was, through the
same request context. TurnReportTests drives the report's interface;
the cancelled-turn replay cases and the keyed sequencing suite check
it through real turns.
The Agenda found the copy to take back by what it looks like, and
matched it by the day it was done. Two things went wrong:

- An occurrence overdue from yesterday, ticked off late, moves a daily
  series on only to today, so the match failed. Unticking the copy then
  reopened it beside the series (two open reminders), the tick's Undo
  re-dated the series and left the copy done, and a cue's Undo did
  nothing.
- A one-off of a series' name in its list, such as "Call mom" today
  beside a weekly "Call mom", looked like a copy. Unticking it deleted
  it and moved the series, and the untick's Undo ticked off the series.

Deleting now goes by what the Agenda knows, not by what a reminder
looks like. Right after its own tick of a repeating reminder, the Agenda
reads what the tick left (the series moved on, and the copy done since
the tick and due on the occurrence's day) and keeps it in memory and in
the tick's Undo (TickedOccurrence). Taking that occurrence back deletes
the copy and puts the series back on its date, and only while both are
as the tick left them. Every other untick opens again only the reminder
it names, even if a copy is then open beside its series: data safety
before tidiness. What a copy looks like (isDoneOccurrence) now compares
dues: due before the series' next date. Reading whether today's
occurrence is done still goes by it, for ticks on the phone, and
changes nothing.

Behaviour changes, each pinned by a test (DayEngineRepeatingTaskTests):
- An overdue occurrence ticked off late is taken back like today's, by
  Undo, by unticking the task, or by unticking the copy
  (takenBackTheOccurrenceLeavesRemindersAsItWas, overdue: true).
- A one-off of a series' name is only ever opened again, and its
  untick's Undo ticks the one-off off again
  (aOneOffOfTheSeriesNameIsOnlyEverOpenedAgain).
- A copy ticked off elsewhere is only opened again, beside its series,
  as on main (aCopyTickedOffElsewhereIsOnlyOpenedAgain).
- A series moved since its tick is left where it is, and the copy is
  only opened again (anOccurrenceChangedSinceItsTickIsOnlyOpenedAgain).

Docs: ADR-0092 (decisions 2 and 3, the option dropped, the risks
left), the ADR-0080 amendment, CONTEXT.md Occurrence, ARCHITECTURE.md,
docs/testing.md.
…ts a passed chunk run

Two findings from review of ADR-0094's Residual Prefill.

The declared change "a chunk's MLX failure throws instead of ending the
process" held only for the closing flush: ResidualPrefill.prefill
hard-coded `.pipelined`, whose per-chunk asyncEval reaches MLX's
process-fatal handler outside `withError`. The eval policy is now the
caller's: the speculative pass evaluates each chunk `.checkedSynchronous`
(it forwards one chunk per call, so it gives up no pipelining) and the
boundary route keeps the `.pipelined` it had.

And the pass changed when a preemption lands: a cancel arriving while a
chunk waited for its session now threw at the prefill's own per-chunk
check, so the pass settled one chunk short where the old loop on
container.perform ran that chunk. The chunk now runs in a detached task,
the claim's conclusion's way, so the loop's check is again the one
cancellation point.

Tests: one checked prefill verb per chunk (which is also what returns
the pool between chunks), a chunk that throws ends the pass with nothing
admitted, and a preemption while a chunk waits for its session still
runs that chunk (a mutation without the shield fails it). The toy
records each prefill's eval policy and counts session requests.
Three assertions passed on wrong output (REVIEW.md). The Jarvis Panel's
"nothing to start" checks used allSatisfy, which is true of an empty
list. The Now Card's free-slot check compared through map(...) != false,
which is true of nil. They now pin the steps listed, in order, with no
task to start (an unplanned day, a plan already started), and the first
free half hour after the way to the 1:1: 13:45.
Today's Start now started a step before cutting it clear of the next
meeting, while a cue's Start cut it first and then started it. A step
started from Today just before a meeting was therefore timed against
the minutes offered, and the plan's pace read the meeting's cut as the
owner being quick (60 planned, 25 taken). Both now go through one
operation (DayEngine.startNow): cut clear of the next meeting, then
started. Step.started(at:timed:) is the engine's one word for a start,
not only the fixtures'.

AgendaMutation's tick repeated complete and reopen apart from the
trace's source. The three are now one case, setDone(reminderID:done:
onToday:). Its source stays "today" for a tick on Today and "card"
otherwise.

Declared and pinned what 01 and 03 changed without saying:
- The Now Card leads with a step the owner started over a meeting it
  runs into, and over an earlier task, not started, whose slot holds now
  too. It showed the meeting, and otherwise offered the earlier task's
  Start now (aStartedStepLeadsTheNowCardOverAMeetingAndAnEarlierTask).
- Move to Today on a repeating reminder is traced as a card reaction,
  and the Agenda leaves the series alone
  (moveToTodayIsTracedForARepeatingReminderToo).

Behaviour change, pinned: a step started from Today before a meeting is
timed against the minutes the meeting leaves it
(startNowOnTodayBeforeAMeetingEndsBeforeIt).

Docs: ADR-0091 (starting as one operation, the two Now Card cases, the
timing), CONTEXT.md (Time to leave, the glossary's word for what the
code calls a standing departure).
…ed-restore policy

Review of 05-08 found two remaining duplications.

The Position Anchor seed was spelled three times (the Residual
Prefill's base and both of plan application's restore shapes), and the
keyed plan unpacked the usable restore's point into
`.restore(cacheOffset:anchorDelta:)`. The plan now carries the
`RestorePoint` whole, and the point's `initialState(seedsPositionAnchor:)`
is the one rule.

The failed-restore policy had two overloads repeating the
corrupt-layers check, the MainActor hop and the skip line, and its typed
failure carried a `drop` nobody read. One implementation now takes an
optional `PlannedResolution` (the keyed turn's re-plan), the manager's
drop has one public entry, and `Failure.unrestorable` carries only the
error.

No line changes; same hops and order.
…ther Day

The engine still worked on a step by hand. About ten
plan.firstIndex(where:) lookups were each followed by setting a field
(cuedAt, checkedInAt, putOff, cutMinutes, isSmall), and the arithmetic
of "15 more min" and of going on after a meeting lived in the engine. A
slot's end, start plus minutes, was written out about ten times.

The step now changes through operations on the value: cue(at:),
checkIn(at:), putOffOnce(), cut(to:), resume(at:minimum:) and
extend(by:from:late:). The engine reaches them through one accessor,
DayState.changeStep(_:_:). A slot's end is one sum (Placement.end, and
TimelineTask.end for a row with a time), used by Step, StepCue, the
Timeline and the Now Card.

A leftover's Another Day was a menu written twice: once on the Now
Card, and once in Today's Chat, which also worked out the owner's day
from the clock in its view. It is now one view (AnotherDayMenu), and its
days come from the day model (DayFacts.anotherDays: the day after
tomorrow to a week on, from the owner's today), pinned by
anotherDayIsTheDayAfterTomorrowToAWeekOn.

Behaviour unchanged. Docs: ADR-0091 (the step's operations and its one
end).
…off facts

TurnRestore.Handoff re-declared three of CacheClaim.Handoff's fields,
and plan application copied them across field by field. The claim's
handoff now carries the cache objects beside one `TurnRestore.Handoff`
(the lease, the rewind backup's bytes, the wait), which plan
application files as the turn's restore unchanged. No line changes.
The engine decided a repeating reminder's Later by reading the repeat
flag itself, while the Agenda held every other series rule, and
Agenda.apply(.dueAt) had no series guard. A Later that reached the
Agenda for a repeating reminder (a stale snapshot) would have given the
whole series a new time.

The rule now lives with the Agenda's date and series rules
(Agenda+Changes):
- AgendaSnapshot.isSeries says a reminder is one item for its whole
  series. The engine asks it where it would give a one-off a new time,
  and places a step in today's plan instead, because the plan is the
  engine's to change.
- Agenda.apply(.dueAt) never re-times a series
  (aRepeatingReminderIsNeverGivenANewTime).
- dueOn's rule is now written down: a repeating reminder moved to
  another day skips to it at its time, as to tomorrow.

Behaviour unchanged where the owner can reach it. Docs: the ADR-0080
one-door amendment, CONTEXT.md Agenda.
The claim's rewind report was built from the lease alone and made up a
Pending-Payload Wait of zero. The claim now keeps the handoff it
reported at check-out with the leased leaf and reports it to the Turn
Report, which renders the turn's real handoff.

Declared one-row change: on a turn that ends before the Leaf Store
phase after a wait that ended in a handoff, the `leafStore path=rewind`
event gains `copyWaitMs`, as the turn's `lookup` line already carries.
No other line changes. Pinned by
TurnReportTests.aRewindAfterAWaitedHandoffReportsTheWait.
…cription

VoiceCaptureSession.cancel() told the shared recognizer to cancel
whenever the session had a task in flight, which includes while its own
take was proofreading or committing. TranscriptionEngine cancels the one
transcription it is running, whichever session started it: the capture
panel's take proofreading, the owner dictating, releasing and pressing
Escape lost dictation's words. The race predates the Voice Capture
module, against its own rule ("a panel closing must not cut off another
surface's take"). The session now counts its own calls into the
recognizer and cancels it only while one of them runs.

Also, HotkeyTable.stop() was app code only its tests called. The
followers hold the table weakly and stop in its isolated deinit, so the
tests need no stop and it is gone.

Tests: VoiceCaptureSessionTests pin a cancel while proofreading leaving
another session's transcription to commit, and a cancel while
transcribing still cancelling its own.
Docs: ADR-0096 (Consequences), docs/testing.md.
…ywhere

"The usable directory for a model" was still spelled by hand in the
server's integration snapshot (which also ran its own vision probe
instead of the memoized isVisionCapable), the Server Configuration's
preserve-thinking scan, the phone's voice (modelPath(for:)!), the
benchmark runner, and the voice engine, which rebuilt modelPath's folder
rule because its @sendable closure can't call the main-actor download
manager.

The folder rule is now one pure, nonisolated function,
ModelDefinition.directory(forRepo:pathPrefix:in:), with the entry's own
directory(in:) on top. The download manager's modelPath, usableDirectory,
status checks, downloads and deletes apply it, through directory(for:);
the voice engine's off-main closure applies it to the spec it shares with
its entry; the benchmark runner and the phone's voice read the same
rule (the phone through its download manager's directory(for:), no force
unwrap). The integration snapshot asks downloads.isVisionCapable, and the
preserve-thinking scan asks usableDirectory(for:). cacheSubdirectory is
gone. Whisper's dictation load keeps WhisperModelContract: it is stricter
than its entry's completeness, and the shared catalog can't see it.

No behaviour change: every folder is the same path. The integration
snapshot's vision answer is now the memoized one (a known answer is kept
for the id), as everywhere else.

Tests: IntegrationSnapshotBuilderTests check the snapshot carries the
catalog's vision verdict (the probe stays pinned in ModelCatalogTests;
the directory-probing cases go); the catalog tests read the folder rule.
The iOS target builds (xcodebuild -scheme tesseract-ios, Debug,
generic/platform=iOS).

Docs: CONTEXT (Usable Directory), ARCHITECTURE, the ADR-0081 amendment.
…all start

Review found a Refused Bequest in the Decode Start: a `.prefillStrategy`
start refused `cacheOwner`, refused a token recorder and required an
empty cache, three runtime traps for a type distinction, and the Raw
Generation Start kept a dummy `var cache = []` for it. The route keeps
no array of the app's, so it now starts through
`PrefillStrategyDecodeStart`, whose interface is only the build and the
loop: misuse cannot compile. The Decode Start keeps the three handovers
whose cache the app holds, and its three app-loop starts fold into one
generic call badged with the start's arm.

Tests: the table's Prefill Strategy rows run through the new start, and
both speculative kinds over turbo8v4 now pin the read-back of the
iterator's converted array. No line changes.
…; names match the glossary

Review's small items: `cumulativeCountersResetForTesting` and
`setSnapshotHydratingForTesting` were still manager members beside the
`testSeam()` the other test-only steps moved behind, so they are seam
members now (listed in scripts/test-only-code.allow). Three names drifted
from CONTEXT's _Avoid_ list ("the restore guard", "restore outcome", a
failure called `restored`), and `applyMemoryPressure`'s doc still said
tests could call it as-is. No behaviour changes.
The Unkeyed Completion's generation value carried a stand-in
`lookupReason: .missNoEntries` for a lookup it never made, so anything
that read it (the trace's restored-offset rule, two log lines) was told
a miss happened. Its lookup reason is now absent, and its Turn Report
stays trivial: no restore filed, no trace record, and the phase's
`unkeyed-completion` skip as its one leafStore.

No diagnostics line changes. Two Log.agent lines (not diagnostics
events) now print the request's cache reason where they printed the
lookup reason: `unkeyed(<reason>)` instead of the stand-in on an
unkeyed turn, the same text on a keyed one. The drive's remaining
keyed-or-unkeyed branches read Request Keying's outcome, not the
report, and stay; ADR-0069's amendment records why.
…s that say what they answer

The review found rules written twice and names that answered another
question:

- SpeechState.isSpeaking is the one "the voice is speaking" rule. The
  menu bar's glyph and the Models section's Voice row each switched over
  the same eight cases.
- ModelDefinition.displayName(of:) is the one "the catalog's name, or
  the id" fallback, for the Models section, dictation's history and
  correction pairs, the onboarding chapters, the agent composer and the
  benchmark runner.
- MenuBarManager.isModelLoaded is canOffload: since Model Residency it
  asks whether Offload Model would free anything, not whether a model is
  loaded.
- Whether a hotkey has a Settings section of its own is its row's
  (HotkeyRow.hasOwnSettingsSection), not a case the Hotkeys pane filters
  out by name.
- VoiceCaptureSession.commitRejectedTake returns the commit's Result. It
  can only commit or fail; the six-case Outcome it returned left its
  caller matching cases it never produced.

No behaviour changes.

Docs: CONTEXT.md (Hotkey Table).
…the take's own app

The review found four changes that landed without a declaration, one of
them without a test:

- ADR-0095: a test host or a scratch launch whose arguments carry a
  harness flag no longer runs the harness: the environment decides
  first (LaunchModeTests.theEnvironmentDecidesBeforeTheArguments).
  Before, the dispatch chain read only the arguments.
- ADR-0096: "Insert anyway" records the rejected take's own app. Before,
  it recorded the app the session's latest take started in, so a take
  begun and let go after the rejection moved it.
- ADR-0096: spoken words join the agent composer's text with the typed
  words trimmed at both ends, as the other fields already did
  (AgentVoiceInputControllerTests.spokenWordsJoinTypedWords).
- ADR-0081: "InferenceArbiter: unloaded TTS" is gone, and the offload's
  gate failure logs as "InferenceArbiter.offloadLLM: the LLM gate
  failed" (it said offloadAllModels).

Tests: DictationCoordinatorTests pin "Insert anyway" keeping the take's
own app after another take began in another app; the test fails with
the old choice.
Docs: docs/testing.md.
…rom the Turn Restore

The lookup event took the Turn Restore apart into five loose values at
its one call site, and the leaf's reported copy reason (the claim's on
a copy restore, else why the leaf itself was copied) was spelled three
times: the leafStore event, the recordingRequest phase and the trace
record. The lookup event now takes the Turn Restore itself, and the
leafStore report's `reportedCopyReason` and `recordingFacts` are the one
projection the other two read. No line changes.
… rule

DFlash2Support spelled the draft's folder, "incoai_Qwen3.8-27B-DFlash2",
the way the catalog's folder rule gives it, because the catalog is
MainActor-isolated and the support enum is not. The folder rule is
nonisolated now, so the support keeps only the draft's repo, which a test
pins to the catalog entry, and finds the folder through
ModelDefinition.directory(forRepo:pathPrefix:in:). The folder is the
same.

Tests: DFlash2SupportTests pin the repo to the catalog entry, and the
detection to the folder the owner's download is in.
Docs: CONTEXT.md (Usable Directory), ARCHITECTURE.md, the ADR-0081
amendment.
…and its Pending Row

`.agentEnd` lowers a run's busy flag one hop after the event, before the
run's body returns and its task releases the gate. A send in that window
sets the flag for a newer run; the older run's task then cleared it,
fired the settle (the Chat Session handed the newer, still queued message
back to the composer) and dropped the newer run's task handle, so a cancel
missed it. DayThreadPicturesTests.theOwnersTurnSeesThePictureAndTheNextTurnReadsTheLine
failed on that now and then under load: its second wait saw the flag the
first run lowered and read one generation instead of two.

Each run takes a number; only the newest run's task clears the flag,
settles and drops the handle. A failure still reports from either.

Pinned by AgentRunControllerTests.anOlderRunSettlingLeavesTheNewerRunItsFlagAndItsSettle,
which fails without the fix (the flag lowered, the settle fired).
The follow-up commits on top of a888a3e ran the 4B prefix-cache e2e
after every step that could move a line (eight runs, 0 of 1,959
in-order lines differing each time), and the 27B DFlash2 e2e and
hybrid-cache-correctness on the last code commit, against references
captured on a888a3e. ADR-0094's As built records them.
The 27B e2e under turbo8v4 on the last code commit matches main check
for check and acceptance line for acceptance line, with main's own
requestP3 failure on both sides.
@spokvulcan spokvulcan added the run-build Run the optional PR Release build and Debug tests once; reapply to run again label Oct 11, 2026
@spokvulcan
spokvulcan merged commit 6a58913 into main Oct 11, 2026
14 of 15 checks passed
@spokvulcan
spokvulcan deleted the refactor/deepen-modules branch October 11, 2026 12:56
spokvulcan added a commit that referenced this pull request Oct 11, 2026
…ds the build's index, Swift formats on write (#640)

## Summary

These are the seven environment fixes from the retrospective of #638's
overnight run, where three agents refactored in parallel on one Mac.

```diff
 proving a server refactor ("every diagnostics line stays", ADR-0078 d8)
-  scratch scripts per refactor; dev.sh gates kill every Tesseract Agent; captures are slices of the owner's diagnostics
+  scripts/gate.sh <run-folder> <flag> on the base and on the change, then scripts/gate-compare.py → SAME / DIFFERENT
 a member only tests call, whose name another member shares (stop, started)
-  invisible to the test-only scan; review caught it
+  the scan's index pass reports it as Type.member, from the build's index
 swift-format
-  check.sh / the commit stops on "files were modified by this hook", 3–6 times per agent
+  the file is formatted as it's written (PostToolUse hook)
 --bench-model qwen3.8-27b
-  missingDirectory(./qwen3.8-27b), a second into the run
+  "No model folder at …/qwen3.8-27b. --bench-model takes a model folder: pass --bench-model-id qwen3.8-27b for the catalog's."
```

```text
scripts/
├── gate.sh                   # one gate → its own folder: report + diagnostics + traces (TESSERACT_TELEMETRY_DIR)
├── gate-compare.py           # ADR-0078's masked comparison: checks, decode lines, diagnostics, traces; exit 0 = same
├── lib/app.sh                # a run (waited for) vs an opened app (asked to quit): nothing kills another checkout's run
├── check-test-only-code.py   # + the index pass (libIndexStore): Type.member, overrides and witnesses left out
├── hooks/format-swift.py     # PostToolUse: swift-format the Swift file just written
└── dev.sh                    # gates go through gate.sh; `run` quits only the open app; prune-worktrees [--yes]
```

Also in this PR:
- **REVIEW.md:** an undeclared behaviour change in a `refactor:` PR is
Important. The "assertions that pass on wrong output" rule now names
`allSatisfy` over a list that can be empty, and `!= false` on an
optional.
- **CLAUDE.md:** a pointer to the gate set, and the stuck-subagent rule
now names a signal you can observe: the subagent's worktree.

## Evidence

- **Whole target:** 3,705 tests, 3,698 passed, 0 failed, 7 skipped
(three dead members' tests went with them). `scripts/check.sh
origin/main` passes, index pass included.
- **`gate-compare.py` on last night's captures** (the 4B e2e, merged
build against the server track's build) prints `SAME`:
  - 38/38 checks on both sides;
  - check and decode lines identical;
  - 0 of 1,959 in-order diagnostics lines differ;
  - 6 scheduling lines print but don't fail.

With one check flipped and one `leafStore` field changed, it prints
`DIFFERENT` and exits 1.
- **Index pass:** about 3 s. It found 25 declarations the name pass
couldn't see:
- 18 test seams (e.g. three `flushForTesting`, `CaptureDumpStore.flush`,
the PrefixCacheManager `TestSeam.*`), now listed with reasons;
  - 7 that looked dead, each triaged in the last commit:
- `SSDEnduranceAccumulator.persistNow` is now called at termination. Its
doc said shutdown writes the ledger, and nothing did.
- `ImagePreviewFileCache.materialize`,
`VisionPrefixMemoryGuard.rejection` (the single-shot guard the windowed
continuation replaced), `PromptCacheEventDisplay.symbol` and
`MenuBarClockText.spoken` are deleted with their tests.
    - `EmittedPathIndex.lookup` stays as a test seam.
- `AgentSystemPromptInspector.fetchRawSystemPrompt` stays as planned:
#184 kept the inspector "for a future settings surface".

A protocol witness whose conformance sits elsewhere
(`SSDSnapshotStore.loadSyncPrefix`) is left to the name pass.
- **`prune-worktrees` dry run on this Mac:** lists 7 worktrees main
already holds, about 15 GB with their builds. It never lists the main
checkout; an early draft did, and the dry run caught it.
- **Not run end to end yet:** `gate.sh` on a real gate. The owner's app
was open, and gates now refuse to run beside an open app.

## Merge Danger

**Door:** two-way. This is scripts, docs and one small app change: the
telemetry seam honours `TESSERACT_TELEMETRY_DIR`.

**Blast Radius:** tooling. What changes for whoever uses it:
- `scripts/dev.sh run` quits the open app (any checkout's) instead of
every instance.
- `bench.sh` and `parity-ab.sh` wait for runs and stop at an open app
instead of killing it.
- `dev.sh`'s gate reports move to the benchmark directory's
`runs/<gate>-<time>/`.
- The commit hook's test-only scan takes about 2 s more when the build's
index is fresh.
- Every Claude Code session in this repo now formats Swift files on
write.
- Quitting the app writes the SSD endurance ledger once more, so the
last 5 s of counters aren't lost.

## Docs impact

- [ ] New or changed domain term → updated `CONTEXT.md`
- [ ] Structural change (new module, moved seam, renamed file) → updated
`ARCHITECTURE.md`
- [ ] Durable, surprising decision with a real trade-off → added an ADR
in `docs/adr/`
- [x] Test workflow change → updated `docs/testing.md` and
`docs/prefix-cache-testing.md` ("Prove a refactor")
- [x] Also `REVIEW.md` and `CLAUDE.md`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-build Run the optional PR Release build and Debug tests once; reapply to run again

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant