Skip to content

Architecture review: every bug fixed with a regression test, polish and clipboard robustness, and the structural work - #70

Merged
dinooo13 merged 80 commits into
mainfrom
t3code/pladder-architecture-review
Oct 15, 2026
Merged

dinooo13 merged 80 commits into
mainfrom
t3code/pladder-architecture-review

Conversation

@dinooo13

@dinooo13 dinooo13 commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

The bugs from the architecture review, a regression test for each, the polish and clipboard robustness work, and the structural changes, in one PR as agreed. 132 files, but most of it is tests (36 files, about +3,500 lines) and moved code. The commits are grouped by area, so it reads best commit by commit.

Bugs fixed (each with a test that fails without the fix)

Scripts and CI

  • bundle.sh exited silently on a Mac without a signing certificate (grep under pipefail). CI now runs that path on purpose: no CODESIGN_IDENTITY, and the runner has no certificate.
  • bundle.sh --install ran pkill -x Pladder, which killed every copy. It now stops only /Applications/Pladder.app, and a CI step fails on any script that kills every copy.
  • CI fails on warnings in Sources/ and selects Xcode 26 strictly. Its path filters use paths: with negations, so a workflow edit runs CI.

Settings

  • One unknown enum case, one bad dictionary row or a missing engineID moved the whole settings.json aside, leaving an empty dictionary.
    • Decoding now goes key by key on top of the defaults. A file that lost anything is copied aside first.
    • Backups get unique names, so a second broken load no longer deletes the first.
    • Saving keeps keys a newer build wrote, and launchAtLogin (written, never read) is retired.
    • Tests: SettingsTests.swift.

Clipboard and output

  • A second paste while a restore was pending put back the older clipboard over something the user had copied in between.
  • If every clipboard item was over 64 MB, the restore cleared the pasteboard.
  • A device with one channel muted had both channels unmuted afterwards. The mute is now restored per element.
  • An end that overtook its start left the speakers muted. OutputMuter calls now carry the recording's number.
  • Return now waits for the target app's read (capped at 400 ms) instead of a fixed 50 ms.
    • A Return still owed when the next paste starts goes out before that paste's Cmd+V.
    • A read before Cmd+V no longer counts as the paste.
  • PasteboardOutput is split into ClipboardKeeper, ClipboardSnapshot and KeyPoster, all tested on named pasteboards with a manual clock.

Quit

  • Quit and SIGTERM now wait (up to 3 s) for DictationCoordinator.shutdown(). It drops a recording, lets a dictation that is on its way finish, unmutes, and restores the clipboard. Before, a latched recording left the speakers muted.
  • The clipboard comes back only after the target app has read the transcript, at most 600 ms after Cmd+V. Restoring at once would have pasted the user's old clipboard.
  • SIGTERM is caught off the main thread and ends the process 5 s later regardless, so a hung copy can still be stopped with pkill.

Hotkeys

  • Nested chords whose shorter chord is the toggle key discarded or wrongly latched the recording. Releases now go before presses within one event.
  • Three recorder fields shared one "suspended" flag, so the hotkey came back while another field was still recording. HotkeyRecordingSession allows one recorder at a time.
  • A lost key-up of Space let a lone Option press start a dictation. A chord with a regular key now needs a fresh key-down of that key.
  • The tap and Carbon refcons are retained.

Processors

  • The filler remover swallowed a sentence's full stop ("I think so, um." → "I think so").
  • "em" is no longer a filler ("an em dash").
  • Custom words rewrote ordinary words: cloud → Claude, shift → Swift, rest → Rust. Now a short key allows one edit and a common-word list guards single words.
  • Spoken punctuation converted a mark used as a noun ("the Oxford comma debate").
  • A dictionary entry ending in punctuation (c++) did not match before a comma.

Polish

  • An answer cut off at the token limit was pasted as if complete.
  • LlamaModel.init leaked the model and context when tokenizing failed.
  • The 8 s budget left out the model's load (S1-mini). For Apple's model it applied per chunk, and the fallback got a fresh 8 s. It is now one budget per polish.
  • Unpunctuated long text was never chunked.
  • Errors from Apple's model were matched by description strings. On macOS 27 that check never matched, so the plain-text fallback never ran.
  • ModelFiles gained a size check, cancel (used when switching models), resume, and one shared download per file.

Coordinator

  • A chunk the feed was handing to the engine at release could arrive after endUtterance and was missing from the audio count.
  • A live pass could start after the release.
  • A cancel abandoned the utterance twice, the second time on the wrong engine.
  • A press right after a cancel could have its utterance abandoned.
  • A failed beginUtterance surfaced as "not loaded" at release. That recording is now transcribed whole.
  • The engine loader no longer lets a superseded load overwrite the status.

Also

  • AX AXManualAccessibility is set only for apps that show no focus or a web area.
  • Learning now skips copies and sends, and holds reviews while a dictation is in flight.
  • The overlay demo and screenshots no longer touch the live settings or add a second menu-bar item.

Third review

Nine findings, commits e163190 to d37fb06. Each bug fix has a regression test, checked to fail with its fix taken out unless noted.

  • Polish: a call that times out while draining never starts its own (e163190). drain() ignores cancellation, so a call whose deadline passed while it waited for the last abandoned call went on to run a full generation and was parked as the next leftover; under a slow model every polish timed out in turn. The race now checks for cancellation after the drain. Test: aCallThatTimesOutWhileDrainingNeverStartsItsWork.
  • Hotkeys: a grant flip hands over the monitor and its stand-in in one update (9f4d763). HotkeyRouter swapped the monitor with the old stand-in and then set the new one: Carbon got a stored Right Command, refused it, and was restarted with Option+Space, dropping the recording twice. One update(_:hotkeyOverride:monitor:) now; replaceHotkeyMonitor is gone and its tests call update. The router is in the app target, which has no tests; the coordinator's half is aGrantFlipStartsTheNewMonitorOnceWithTheNewStandIn.
  • Clipboard keeper: a restore moved by a later read cannot fire at its old instant (ee9ebb5). A restore task already woken and waiting to enter the keeper was past its cancellation check, so a read in that window did not stop it (Chromium's second read, issue Timing experiments: warm on wake, trailing silence pad, receipt-sequenced clipboard restore #40). Each scheduling has a number and only the latest restores. Test: aRestoreThatWokeBeforeALaterReadDoesNothing.
  • Settings store: a dictionary row without an id no longer hides that nothing changed (d0628a6). Such a row gets a fresh UUID at every decode, so the save never saw the dictionary as unchanged and dropped a row this build could not read. Rows are compared without id. Test: savingKeepsADamagedRowBesideRowsWithoutIDs.
  • App: copying the last transcript goes through the keeper (e9909ed). The menu's copy wrote NSPasteboard.general directly: no nspasteboard.org markers, and a pending restore could put the old clipboard back over it. It now calls PasteboardOutput.copy, the keeper's copy. Test: copyingTheLastTranscriptCarriesTheMarkersAndEndsThePendingRestore (on the new method; the old path was in the untested app target).
  • Polish model: a checksum whose read fails is a disk failure (7f19ed1). try? on the read ended the loop like end of file, so a partial hash was compared and the 1.5 GB download deleted as corrupt. A failed read now gives no digest, reported as .failed(.disk). Tests: aReadThatFailsGivesNoChecksum, aChecksumThatCannotReadTheFileFailsAsTheDisk.
  • Coordinator: the last transcript is the processed text even when the paste fails (8866671). It is set just before insert instead of after it, and the raw assignment before the processors is gone, so a dictation the processors empty leaves the previous one. Test: aFailedPasteLeavesTheProcessedTextAsTheLastTranscript.
  • Learning: a held review waits on the coordinator's state, not a 200 ms poll (c2957c0). ObservationLoop.wait(until:) resumes on the change itself. Tests, on the new helper: aWaitForAConditionThatHoldsReturnsAtOnce, aWaitResumesOnTheChangeThatMakesItHoldAndNotBefore, aCancelledWaitReturns.
  • One word count, in Core (d37fb06). The polish minimum and the polishers' chunks both use String.wordCount. No new test; the existing ones cover both callers.

swift test: 601 tests (590 before). swift build and swift build -c release have no warnings in Sources/.

Structure

  • Coordinator:
    • It takes DictationSettings, the part of Settings it acts on. UI clicks no longer reach it or rebuild the processors.
    • There is one endRecording() and one hotkeyConfigurationChanged().
    • The clock is injected. Tests use ManualClock and a fake keyboard clock instead of sleeps; one deliberate wall-clock sleep remains.
    • The input level is out of DictationState, so the state no longer changes 20 times a second.
  • App:
    • AppModel is the composition root and the settings owner.
    • HotkeyRouter, PermissionMonitor, PolishModelController, CorrectionInbox and SettingsLocation each take one job, and MainActorRelay replaces three relays.
    • Menu wording, processor names and hotkey warnings live in StatusText. The decisions behind them are pure, tested values in Core: HotkeyWarning, HotkeySource, the timing line.
  • Core: TextProcessor is synchronous and cannot fail, and carries no English text. Hotkey moved to Models/.
  • Engines: StandardEngines is the one catalog for the app and the CLI.
  • CLI: split by command, and bench-process is new.
  • Views:
    • The settings window is built from sections.
    • The overlay has one file per part.
    • PillMetrics holds the pill's font and geometry.
    • The duplicated helpers (Duration conversion, dictionary merge, desktop gradient, appearance mapping) are merged into one each.
  • Docs: CLAUDE.md's decision rows and pluggability rules are updated. Its claim of a SHA-256 check on the speech model is corrected: FluidAudio checks it by size only.

Benchmarks (M1, macOS 27.0, same machine, before = 3addeb6, back to back)

Processors (pladder-cli bench-process, 31 runs, median; the before build had this one file added):

Fixture Words Typical before Typical after Worst before Worst after
10s 36 2.27 ms 2.12 ms 1.57 ms 1.57 ms
30s 112 1.63 ms 1.65 ms 1.76 ms 1.79 ms
60s 215 2.08 ms 1.80 ms 2.28 ms 2.00 ms
2m 433 2.73 ms 2.13 ms 3.08 ms 2.57 ms
5m 1086 3.16 ms 3.12 ms 4.13 ms 4.21 ms
10m 2173 4.72 ms 4.80 ms 6.76 ms 6.91 ms

All within noise. The new rules (common-word guard, sentence-mark handling, article check) cost nothing measurable.

Paced engine (bench --paced --runs 2 --pause 2, 30 s / 60 s / 2 m fixtures; the engine's endUtterance changed):

Fixture endUtterance before endUtterance after Identical to whole buffer
30s 0.226 s 0.268 s yes / yes
60s 0.269 s 0.282 s yes / yes
2m 0.295 s 0.298 s yes / yes

Two kept runs is thin, and in that pass the untouched whole-buffer call on the 30 s fixture also moved 8 %. So I re-ran 30 s with six runs, alternating before and after twice:

Round Before After
1 0.265 s 0.270 s
2 0.269 s 0.274 s

That is +2 %, noise. The machine was in use, with load averages of 1.4 to 2.4.

The coordinator's part of the release path (the feed hand-over, endRecording, the synchronous processors) is not in the CLI benchmarks. It shows in the app's release-to-paste log line. The engine stage now includes the rare wait for a chunk the feed was handing over at release, which endUtterance would have waited for anyway. insert on a named pasteboard with the fake poster measured 197 µs median after the split.

Third review (before = 5b2afc3, after = d37fb06, release builds, run alternately). Two changes touch the code between recordingStopped and inserted: lastTranscript is set before insert, and the polish gate counts words with the shared String.wordCount. Neither is in the CLI benchmarks, which time the processors and the engine; they are a property write and the same split as before. The processors and the engine are unchanged, so these runs check that nothing else moved.

pladder-cli bench-process bench/fixtures --runs 31, typical / worst, median:

Fixture Before, round 1 After, round 1 Before, round 2 After, round 2
10s 1.91 / 1.57 ms 1.94 / 1.61 ms 1.57 / 1.60 ms 1.54 / 1.54 ms
30s 1.63 / 1.76 ms 1.68 / 1.80 ms 1.70 / 1.99 ms 1.62 / 1.72 ms
60s 1.79 / 2.02 ms 1.83 / 2.08 ms 2.00 / 2.04 ms 1.78 / 2.01 ms
2m 2.16 / 2.64 ms 2.56 / 2.66 ms 2.15 / 2.62 ms 2.19 / 2.63 ms
5m 3.21 / 4.30 ms 3.24 / 4.87 ms 3.39 / 4.30 ms 3.28 / 4.40 ms
10m 4.99 / 7.45 ms 5.23 / 7.04 ms 4.80 / 6.97 ms 4.84 / 7.03 ms

The processors are untouched by these commits. The largest gaps in round 1 (2m typical +0.40 ms, 5m worst +0.57 ms) are 0.04 and 0.10 ms in round 2: noise. Load average 3.9 (one minute) at the start.

pladder-cli bench <30s and 60s> --paced --runs 2 --pause 2, endUtterance, one kept run each:

Fixture Before, round 1 After, round 1 Before, round 2 After, round 2 Identical
30s 0.744 s 0.723 s 0.733 s 0.705 s yes in every run
60s 0.716 s 0.713 s 0.714 s 0.715 s yes

Within noise; word error rates unchanged (0.0, 0.9 %). Load average 1.7 to 2.9. These are about 0.45 s slower than the earlier tables for both builds: the Neural Engine was in a bad state on this machine that day. The first paced run of each build failed to compile the encoder for the Neural Engine (ANECCompile() FAILED (11)) and was repeated, and cold model loads took 33 to 150 s. Before and after were measured under the same conditions, back to back, but the absolute numbers are not comparable to the baseline. The app's release-to-paste line is still the only measure of the coordinator's own part.

Simplify pass

After review, a second pass looked the other way: smaller and simpler without losing a fix. It is the 20 commits from 2211146 to fc6a081.

Before the pass After
Source code lines 10,219 10,007
Test code lines 6,424 5,843
Tests 574 558

The 16 fewer tests were near-duplicates, merged or parametrised. Every fixed bug keeps a test that fails without its fix.

  • Tests: a shared PladderTestSupport target holds one ManualClock, Recorder, Gate and eventually instead of two clocks and six recorders. The coordinator tests got shared helpers.
  • Coordinator: the feed task returns the samples it handed over, so cycleStreams and fedSampleCount are gone. The copied hint and the error share one reset timer.
  • Paste: the restore timings nobody overrode are constants, the always-zero propagation delay is gone, and the owed Return sits in one slot.
  • Polish: one race helper and one log-line builder.
  • Settings: the encoder is synthesised again, and dead helpers are removed.
  • System: the tap and the AX observer share one run-loop thread.

The processor benchmark after the pass, against main, two rounds each: every difference is under 0.35 ms, which is noise. The machine was busy (load around 7). Two costs on the release path cannot be reached by the CLI benchmarks: the clipboard check started at release, and the hop PasteboardOutput gained by no longer being an actor. They show in the app's release-to-paste log line.

A comment pass follows in its own PR stacked on this one. It changes no code and moves the design explanations into docs/ARCHITECTURE.md.

Not done

  • A pure, tested rule now prevents the dictation from a lost key-up. The CGEventSource.keyState check against the real keyboard state was left out.
  • Resume data for a polish model download lives in memory only, so it does not survive a relaunch.
  • In Chromium, the early read starts the Return's wait too, so the Return there is as early as the old fixed delay.
  • The app was not run against real dictation here (CLAUDE.md: no synthetic key events while a copy is in use). overlay-demo.sh was run, and every path plays as before.

Test

swift test: 558 tests, about 0.7 s (557 on CI). swift build has no warnings in Sources/.

Review fixes

Ten findings from the review, one commit each (the coordinator's two share one), each with a regression test (the processor, keeper, S1-mini and settings tests were checked to fail on the code before the fix; the others exercise the new coordinator API or the seam each fix added): spoken "ein" before a mark, the short-key Soundex cap keyed on the term, a failed Cmd+V that cannot restore, an S1-mini load after unload(), settings backups and unreadable values on save, a chord and its stand-in in one monitor restart, a press right after a monitor swap, the polish file's cancel and ensure order (OrderedTasks), the recorder session keyed on tokens (HotkeyRecordingSlot, now in Core), and the overlay's observation chains (ObservationLoop, now in PladderSystem).

Benchmark (M1, macOS 27.0, before = 4adb246, after = 51b16d7)

pladder-cli bench bench/fixtures, engine time, median of five:

Fixture Before After Before spread After spread
10s 0.214 s 0.259 s 23 % 5 %
30s 0.428 s 0.441 s 3 % 6 %
60s 0.591 s 0.635 s 28 % 12 %
2m 0.929 s 0.950 s 6 % 3 %
5m 2.065 s 2.089 s 5 % 5 %
10m 3.753 s 3.741 s 2 % 18 %

Word error rates are unchanged (0.0, 0.0, 0.9, 0.7, 0.6, 0.6 %). Load average 1.9 to 2.8 before, 2.3 to 3.4 after. No engine, audio or CLI code changed. The 10s row is over the ten percent line, so it was run again with --runs 11 on both commits back to back: 4adb246 0.259 s (spread 4 %), 51b16d7 0.257 s (spread 10 %). The machine had shifted, not the code.

pladder-cli bench-process bench/fixtures, which is where the processor changes would show:

Fixture Typical before Typical after Worst before Worst after
10s 1.52 ms 1.55 ms 1.55 ms 1.74 ms
30s 1.64 ms 1.62 ms 1.76 ms 1.73 ms
60s 1.77 ms 1.76 ms 1.99 ms 2.00 ms
2m 2.12 ms 2.10 ms 2.54 ms 2.53 ms
5m 3.09 ms 3.10 ms 4.16 ms 4.20 ms
10m 4.74 ms 4.75 ms 6.82 ms 6.86 ms

Every difference is under 0.2 ms, which is noise. The clipboard keeper's change is on the failure path only.

Second review fixes

Nine findings from the second review, commits ba0f557 to 5b2afc3. Each bug fix has a regression test, and each test was checked to fail with its fix taken out.

  • Engine: an utterance is a handle (ba0f557). beginUtterance returns an Utterance; feed, live pass, end and abandon name it, and a stale one does nothing. Before, an abandon dropped whichever utterance was current, so a cancelled recording's late abandon could drop the next recording's, which then failed as "not loaded"; two overlapping begins left one session never cancelled. The bookkeeping is UtteranceSlot in Core, tested on its own (UtteranceSlotTests) and used by the fake engine; the coordinator test is aLateAbandonOfACancelledUtteranceLeavesTheNextOne. The paced bench uses the handle. CLAUDE.md's "Adding an engine" rule names it.
  • Coordinator: a cancel's cleanup waits for the cancel before it (1c75108), as the mute restores already did. Test: aPressWaitsForEveryEarlierCancel.
  • Coordinator: a press cancelled while it waits for a cleanup leaves the microphone alone (506de91). Test: aPressCancelledWhileItWaitsLeavesTheMicrophoneAlone.
  • Coordinator: one update, no restart sentinel (2faf220). update(_:hotkeyOverride:monitor:) works out the restart once; settings, hotkeyOverride and replaceHotkeyMonitor go through it. Test: aCombinedUpdateRestartsTheMonitorOnce.
  • Hotkeys: a recorded chord gets its monitor at once (7a6d0f6). HotkeyRouter.hotkeyChanged runs HotkeySource.choose again with the last secure-input reading and returns the monitor to switch to; the app passes it to update with the chord and the stand-in, so it is still one restart. Re-running the router's flip directly would have restarted twice, the first time with the old chord on the new monitor, which is the Carbon failure update exists to avoid. The router lives in the app target, which has no tests; the coordinator half is tested by aChordAndTheMonitorItNeedsChangeInOneRestart.
  • Coordinator: only a dictionary change rebuilds the processors (e2cdf4a). dictionary is the only DictationSettings field StandardProcessors builds from; disabledProcessors is read per run. Test: onlyADictionaryChangeRebuildsTheProcessors.
  • Polish model: a cancel reaches the checksum (c7fc8b4). The hash checks for cancellation between 16 MB chunks and is cancelled with the download, so cancel no longer waits out up to 1.5 GB of hashing; the staging file is still removed. Tests: cancelReachesTheChecksum, theChecksumStopsBetweenChunksWhenCancelled.
  • One firstOf, in Core (5b2afc3). The app delegate's second implementation is gone; FirstOfTests covers the helper.
  • The release-path measurement below.

swift test: 590 tests by swift-testing's count across the five bundles (576 at c536743 by the same count). swift build and swift build -c release have no warnings in Sources/.

Benchmark: the engine's endUtterance (M1, macOS 27.0, before = c536743, after = 5b2afc3)

The handle changes endUtterance, so the paced bench, on the 30 s, 60 s and 2 m fixtures, alternating before and after:

pladder-cli bench <dir with 30s, 60s, 2m> --paced --runs 3 --pause 2
Fixture Before, round 1 After, round 1 Before, round 2 After, round 2 Identical
30s 0.268 s 0.276 s 0.274 s 0.281 s yes in every run
60s 0.298 s 0.294 s 0.283 s 0.296 s yes
2m 0.306 s 0.262 s 0.301 s 0.284 s yes

Medians of two kept runs. Every difference is within ten percent, which is noise; word error rates are unchanged (0.0, 0.9, 0.7 %). Load average 1.5 to 2.8.

Release path: insert and the release-time prepare (before = 3addeb6, after = PR head)

The earlier note said these two costs could not be reached by the CLI benchmarks, and gave one after-only number. Measured now with the same harness on both builds: a temporary release-mode test (swift test -c release -Xswiftc -enable-testing, not committed) that calls prepare() at "key-down", then the release-time prepare() (after only), then insert from the main actor, as the coordinator does, on a private named pasteboard with a key poster that posts nothing; a read of the transcript and 700 ms for the restore between iterations. 65 iterations, the first five dropped. On main, PasteboardOutput hard-wires NSPasteboard.general and the real CGEvent post, so its before build had exactly those two made injectable and nothing else changed. PasteboardOutput, ClipboardKeeper and KeyPoster are unchanged between c536743 and this head.

main (actor) PR head (ClipboardKeeper hop)
insert, median, round 1 1.403 ms 1.442 ms
insert, median, round 2 1.427 ms 1.307 ms
insert, p90, rounds 1 and 2 1.623, 1.651 ms 1.704, 1.592 ms
release-time prepare(), median none 0.153, 0.148 ms

The extra hop is within noise. The release-time prepare() takes about 0.15 ms and runs beside the engine's pass, not before insert. Load average 1.9 to 5.4 during these runs (the first round started right after a build).

Still outstanding: what this costs inside the running app, against real dictation, with Pladder's main thread serving the pasteboard promise, cannot be measured here without a live UI test. It shows in the app's own release-to-paste line:

/usr/bin/log show --last 1h --style compact --predicate 'subsystem == "de.dinooo13.pladder"'

That comparison, main against this branch, is still to be done.

🤖 Generated with Claude Code

dinooo13 and others added 29 commits October 9, 2026 19:30
Six copies of the attoseconds arithmetic become Duration.timeInterval. The
Dictionary tab's import, an accepted learned correction and the learner's
known-rule check each normalised dictionary keys their own way; they now
share DictionaryEntry.mergeKey, hasRule(for:) and merge(_:), with tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ttings type, an app split by job

Coordinator
- Takes DictationSettings, the part of Settings it acts on; the app hands
  it a new value only when that part changed, so UI clicks never rebuild
  the processors.
- One endRecording() for every way a recording ends and one
  hotkeyConfigurationChanged() for every way the chord set changes.
- Every wait goes through an injected clock.
- The release waits for the feed loop to hand over a chunk it drained, so
  every sample reaches the engine once and the fed count is exact; no live
  pass starts after the release.
- A cancel abandons the recording's own engine once, after the feed, and a
  press on its heels waits for that, so it cannot abandon the new utterance.
- A streaming engine that cannot begin an utterance is transcribed whole at
  release instead of failing as not loaded.
- Recordings are numbered; the output muter gets the number with each call.
- inserted carries whether the text was pasted or copied and whether Return
  followed; shutdown() gives back the speakers and the clipboard at quit.

App
- AppModel is the composition root and settings owner; HotkeyRouter,
  PermissionMonitor, PolishModelController, CorrectionInbox and
  SettingsLocation each take one job; one generic MainActorRelay; menu
  wording and processor names move to StatusText.
- Quit and SIGTERM wait (3 s at most) for the coordinator's shutdown.
- The overlay demo and screenshots use a scratch settings file and no menu
  bar item.
- Learning skips copies and sends.

Settings
- Decoded one key at a time on top of the defaults: an unknown case, a
  damaged dictionary row or a missing engine no longer moves the whole file
  aside. A file that lost something is copied aside first; backups get
  unique names. Saving keeps keys a newer build wrote; launchAtLogin,
  never read, is retired.

Scripts and CI
- bundle.sh no longer exits silently without a certificate, and --install
  stops only the installed copy. CI runs that path, fails on warnings in
  Sources, selects Xcode 26 strictly, and checks scripts never kill every
  Pladder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eanup

Custom words: the Soundex bonus lifted the bar to a normalised distance
of 0.6, so with "Claude", "Swift" and "Rust" listed, "cloud", "shift",
"rest" and "roast" became the terms. On a key of five characters or
fewer the bonus now excuses one edit at most, and a single word on a
built-in list of about 1,600 common English, German and Spanish words
only ever matches exactly. The list is an injectable closure
(isOrdinaryWord, defaulting to CustomWordCorrector.isCommonWord), built
in init so no dictation pays for it. Rarer near misses ("clawed",
"kafca") and runs of common words ("cloud code") are still repaired.
The case-rule comment no longer claims the replacer mirrors all caps.

Fillers: a full stop, question mark or other sentence mark after a
filler stays with the sentence: "I think so, um." keeps its full stop,
"Yes, uh. Okay then." keeps its boundary, "Is it ready, uh?" loses the
stray space. A filler that is a sentence of its own still takes its
mark. A single-m "em" is no longer a filler ("an em dash", "let 'em
in"), and a filler joined by a hyphen ("uh-oh", "uh-huh") stays.

Spoken punctuation: a phrase after an article, or after an article or
demonstrative and an adjective that describes a mark, is the noun: "add
a semicolon", "the Oxford comma", "ein großes Fragezeichen". "the end
comma" and "What is that question mark" still convert.

Dictionary: an entry with a punctuation edge ("c++", ".net") now
matches next to punctuation, "c++," and "(c++)" included, and still not
inside "c+++", "c++x" or "asp.net". The existing "(c++)" expectation
flips to "(C++)" with it.

Whitespace: drop the redundant isNewline check.

Release-mode median per processor over a 100-word transcript, before ->
after (us): fillers 69-72 -> 72.5, dictionary 12-13 -> 12.2, custom
words 232-248 -> 230, whitespace 44 -> 43, spoken punctuation 121-126 ->
120.5; the corrector's own cost test 434 -> 439. All within noise.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The load task was neither kept nor cancelled. A `select` while a load was
in flight left the old task running, and when the old engine finished it
set the status from that engine (`.ready`, say) and cancelled the poll of
the engine that had replaced it, so the new engine's progress and result
never reached the menu. The task is kept now, cancelled by `select`,
`load` and `stop`, and writes nothing unless its engine is still the
current one.

The poll interval is an init parameter, 250 ms by default, so the new
test, two gated engines with a switch between them, runs in a tenth of a
second.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`endUtterance` set `session` to nil after `await session.finish()`, so a
`beginUtterance` that ran during that await had its new session wiped, and
a `finish()` or tail `append` that threw left the finished session and the
recording's samples in memory until the next press. A `defer` now clears
both on every exit, and only while `session` is still the one this call
finished. The audio duration is read before the awaits for the same
reason. The engine needs the Parakeet models, so this has no unit test.

Comments that still described the removed `FluidAudioEngine` and a step of
the retired performance plan now describe the code as it is, and the
meter's doc comment says -60 dBFS, which is what the code maps to zero.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The engine called itself "Parakeet TDT v3 (incremental)" while the app's
registry entry said "Parakeet TDT v3", and the CLI built the engine type
by hand in three places. `StandardEngines` in PladderEngines now holds the
entry once: its id, the name "Parakeet TDT v3", the English description
the app's catalog translates, and the factory. The engine's own
`displayName` reads the catalog's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tap processor reserved ten seconds of samples and threw the
reservation away at the first one-second drain, so for the rest of the
recording the realtime thread grew its array from nothing every second.
The samples now live in `SampleAccumulator`, which copies a drain out and
keeps its buffer.

One accumulator lasts the whole recording and a device change puts a new
tap in front of it, which replaces the carried-samples array and the
merging `drain` and `stop` did. A failed restart or tap reinstall after a
device change is logged in the capture category instead of being dropped
by `try?`; a redundant `isRecording` check goes.

The accumulator is tested without a microphone: a drain returns what came
since the previous one, the last drain only the tail, drains racing a
producer thread neither lose nor repeat a sample, and the buffer is kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
main.swift had grown to 760 lines. It now parses the command line and
dispatches; transcription, the two engine benchmarks, polishing and the
shared helpers have files of their own. Output is unchanged except where
noted below.

- The engine comes from `StandardEngines` instead of three hand-built
  `FluidAudioIncrementalEngine`s; the paced bench asks the catalog's engine
  for the streaming protocol. The bench header's model name is now the
  catalog's, "Parakeet TDT v3".
- The whole-buffer and paced benchmarks share the options check, the
  header, the results table, the thermal tagging and the footer.
- `eprint` replaces sixteen `FileHandle.standardError.write` calls.
- `polish-set` runs `StandardProcessors.factories` with a fresh install's
  settings instead of its own list of three; with no dictionary the two
  extra stages change nothing. An empty test set is an error instead of
  an index out of range.
- `--model` names come from one switch over `PolishModel`, which the
  compiler keeps complete, and the usage line lists them from it.
- The sysctl string is decoded as UTF-8 up to its NUL instead of through
  the deprecated `String(cString:)`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
docs/BENCHMARKS.md describes a processor benchmark, filler in every
sentence and a spoken question mark in the worst case, that no command
ran. `pladder-cli bench-process <fixtures dir> [--runs N]` does: each
fixture's script, as one line, through the pipeline `--process` runs with
a fresh install's settings, 31 runs per variant by default, the first
discarded, and a table of the medians.

BenchProcess.swift uses nothing else from the CLI, so the file and its
`case` in main.swift can be copied onto an older commit to measure a
change before and after. It awaits `ProcessorPipeline.run`, which builds
whether that is async or not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…processors become synchronous

The app registers engines from StandardEngines, and TextProcessor is
synchronous and cannot throw, since no processor awaited or threw;
anything slow is a TranscriptRefiner. Processors carry no English text
any more: the app words them by id in StatusText.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nested chords handed over only when the shorter chord was dictate. With
toggle = Right Command and dictate = Right Command + Right Option the set
reported in role order, [dictate.pressed, toggle.cancelled], so the
gesture tracker ignored the press and the cancel dropped the recording;
past the window the toggle release latched under a chord no longer held.
`HotkeyChordSet` now reports every end before any press, role order
breaking ties within each group, so both nestings behave alike.

A lost key-up of a chord's regular key left it in `heldKeys` and
`swallowedKeys`: after Option+Space with Space's key-up lost, a lone
Option pressed the chord and the next Space typed was eaten. Once the
chord's modifiers stop being held its regular key no longer counts until
it goes down again, and a non-repeat key-down of a key still counted as
swallowed is taken as the missed key-up. The cancel key gets the same
rule for its own key-up.

Tests: the reversed nesting at set level, the set and gesture tracker
piped together as the coordinator does, and the lost key-up cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r the last

The three recorder fields shared `coordinator.isHotkeySuspended` and each
set it from its own recorder. Clicking a second field while the first
recorded left both recording; whichever finished first resumed the live
hotkey while the other still listened, so pressing the old chord into it
started a dictation.

`HotkeyRecordingSession` in Core decides: beginning one recorder ends any
other and keeps the hotkey suspended, and only the recorder holding the
session resumes it, once. A private `RecordingSlot` in the recorder file
holds the app's one session, cancels the displaced recorder and calls the
suspend closure. The fields take `setHotkeySuspended:`, called on the
transitions rather than from each recorder's own state, and a committed
chord is stored before the hotkey resumes, so the monitor restarts once
with the new chord.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ned refcons

Both monitors registered `Unmanaged.passUnretained(self)` with the tap and
with Carbon and tore down asynchronously, so a monitor freed in between
could be called back on freed memory. The tap and the Carbon handler now
hold the monitor retained, released exactly once in the teardown that
follows invalidating the port or removing the handler, or at once when
nothing was made.

The start/stop bookkeeping both monitors duplicated, generation,
continuation, onTermination and the stale-install undo, is
`HotkeyMonitorLifecycle`. A dropped stream now ends the session the way
`stop` does, so a pending tap install no longer comes up for nobody and
Carbon's Escape is unregistered with the hot keys.

Carbon's ID packing, generation filter and press/release de-duplication
are `CarbonHotkeySession` in Core, tested; its separate generation check
went, being implied by the per-session ID lookup. `stop` takes the lock
once, `onMain` and the optional handler are gone.

`HotkeyMonitorEvent` is a kind and an instant, the kind `.chord(role,
event)` or `.escape`, so no fake role rides on Escape; the coordinator's
switch matches the kind. `Hotkey` moved to Models/Hotkey.swift and lost
the unused `.function`. `HotkeySource.choose(accessibilityTrusted:
secureInputSustained:hotkey:)` is the tap-or-Carbon rule from AppModel,
for AppModel to call.

GlobalHotkeyMonitor: `retryInterval` is a private let and the
force-unwrapped chord set is gone with the optional session state.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`KeyNames.layoutCharacter` repeated `KeyboardLayout`'s layout fetch and
`UCKeyTranslate`, and was not main actor bound although entering Text
Input Sources from two threads aborts the process. It is
`KeyboardLayout.displayCharacter(for:)` now, `@MainActor`, sharing one
translate with the Cmd+V lookup and tested against the US and Dvorak
layouts. `displayName`, `sideAgnosticDisplayName` and `KeyNames.name` are
`@MainActor`; every caller already was. The layout key is read as the
constant: it imports as a `let`, unlike the Accessibility prompt key.

ModifierKeyState gets tests: device bits, generic-only flags read as the
left key, Fn only from its own flagsChanged.

Comments: three positives, about four seconds, sustain secure input; the
default Option+Space stops under secure input; the chord set, not the
monitor, feeds the trackers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The pipeline tests drive it through the chord set; these feed the gesture
tracker the order the set now reports, end first, for both nestings and
past the window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ce.choose

HotkeyRouter's own copy of the rule goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- S1-mini: an answer that runs out of tokens before its end token is a
  failure (`.truncated`), not a short answer pasted with the end of the
  dictation missing. The generation loop is factored out so the tests
  drive it with stand-in tokens.
- LlamaModel's init tokenizes the prefix before the context exists and
  frees what it allocated on every throw before deinit can run.
- S1-mini's 8 s budget now covers the wait for the model: a first
  dictation that finds the load still running pastes as dictated at the
  deadline, and the load carries on for the next one.
- Apple's polish has one deadline per call covering every chunk and the
  plain fallback, instead of 8 s each.
- Chunking forces a cut between words at 1.5x the chunk size, so a long
  unpunctuated dictation no longer overflows S1-mini's context.
- Framework errors are mapped once, inside the race, into typed
  OnDeviceModelError cases (decodingFailure, unsupportedGuide), macOS 27's
  replacement types included; both callers switch on those. On macOS 27
  the old description strings never named the case, so the plain
  fallback never ran, and their debug text could reach a public log line.
- ModelFiles: a file of the wrong size is not ready; cancel(_:) stops a
  download and leaves nothing behind; an interrupted download resumes from
  URLSession's resume data and is still checked against its SHA-256. A
  transport protocol lets the tests run without network.
- The report and chunking both polishers share move to PolishReport and
  PolishChunking; TranscriptPolisher.Report stays as a typealias.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…olish off

The app cancels an S1-mini download when the user picks another model or
turns polish off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and-over fix

ManualClock drives the coordinator's timers, so the cap, the hints, the
warm loop and the bounce settle are fired, or shown not to fire, by
advancing it rather than by waiting. FakeHotkey stamps its events with a
clock of its own, a step apart, so holds and bounce gaps are arithmetic;
'nothing happened' checks wait for the coordinator to have handled the
event instead of sleeping. One test sleeps on purpose: it is about wall
time passing between a press and a late release.

New tests, each failing on the code before the fix: a chunk in flight at
the release reaches the engine before the end and is in the count; no live
pass starts after a release; a press right after a cancel keeps its
utterance; a cancel abandons the recording's engine once; a streaming
engine that cannot begin is transcribed whole; the inserted event carries
the result and whether Return followed; only a real settings change
rebuilds the processors; changes before start register once; shutdown
gives back the speakers and the clipboard and lets a dictation finish.
Fakes that stand in for engine work now wait uncancellably, as a CoreML
pass does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
OverlayView.swift held eleven types. The model and its phase, the view,
the pill, the live transcript text with its metrics, and the small parts
(dot, background, bars) now have files of their own. No behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r splits out

PasteboardOutput did six jobs in one actor. It now picks the key and decides
between pasting and copying; ClipboardKeeper owns the snapshot, the promise
and the restore timing against an injected pasteboard name and clock; a
KeyPoster types the keys. The grant is injected like AXPasteObserver's.
`PasteboardOutput()` keeps its public init and defaults.

- A paste nothing read left its restore pending for 8 s; a copy in that
  window followed by a second dictation got the older clipboard back. The
  pending snapshot is now carried only while our transcript is still on the
  pasteboard; otherwise the key-down snapshot or a fresh capture is used.
- TextOutput.flush(), default no-op: PasteboardOutput restores at once if
  our transcript is still there, for quitting inside the restore wait.
- Return goes out submitDelay after the target app's first read of the
  transcript, or at restoreFloor if nothing read it, instead of a fixed
  50 ms after Cmd+V; a failed post is logged.
- prepare() is idempotent: nothing changed since the last snapshot reads
  nothing, so it can run again at release and keep the capture off insert.
- A snapshot whose every item was over 64 MB no longer clears the
  pasteboard on restore; an incomplete snapshot is logged.

Tests run against a private named pasteboard, a recording key poster and a
manual clock: back-to-back carry, a copy in between, a newer paste taking
over, a read moving the deadline, the no-Accessibility drop, flush, Return
timing, prepare twice, oversized items.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A device with one channel muted by the user read as not muted, so both
channels were muted and later both unmuted. OutputMuteControl now reads and
writes a MuteState, element by element; the controller mutes only the
elements that were off and turns back off exactly those, including the ones
a half-failed set did reach.

The start and end of a recording run on unordered tasks; an end that came
first left the start to mute with nothing to undo it. OutputMuter now takes
recordingStarted(session:) and recordingEnded(session:): an end disarms its
session whenever it arrives, a start whose session has ended returns at
once, and an older session's end leaves a newer one alone. The session-less
pair stays as a transitional requirement so today's coordinator and its
fake still compile; remove it once the coordinator passes sessions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… trees alone

- CorrectionLearner takes waitUntilQuiet, awaited before every review, so
  the app can keep reviews off the language model while a recording or a
  polished dictation is in flight. Default: never holds.
- A reviewer error is logged by type and case, never by its text.
- AXManualAccessibility is set only for an app that shows no focus at all
  or a bare AXWebArea, not for every app whose focus is not a text field.
- Releasing the observer mid-watch resumes its caller with nil instead of
  leaving the continuation hanging.
- The settle sleep and the paste window's margins are named.
- CorrectionDiff loses checks a word token can never fail.
- PastedTextObserver documents the one-watch-at-a-time contract and that
  cancelling the caller does not end a watch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…it for the dictation

The muter takes session numbers only: the transitional session-less pair
is gone, with a coordinator test that an end names its own start. The
learner's reviews wait while a dictation is in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ll metrics

- The settings window is a tab container; the General tab is built from
  one section per concern and the Processing tab has a file of its own.
- What each recorder field warns about is HotkeyWarning, a pure, tested
  value in Core; the app words it in StatusText and shows it with one
  WarningLabel instead of five copies of the same modifiers.
- Views no longer reach through the model to the coordinator.
- The pill's font and meter geometry are PillMetrics; the live row's width
  is computed from them instead of a hand-summed constant, and the words
  are measured in the face they are drawn in by construction.
- The stand-in desktop's wash is one view, shared with the screenshots;
  the overlay panel takes its appearance in one place.
- Three catalog keys the removed polish key left behind are gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The level changed twenty times a second and rode in .recording(level:), so
every reader of the state was redrawn with it, and the overlay controller
re-ran its presentation logic per tick. It is the coordinator's inputLevel
now; the menu bar glyph and the overlay meter read it, the overlay mirrors
it on a loop of its own, and the status line and the presentation only
hear real state changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The speech model's download is checked by size, not SHA-256; the send
key's Return waits for the read; the polish budget covers the load and
every chunk; quitting, the settings file and the mute sessions get
decision rows; the pluggability rules name StandardEngines, the
synchronous TextProcessor with its wording in StatusText, and the app's
parts; the engine stage includes a feed hand-over.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…count, a left transcript stays served

- A send whose target had not read yet still owed its Return when the next
  paste began, and its timer could post it after that paste's Cmd+V,
  sending both texts. The next paste now posts it first, once.
- A read stamped before Cmd+V counted as the paste, because the actor sets
  'posted' before the read's task runs; only the instant tells them apart
  now.
- When every item of the user's clipboard was too large to keep, the
  restore leaves the transcript, and its promise lost its last owner; the
  keeper holds it, and the next snapshot does not read it back as the
  user's clipboard.
- The Return's comment and CLAUDE.md no longer claim more than the read
  can give in Chromium.

Each with a test that fails without its fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p still ends on SIGTERM

- flush() restored at once, so a dictation pasted on the way out handed
  the target app the user's old clipboard. It now waits as the timer
  would, for the read and its settle, no sooner than the floor, and gives
  up at the floor on an app that has not read; with nothing to restore
  (the user copied since) it returns at once.
- SIGTERM was handled on the main queue, so a stuck main thread made the
  app unstoppable by pkill. It is now caught on a global queue, asks for
  the normal quit, and ends the process 5 s later regardless.

Tests: flush waits for a read right after Cmd+V, respects the floor after
a prompt read, and gives up at the floor; each fails with the old flush.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dinooo13 and others added 24 commits October 10, 2026 02:16
llama.cpp ignores cancellation, so polish off and on during a load started a second load while the first still read its weights, and both models were resident. The next load now waits for the abandoned one, whose model is dropped inside its task and so freed before the second is read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nnot read survives a save

An older build launched against a newer build's file copied a backup on every load and, at its first save, wrote the default over the newer value. A file already backed up is not copied again, and a save keeps the file's own value for a key this build dropped while the settings still hold what the load put in its place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… dropped recording ends before the new monitor starts

A chord change set the settings, restarting the monitor with the new chord and the old stand-in, then the stand-in, restarting it again; without Accessibility the first handed Carbon a modifier-only chord it cannot register. `update(_:hotkeyOverride:)` takes both and restarts once.

A recording dropped by a configuration change was cancelled from a task while the new monitor started at once, so a press on the new stream could find `.recording` still set and be refused. The cancel's synchronous half now runs before the monitor starts; the press waits only for the microphone to stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Polish off and on within one run-loop turn fired the cancel and the ensure as two tasks; when the ensure reached the actor first it kept the running download, and the cancel then killed it, leaving polish on with no download and no Try Again. `OrderedTasks` runs each call after the one before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r gives the hotkey back

Keyed on the recorder's address with a weak holder, a recorder freed mid-recording left the hotkey suspended, and the next one at the same address looked like it beginning again and suspended nothing. The slot moves to PladderCore, hands out tokens, takes the session over from a holder that is gone, and the recorder, now held weakly by its monitors, releases it in its deinit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each chain re-armed from its own callback, so one pending at `stop()` re-armed after the next `start()`, and every level change ran two trackers from then on. `ObservationLoop` gives each start a generation and ends a chain from an older one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nothing

`beginUtterance` returns an `Utterance`, and feed, live pass, end and abandon name it. The engine dropped whichever utterance was current when an abandon ran, so a cancelled recording's late abandon could drop the next recording's, which then failed as "not loaded"; two overlapping begins each assigned their session after an await, and the loser was never cancelled. `UtteranceSlot` in Core keeps the bookkeeping, so it is tested without the model, and the coordinator's cancel-ordering guard for a begin that was cancelled is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Each cancel replaced the stored cleanup, so a press waited for the newest only. An older cleanup still waiting for its feed then dropped the engine's utterance after the next recording had begun one. Now each cleanup ends with the one before it, as the mute restores already do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e microphone alone

It went on to start the capture after the wait, which restarted the capture of a recording that had started since: its buffered samples were dropped and its meter went flat.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… monitor, without a restart sentinel

`update` works out once whether the monitor has to restart, from the old and new chords, the stand-in and a new monitor if one is given, and restarts it at most once. `settings` and `hotkeyOverride` set through it, so the tri-state flag their observers had to consult is gone, and `replaceHotkeyMonitor` is `update` with a monitor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he next permission poll

The router chose between the tap and Carbon only on the two-second poll. Under Secure Event Input a modifier-only chord recorded while Carbon had the hotkey was handed to Carbon, which cannot register it, and nothing listened until the poll. `hotkeyChanged` now chooses again with the last secure-input reading, and the app hands the coordinator the chord, the stand-in and the monitor in one restart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every dictation setting rebuilt them, a compiled regex per dictionary entry in two processors, although the dictionary is the only setting they are built from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The SHA-256 ran to its end on a detached task, so cancelling a download while it verified waited for up to 1.5 GB of hashing, and the ensure of the model picked next queued behind it on the ordered file work. The hash now checks for cancellation between chunks and is cancelled with the download; the staging file is still removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The app delegate raced the shutdown against its deadline with a second implementation of the helper the polish models use. `firstOf` moves to PladderCore, public, and the delegate's copy goes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s its own

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-in in one update

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…old instant

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…othing changed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ipboard markers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrupt download

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…paste fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ate, not a 200 ms poll

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…olish and the chunks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dinooo13
dinooo13 merged commit 74d7b2d into main Oct 15, 2026
1 check passed
dinooo13 added a commit that referenced this pull request Oct 15, 2026
…ove to docs/ARCHITECTURE.md (#71)

A comment stays for an outside constraint, a measured number or a trap; everything else is said by the code, a name, docs/ARCHITECTURE.md or CLAUDE.md. Apart from comments, whitespace and the renames the PR lists, the code is the one the architecture review (#70) landed. The PR's own history is on its original head, 9807051.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dinooo13 added a commit that referenced this pull request Oct 15, 2026
…ove to docs/ARCHITECTURE.md (#71)

A comment stays for an outside constraint, a measured number or a trap; everything else is said by the code, a name, docs/ARCHITECTURE.md or CLAUDE.md. Apart from comments, whitespace and the renames the PR lists, the code is the one the architecture review (#70) landed. The PR's own history is on its original head, 9807051.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dinooo13
dinooo13 deleted the t3code/pladder-architecture-review branch October 15, 2026 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant