Repository navigation
Ergopti v1.0.7
Ergopti 1.0.7
Ergopti — keyboard layout: install the ergonomic layout without running an application.
Ergopti Plus — advanced typing tools: the companion application adds remapping, shortcuts and text expansion.
Downloads
Ergopti — keyboard layout only
No Ergopti Plus application is required for these keyboard layouts.
| Platform | Download | Installation |
|---|---|---|
| Windows | Ergopti_windows.exe | Run as Administrator, log out and back in, then select the layout in Settings → Time & Language → Language & region. |
| macOS | Ergopti_macOS.zip | Unzip, copy the .bundle folder to /Library/Keyboard Layouts/, log out and back in, then add it in System Settings → Keyboard → Input Sources. |
| Linux | Ergopti_xkb.zip | Follow the XKB installation guide. Includes standard, ANSI, plus and plus-ANSI variants. |
Ergopti Plus — application with advanced typing tools
Select your platform and download the corresponding file.
Windows — Ergopti Plus
| Asset | Description |
|---|---|
| ErgoptiPlus.exe | Compiled AutoHotkey executable (AHK v2 runtime bundled) |
Installation — app:
- Download
ErgoptiPlus.exe. - Double-click to run. On first launch, resources are extracted to
%LOCALAPPDATA%\Ergopti. - Your personal configuration is stored in
%USERPROFILE%\.config\ergopti_plus. If this folder does not exist yet (first launch), the setup wizard will open automatically.
macOS — Ergopti Plus
| Asset | Description |
|---|---|
| ErgoptiPlus.app.zip | macOS application (Hammerspoon bundled; Karabiner-Elements installed on first run) |
Installation with Homebrew:
brew tap adrienm7/ergopti
brew install --cask ergoptiplusergoptiplus installs the stable releases and ergoptiplus@dev the dev prereleases; this release is in ergoptiplus. brew upgrade then updates it with your other casks, and the app still updates itself from its About menu.
Installation — app, by hand:
- Unzip
ErgoptiPlus.app.zipand moveErgoptiPlus.appto/Applications. - Remove the quarantine flag (the app is not yet Apple-notarised):
xattr -dr com.apple.quarantine /Applications/ErgoptiPlus.app- Launch the app. On first run, Karabiner-Elements will request a System Extension approval — required for key remapping.
- Your personal configuration is stored in
~/.config/ergopti_plus/. If this folder does not exist yet (first launch), the setup wizard will open automatically.
Linux — Ergopti Plus
| Asset | Description |
|---|---|
| ErgoptiPlus-linux-amd64.deb | Driver package for Debian, Ubuntu and Mint |
| ErgoptiPlus-linux-x86_64.rpm | Driver package for Fedora, RHEL and openSUSE |
| ErgoptiPlus-x86_64.AppImage | Portable driver — any distribution, no root required |
| ErgoptiPlus-linux-x86_64.flatpak | Sandboxed driver, for a distribution-agnostic install |
| ergopti-plus-linux.tar.gz | Driver bundle with install.sh, installs under your home directory |
Which file for your distribution:
- Debian, Ubuntu, Mint, Pop!_OS, elementary →
ErgoptiPlus-linux-amd64.deb - Fedora, RHEL, CentOS, openSUSE, Mageia →
ErgoptiPlus-linux-x86_64.rpm - Arch, Manjaro, EndeavourOS → build it from
tools/build/PKGBUILDin the repository; no Arch package is attached to this release - Flatpak →
ErgoptiPlus-linux-x86_64.flatpak, installed withflatpak install --bundle. It requests--device=all, which is the only permission that reaches/dev/inputand/dev/uinput; the sandbox still cannot grant access your user does not have on the host, so the udev rule in step 2 is required either way - Any other distribution, an immutable one, or no root access →
ErgoptiPlus-x86_64.AppImageorergopti-plus-linux.tar.gz
Alpha. CI exercises virtual input devices, display sessions and package launches. Physical keyboard behavior and desktop-shell integration still need validation on the target distribution.
Installation — app:
- Open the downloaded package with your distribution's software installer and install it.
- Open Ergopti Plus from the applications menu. Input permissions are prepared by the package; if additional authorization is needed, Ergopti opens a graphical administrator prompt.
- Follow the first-use window. If group membership changed, sign out and back in when prompted.
- Choose Global actions → Start at login to enable or disable automatic startup.
Removal: choose Global actions → Uninstall Ergopti, or remove the package through your software manager. The standalone archive also includes an ownership-checked uninstall script. Personal settings are preserved.
Changelog (click to expand)
Timestamps are in Paris time (Europe/Paris, CET/CEST).
Commits since v1.0.6.
Features
-
Gestures: Move the recommended-gestures explanation behind « Infos… »
The Gestures menu showed the recommended-defaults note and each platform's
reason as label rows: a whole paragraph in the tray, and AutoHotkey drops a
menu item name over 260 UTF-16 units. The menu now has one short command,
« Infos… » (menu.gestures.recommended_info, all 21 locales), and each
driver opens its existing native info dialog with the unchanged text:- Windows: Ui_MsgBox with menu.gestures.recommended_reason_windows only,
since the driver takes the system's assignment over itself. - macOS: dialog_util.block_alert with the shared note and the macOS reason.
- Linux: the menu builder's zenity show_info with the note and the Linux
reason.
The gesture_recommended_notes list and template are retired; the menu
manifest and config-key constants are regenerated. The rule test pins the
command, the absence of explanation rows, short captions in every locale
and each driver's dialog keys; the macOS, Linux and Windows menu tests
click the row and check the dialog receives that text.Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
- Windows: Ui_MsgBox with menu.gestures.recommended_reason_windows only,
-
Model-selector: Collapse backend groups and list local before API
The Ollama group came first and listed every Ollama model, burying MLX and
the API. The page now opens on one collapsed header per backend: the local
backends by name, a thin separator, then the API, which holds every
provider (discovered local servers and custom ones included). Each header
shows its chevron, emoji and brand once, the model count, the tools and
the greyed Install button as before, and the active model while
collapsed. It toggles by mouse, Enter or Space with aria-expanded; a filter
or a search opens the groups it matches in, and the groups the user opens
stay open while the page lives. The backend filter follows the same order
with an optgroup per kind, rows sort case-insensitively and API rows by
provider then model. The page refuses a brand that carries its emoji.The header reuses existing strings, so no locale changes.
-
Llm: Give each local backend of the model selector its install button
Maintainer request: each local backend group of the model selector page
carries "Install ", greyed with the reason once installed or
where the backend cannot run; until a backend is installed its rows stay
greyed, so a fresh machine can select only API rows; the click runs the
owned-runtime install with its progress in the page, and the payload
that settles it unlocks the rows.The shared page draws the button from a new backend field
install = { state = available | installed | unsupported | running,
reason_key?, percent? }, posts the existing group action "install", shows
"Installing …" (with the percentage from injectInstallProgress)
while it runs, and refuses an unknown state.- macOS: install_state() reads the runtime owners (MLX on Intel and a
config.toml that refuses writes are unsupported); the click runs
runtime_install_offer.install_ollama / select_mlx (the press is the
consent), not the backend switch, and refreshes on its outcome. The
download window now also reports a runtime install's percentage to
its observer, which the page forwards to the group header. - Windows: Ollama counts as installed when its owned runtime is
published (a directory lookup under the managed root), its server
answered or it lists models; otherwise its rows are greyed with
reason_ollama_missing. LLM_Deps_IsChecking() reports the running
bootstrap; the click runs the existing LLM_Deps_CheckAndInstall and
refreshes on ready or failure. MLX is unsupported. - Linux: the owned Ollama install is the AI enable's download offer, so
the button runs prediction_engine.enable while the AI is off and is
greyed with reason_install_ai_on while it is on; runtime_pending()
reports it running. MLX is unsupported.
Four new strings in all 21 locales. Tests: the shared page (fresh
machine, click, progress, unlock, greyed reasons, unknown state), the
macOS owner (every state, install, progress, refusals), the Windows
payload runner (fresh machine, running) and the Linux bridge (states,
enable, running, AI on).Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
- macOS: install_state() reads the runtime owners (MLX on Intel and a
-
Linux: List API models, grey a missing Ollama and show pull percentages
The Linux model selector page listed only the configured model of an API
entry and never greyed its Ollama group. Three gaps close, with macOS and
Windows:- Each chat entry whose key its endpoint accepts is asked for its
provider's models once per page open, one request at a time and at most
16 entries (api_remote.list_models: its own HTTP owner, so a listing
never cancels a prediction; the request timeout; the key only in the
header or the gemini URL, never in a log). Its rows follow
llm.api_model_listing: one per listed model, or the known models and a
typed-id row whose hint names the reason. Another model, listed or
typed, becomes the entry's own and the selected one in one private
publication (api_entries.set_model). - modules/llm/ollama_install_state.lua reads whether the local Ollama can
run: greyed with its reason when the managed runtime is absent (the
read-only resolver and installed-runtime capture, no spawn) and no
system ollama is on PATH, or when the last owned repair failed; never
when the server answers with models or the user points at a remote one.
The page refuses Ollama actions while it is greyed. - A pull started from the page shows its percentage: model_download tells
its one observer each new whole percentage of the /api/pull NDJSON, then
the end, and the page draws it on that tag's row and keeps it in the
next payload.
The menu binds the listing, install-state and percentage ports, so a page
test without a menu never reaches the HTTP adapter.Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
- Each chat entry whose key its endpoint accepts is asked for its
-
Windows: List API provider models and show pull percentages in the selector page
Windows listed only the configured model of an API entry. The page now
asks each chat entry whose key its endpoint accepts for its provider's
models once per page open, one request at a time and at most 16 entries
(LLM_RemoteListModels_Async), through the readiness probe's own bounded
GET of /models: same request budget, proxy admission and key
headers, the key never in the payload or a log. Each entry then shows one
row per listed model, or its known models and a typed-id row whose hint
names the reason, through the AutoHotkey port of
_shared/lua/llm/api_model_listing.lua, which replays the shared corpus.
Choosing another model, listed or typed, writes it to the entry and
selects it in one private-entries transaction, then the API backend.A managed Ollama pull started from the page shows its percentage: the
pull's own tree-owned capture is read for its last whole percentage
(handle.outputTail, read-only and shared) every llm.pull_progress_poll_ms,
and the page draws it on the row of that tag and keeps it in the next
payload. The unmanaged pull still prints its progress in its console only.Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
-
Macos: List API models through the shared plan and show download percentages
The model selector page now draws each API entry's rows through
llm.api_model_listing: one row per model the active entry's provider
listed, or the provider's known models and a typed-id row whose hint
names why the list is missing (pending, refused, unavailable). Before,
a failed listing left only the configured model. api_remote records the
listing state of each entry and reads its ids through the shared parser.
An entry's own model wins a name two entries of one provider share.A download started from the page shows its percentage: the download
window tells its one observer each new whole percentage of an Ollama or
MLX model download, and the page draws it on the row of that backend's
pending download and keeps it in the next payload.Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
-
Llm: Share the API model listing plan and show download percentages
The model selector page drew one row per model only on macOS, from the
active entry's /v1/models, and a refused or unreachable listing left the
configured model alone with no reason. _shared/lua/llm/api_model_listing.lua
now owns the rule every driver follows: the model ids a provider listing
yields (openai and anthropic data, gemini models without their models/
prefix; sorted, unique, at most 200 ids of at most 200 bytes, without
whitespace or control characters), and the rows of one entry: one per
listed model, or, while the listing is pending, refused or unavailable,
the provider's known models (configured, default, model_extras) and a
typed-id row whose hint names the reason. Row names keep the entry's
host suffix, so two entries of one provider stay apart. A page lists at
most 16 entries. _shared/tests/corpus/llm/api_model_listing_vectors.json
pins the rule for the Lua module and its AutoHotkey port.The page shows a download's percentage: injectProgress() marks one row
downloading with a whole percentage the driver read from its own progress
source, and a payload row may carry it too. New strings in all 21 locales:
the refused and pending listing hints and "Downloading… {n} %".Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
-
Llm: Make the model browser page the model selector
The maintainer decided on 2026-10-27 that the AI menu lists no model. On
Windows, macOS and Linux the tray now draws one button, the backend's
emoji then the model ("🦙 qwen3:4b", "🚀 Qwen3-4B", "🌐 OpenAI/gpt-4o-mini"),
which opens the model browser page. The Backend chooser, the model list,
the API entries, the provider Add rows and the custom-model dialog left
the tray.The page is the selector: one table grouped and filterable by backend
(Ollama, MLX, API) whose columns come from the shared catalogue
projection. The user picks the model and its backend together, downloads
or deletes a local model, adds a custom one, and adds, tests or removes an
API entry. Each action runs the drivers' existing transactions: macOS
collects the Backend and API panels' own closures, Windows keeps a
payload registry of the native closures, Linux keeps the page rows' run
closures in the bridge.Only what works now is selectable. A row is greyed with a localized
reason for MLX off Apple silicon or with its runtime absent or broken,
for Ollama with its install failed, for an API entry without the key its
endpoint requires, and for a read-only config.toml. A model that is not
downloaded offers its download, never its activation.Single sources: _shared/modules/llm/backends.json holds the brands and
emojis for the three drivers and the page; _shared/lua/llm/model_identifier.lua
and its corpus validate an added model on every driver and in the page.Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
-
Llm: Resolve local API server ports from process, settings and sockets
The local server catalogue named each server's documented default port
only. oMLX lets the user change its port in its app, which stores it in
~/.omlx/settings.json (server.port); a server on another port answered
nothing at the catalogue address, and every sweep reported none.local_servers.json now declares, per server, how to find it running
(process names, command-line fragments), the flags that carry its port,
and the settings files (per OS, key path) that hold it. The shared policy
(_shared/lua/llm/local_server_ports.lua) probes a server only while its
process runs, at its command-line port, else its settings-file port, else
a port its process listens on, and only then at the catalogue default,
naming the source of each port. A shared corpus pins every source,
including oMLX on 8181 from its settings file.Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
-
Macos: Report the Lua heap around slow holds and saves
The Mac's preferences prepare took 1291 ms where the same whole-snapshot save over the generated config template costs 58 ms of Lua here, so the gap is not the save's own CPU; a garbage collection over the large heap (843 MB resident) is one candidate. Hold reports and the slow-save line now give the Lua heap before and after, so a drop names a collection inside the hold.Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
-
Macos: Name every main-thread hold from 250 ms on
The 23:33 Mac log counted 7 to 18 timer holds of 250 ms to 1 s per five minutes (runtime.timer_hold_over_250ms) without naming one; key events arrive late from 250 ms on. Such a hold now writes one evidence line per callback site and minute, 'Main-thread hold of N ms in an hs.timer. callback ()', with the phases inside it. Holds of 1 s or more keep their warning.Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
-
Macos: Name the synchronous shell forks inside a main-thread hold
On the Mac a synchronous hs.execute fork took about 40 ms, and several per callback made holds that no phase named (the 23:33 keystats start hold reported no phases). The runtime capture now times every hs.execute as phase shell., so a slow-callback report lists the forks it waited on and the phase summary counts them. Only a bare program basename is kept; a quoted or unusual first word reads other.Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
-
Toggle every web-view page from a shortcut action
The maintainer decided on 2026-10-27 that every Ergopti web-view page gets
an action in the shared catalogue that toggles it, so a single shortcut
shows and dismisses it. Closed or hidden opens and focuses the page; open
behind another window brings it to the front and never closes it; open and
frontmost closes it through the page's own close.The catalogue's UI group gains open_hotstrings_settings,
open_layout_manager, open_layer_editor, open_model_browser, open_changelog
and open_healthcheck, labelled in all 21 locales from each page's own
window title. The four existing window actions keep their ids and become
toggles on macOS and Linux too (Windows already toggled the metrics pair;
its hotstring and paths editors only ever opened).Each driver owns the three-state rule once and reads only the window state
its pages already hold:- macOS ui/window_toggle.lua asks each page module for window() and
close() and presents a covered page with ui_builder.force_focus. The four
openers that need session context are registered by their owner: the menu
session (layer editor, changelog, diagnostics) and the AI menu build
(model browser, one line; the page itself is untouched). - Linux webview_manager.toggle() reads its window pool and GTK's is-active
flag; the diagnostics page opens through its bridge. - Windows GestureToggleOrFocusUI names each page's Gui global and opener;
pages without a dedicated closer get WM_CLOSE through the new
WMRequestClose adapter, which runs their own Close handler.
Every page exists on the three OS, so no row is greyed. The onboarding
wizard, the download progress window, the release-notes pane and the
contextual dialogs are deliberately not toggles (reasons in actions.toml).Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
- macOS ui/window_toggle.lua asks each page module for window() and
-
Macos: Name the ignored-window probe stage that fails or waits
The Mac field log shows "Ignored-window prewarm incomplete" and 1 s holds from the ignored-window refresh timers (ledger L23) without naming the AX read. The prewarm warning now names the failed probe stage and the TTL state, and a probe over 250 ms logs every stage's milliseconds.Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
-
Macos: Name the slow preferences prepare stage and write forks
A one-key save held the main thread about 2 s on the Mac (ledger L17, prepare 1.25 s for 293 operations) and the report could not say which preparation step costs it. The slow-save line now times the runtime snapshot, decode, model rows, sparse rows, wrap rows, row filtering and the four section preparers, and counts the metadata forks of the write.Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
-
Macos: Name the slow ledger append stage and its metadata forks
The Mac field log shows the keystats ingestappendphase at 0.5 to 0.7 s
for a single entry all day (ledger L16) without saying which part of the
ledger flush waits. LedgerAppend.flush now reports its lock, journal,
prepare, publish, write, clear, settle and release stages to the ingest
trace, and a new adapters/metadata_command_stats counter records every
synchronous metadata fork of an atomic publication, so the slow-tick line
names both the stage and the fork cost.Claude-Session: https://claude.ai/code/session_01T2JT9GiVT5rpWoGSU8RVkA
-
Config: Add the wrap_value migration op to every engine
A bare scalar stored where the manifest keeps a feature row (the macOS
writer'shotstrings.dynamic.date = true) cannot be rewritten by the
existing op kinds: map_value compares and writes scalars only, and a
rename into [section.key] would also move a value that already is a
table. wrap_value moves a present scalar into its own [section.key]
table under to_key and leaves a table, an array or an absent key as it
is, so a replay changes nothing.Windows config_migrate.ahk learns the op first, since it refuses an
unknown op at boot; the shared Lua engine and the JS reference follow.
The shared corpus covers the wrap, the untouched table, array and absent
keys, the replay, and two registry defects. -
Config: Add the rename_prefix migration op to every engine
The schema v15 slot renames cannot carry the per-slot action parameters
(keys keyboard____) with the existing op kinds: each would
need one rename per slot and parameterized action. rename_prefix renames
every key of one section that starts with a prefix, keeping the
remainder. A target already held by a key or a table refuses the
migration with a named "Prefix rename refused:" detail (plan outcome
"invalid", the file is never written), and the validators of all three
interpreters reject two prefixes that extend each other, so a replay
finds nothing left to rename.Windows config_migrate.ahk learns the op first, since it refuses an
unknown op at boot; the shared Lua engine and the JS reference follow.
The shared corpus covers the rename, untouched keys and sub-sections,
the replay, a key and a table collision, and four registry defects. -
Windows: Start developer mode from a double-clicked ErgoptiPlus.ahk in a clone
Without ERGOPTI_DEV_SOURCE_ROOT, a source run derives the clone root from
its own folder and accepts it only for a Git checkout of this repository
(.git and the shared identifiers). The explicit variable still wins and is
validated as before; a compiled build and a tree outside a clone keep
today's behaviour. The startup line names where the root came from. -
Windows: Run ErgoptiPlus.ahk from a clone in developer mode
tools/dev/run-windows-dev.ps1 asks a running ErgoptiPlus to close (it holds
the single-instance mutex), then starts the clone's ErgoptiPlus.ahk with
AutoHotkey v2 x64 and the shared developer variable. The vendor helpers are
the files the exe extracts, so the run uses the same native pieces.infra/dev_source.ahk accepts only a root naming the running script and, in
that run only, polls the driver's .ahk once a second: a change reloads through
ReloadPreservingSuspend, the tray's Reload path, after one quiet poll. A
developer run also reads %APPDATA%\Ergopti\paths.toml like the exe; a source
run used the clone's _generated copy and so did not see the user's chosen
configuration folder. -
Linux: Run the daemon from a clone in developer mode
tools/dev/run-linux-dev.sh starts the clone through install/launch.sh, the
packages' entry point, with the tray, names the clone in the shared developer
variable (dev_source.environment) and builds the native archive backend into
bin/ when it is missing or older than its sources. It stops an installed user
unit first, since two daemons would both grab the keyboard.infra/dev_source.lua accepts only a root naming the running driver. In that
run a saved .lua of the driver or of _shared/lua restarts the daemon after the
watcher's settle window (the watcher used to watch only top-level files and
re-read the hotstrings, which cannot load new code), and the tray's Reload
relays the developer runner: a checkout has no install-layout wrapper, so
daemon_restart refused and Reload failed outside the systemd unit. -
Diagnostics: Prefill the bug form with the compact report and a true version and system
Debug > Report a bug (issue #104) sent the version "unknown" on a source run
and the driver name "macos" as the operating system, and left the report to
be pasted by hand into "What happened?".Design note. Root causes: the share projection dropped the source-run
version "local" and any system name that is not a bare version (Windows
"11 Pro 26100.9457", Linux PRETTY_NAME), then the document filled the form
from "unknown" and from the driver id. Decisions: the approved report goes
into the diagnostics field of the URL as its compact markdown tables (no JSON
block), cut last-field-first to max_url_bytes with the existing marker plus
one note line saying the complete report is on the clipboard and in the saved
file; the builder takes the note per field and counts it in the budget
(JS, Lua and AHK ports, replayed on new shared vectors). The version of a
source run reads "dev source "; the system reads the real name and
version then (desktop, session, architecture), through a new closed
platform_name projection (known families plus a name-character grammar, so
a path or an unlisted name is still dropped) implemented in the three ports.
The form gains an optional day-log textarea that is never prefilled, loses
the consent checkbox, and its descriptions no longer hold ": " (GitHub
refuses such a plain scalar; the template test now checks it). issue_templates.json,
test-issue-templates.cjs and the shared vectors change with it, and the new
form_identity_vectors.json is replayed by the macOS, Linux and AHK suites. -
Llm: Run only Ergopti's own pinned Ollama on its own port on macOS
The maintainer decided that the Ollama Ergopti uses is installed and pinned
by Ergopti, never the user's own (issue #104). On macOS the resolver
preferred a user's Ollama (Ollama.app, Homebrew, PATH), selected it as a
client and pulled through it, which failed with 412 because that Ollama was
older than the model required; Ergopti's daemon also shared the stock port
11434 with it.- The resolver names only Ergopti's own pinned runtime. A user's Ollama is
never selected, reused, run, stopped or modified; it is named only in a log. - Selecting Ollama installs Ergopti's own runtime on consent (the install
button counts as that consent). The offer names the pinned version, read
from managed_ollama_runtime.json, and says it runs beside the user's. - Ergopti's daemon listens on llm_ollama_owned_port (11435), a new key in the
shared defaults; an owned override on the stock port is refused. A former
llm.ollama_port override of 11434 is dropped, and any other value moves to
the owned key, logged once. - The notices say "Ergopti's own Ollama" in every locale.
Linux and Windows are unchanged; the defaults carry the TODO to move them.
- The resolver names only Ergopti's own pinned runtime. A user's Ollama is
-
Macos: Name both causes when the direct try after a PAC fails
The shell admission now goes direct when the company PAC cannot be used.
The Lua callers read the stage from its marker, against the shared
proxy_policy.json list, so a model pull that still fails tells the user
that the proxy auto-config was unreachable and the direct connection
failed too (network.failure.pac_unreachable_direct_failed, all locales),
and a pull that works logs that the direct route worked.The Debug network check reports a route plan lookup at a fallback stage
as direct information instead of a failure, and the PAC diagnostics
probe says that the downloads then try a direct connection. -
Macos: Name the PAC stages that let a download go direct
The maintainer decided that a download behind an automatic proxy
configuration tries the PAC/WPAD route first and goes direct when the
PAC cannot be fetched or evaluated. proxy_policy.json now owns that list
(pac_unavailable_fallback), with the two exclusions: an explicit
inherited relay never falls back, and a relay the PAC names that then
fails is never retried direct.The native worker gates its existing direct fallback on the generated
list, and the bootstrap spool prints one warning naming the stage, then
whether the direct connection worked or both causes when it failed. -
Dev: Drive the developer app's field run from the field report collector
The collector opens the developer app of this clone when it is not running
(building it on a first run), writes the field run request, prints each step
result as it lands, waits for the done marker within 15 minutes and copies
the results into the report before committing it. A refusal or a timeout is
recorded as the field_run step and never hides the other steps. A JS test runs
the real collector against a recording driver and pins the request and result
contract to data/field_run.json. -
Macos: Run every Debug check and AI flow on a developer field run request
A developer ErgoptiPlus.app watches /build/field-run (declared in
data/field_run.json) and, on a request, runs the Debug field checks, the
diagnostics snapshot, and the Ollama and MLX runtime and tiny model flows
through their real owners. Each step writes one JSON result with its
verdict, closed codes, timings and log window under its own deadline, then a
done marker. The key accounting check is reported as needs_typing: synthetic
keys would enter the driver's own pipeline. The configuration is snapshot and
restored, a downloaded model is removed when it was absent, and the watcher
starts only in developer source mode. -
Macos: Expose the AI menu's model download and removal entries per backend
The developer field run downloads the catalogue's smallest Ollama and MLX
models through the menu's own owners whatever backend predicts, then removes
them. The models manager now names those entries per backend, and the AI menu
exposes its committed manager. -
Linux: Translate the shell scripts' sentences through a generated catalogue
The Linux shell scripts (install.sh, uninstall.sh, the launcher and the
install/ helpers) printed 116 sentences typed in French or English: the
largest block of the UI-string gate's baseline. They could not use the
i18n catalogue because they run before LuaJIT, jq or Python can be
assumed (install.sh is what installs them), and the catalogue is JSON.Design:
- Source of truth:
linux_shell.<script>.<name>keys in
_shared/data/locales/*.json, translated in all 21 locales like every
other string and held to the same parity, allowlist and unused-key
gates. English is the source language. - Projection: tools/codegen/codegen-linux-shell-messages.cjs writes
linux/_generated/shell_messages/.msg, onename=valueline per
message with the prefix removed. It stops before writing when a value
spans lines or when a translation's {1}..{9} placeholders differ from
English. It is registered in tools/build/generators.cjs, so gen:check
catches a stale catalogue, and linux/_generated ships whole in every
package. - Reader: linux/install/messages.sh, sourced by each script. It selects
the language with the POSIX precedence LC_ALL, then LC_MESSAGES, then
LANG. C, POSIX and an unset environment select English, and nb or nn
select no. A language Ergopti does not ship selects the English
catalogue by name. That is a declared choice made once per process,
not a per-message fallback: a key missing from the selected catalogue,
or a placeholder without its argument, prints nothing and returns 2
with the key on stderr. The catalogue is loaded when the file is
sourced, so a missing catalogue stops the script there. - API:
ergopti_msg <name> [args]prints a line;
ergopti_msg_with <prefix> <name> [args]prints layout (indentation, a
status mark) before it;ergopti_textprints without a newline for a
capture. Placeholders are filled in one left-to-right pass, so an
argument holding "{2}", "&", "$" or a backslash is printed verbatim.
This first slice converts the checkout launcher (linux/bin/ergopti-hotstrings,
7 sentences). install.sh, uninstall.sh and the install/ helpers follow in
their own commits. The reader's two self-diagnostics are allowlisted in the
gate, because no catalogue can translate a missing catalogue.
build-linux-driver.sh requires the reader and the English catalogue.
test-driver-tree-parity records the new Linux-only
linux/_generated/shell_messages directory (union 62 -> 63) with this reason.Windows and macOS: no counterpart. Their installers are not shell scripts
that print to a terminal; this mechanism exists only because these
scripts run before the driver's own i18n is available.Regression test: tools/test/test-linux-shell-messages.cjs (js-linux-shell-messages)
runs the real reader under bash: language precedence, C and unknown
languages, nb/nn, verbatim placeholders, prefix, a missing key, a missing
argument and a missing catalogue. It also checks that every message call
of a script names an English catalogue key and that every key is printed.
It fails on the previous tree, where no reader or catalogue exists. - Source of truth:
-
Linux: Make the number-row menu rows live where the session applies them
The Linux menu draws the ordering and the edge-key switch as live rows when
the session loads a layout live and an ErgoptiPlus layout exists: a choice is
stored and applied through the layout manager, a refusal after the store is
told. Where it cannot, the rows stay drawn and greyed with the stored choice
still shown and a reason that says what the session does instead (only X11
applies it live) or what is missing (an installed layout). The shared policy
module gains live_rows next to the native read-only rows macOS keeps; a
contract replays both on the real renderer. Three texts are added to the 21
locales: the two reasons and the notice for a refused application. -
Linux: Store the number-row options and apply them through the layout manager
number_row_preferences owns the two [layout] leaves (ordering and edge
keys) with sparse writes and outdated-shape handling, and keeps them out of
the unused-key cleanup. number_row_live stores a choice first, then asks the
layout manager to apply the stored options, only where the session loads a
layout live and a layout of ours exists; a stored choice the session cannot
apply stays stored and reports session_unsupported or no_source_layout, and a
refused load hands its closed code to the caller. -
Linux: Apply the number-row options to an installed layout live on X11
apply_rows converts the layout again with the options, installs it in the
user XKB tree (allowing Xorg because the layout is loaded here) and loads
it into the running server with apply-x11. A session that cannot load a
user layout live (Wayland, a console, none declared) is refused before
anything is written, no layout to apply them to and an uninstalled one have
their own codes, and the closed code of a load the server did not keep is
handed on. Tests replay the manager with the real Ergo-L: the converted file
holds the options, the installer argument vectors, every refusal, the free
slot after a failure and an inert choice kept with its reason. -
Layouts: Apply the number-row options in the shared XKB converter
convert takes rows { order, edges } and trades the unshifted and Shift
cells of the digit keys (and the three edge keys on request) by the rule
Windows applies and the composer already follows (layout/number_row_policy.lua),
leaving the Ctrl/Alt/Win and AltGr levels alone; Caps takes what the key
typed with Shift and Caps, Shift+Caps follows Shift. The report says the order
in effect and why an inert choice changed nothing. A differential gate
converts every shared case two ways, the plain layout with the options and the
layout the composer compiled with them, and requires the same XKB and XCompose,
the options applied twice to change nothing, and the hand-written Windows
outcomes at the unshifted and Shift levels. -
Linux: Replace the running X11 keymap from the user XKB tree
New apply-x11 command and apply_x11_user_tree: the layout's keymap is
printed with setxkbmap, its types section pointed at the layout's own
types file (setxkbmap does not bind them from the user rules), and
compiled into the running server with xkbcomp -I , with no root and
no logout. The result is read back from the server and a closed code
reports not_x11, no_display, tool_missing, compile_failed or not_loaded.
install takes allow_xorg for a caller that loads the layout itself; the
default refusal is unchanged. Tests run the real Ergo-L and Ergopti
conversions on an Xvfb server started with -noreset: three consecutive
replacements, a rewritten layout without a new name, every refusal code and
the command line. -
Composer: Apply the number-row options in the composed layout
The declaration may carry rows { order, edges }, validated and stored with
the composition. compose trades the base and Shift cells of the digit keys
(and of the three edge keys on request) by the rule Windows applies with
hotkeys, dead keys with their state machines, leaving AltGr and the
shortcut maps alone, and reports an inert choice with its reason. The rule
is replayed against the shared number-row policy corpus and against
hand-written outcomes in Windows terms (scan code, modifiers, Caps, Ctrl,
dead key). -
Build: Let a caller own the Linux build output directory
The driver, deb, rpm, flatpak and AppImage builders wrote only to
/build/linux, so tests that build side by side collided on one
folder ("Copy destination already exists" under parallel load).
ERGOPTI_LINUX_BUILD_DIR (absolute) now selects the output directory of all
five; unset, nothing changes. The driver builder passes it to the tracked
copy, which accepts a destination outside the repository only below the
owner named by ERGOPTI_COPY_DESTINATION_ROOT, and to the native backend
build (--output-directory). The tracked-copy test pins that an unowned
outside destination is refused and the owner replaces the repository as the
allowed tree. -
Dev: Run the three input-source probe routes in the field report
The collector now runs the probe plainly, with --write-prefs (the driver's
own enabling route through com.apple.HIToolbox and a SystemUIServer
restart, both restored) and with --replace-ergopti --write-prefs (a layout
added inside the enabled Ergopti bundle, backed up and restored), so one
command answers the live-replace questions. -
Diagnostics: Probe the driver's own enabling route and an in-bundle layout on macOS
The second Mac run showed that TISEnableInputSource answers 0 but leaves a
registered layout disabled, so selecting it answers -50, and that a bundle with
a made-up identifier is not listed. Version three shapes the probe bundle like
the shipped Ergopti bundle, tests the driver's own route (preferences edit and
SystemUIServer restart, restored at the end) behind --write-prefs, and with
--replace-ergopti adds a layout under a new name and id inside the installed
bundle that holds the current source, backed up first and put back at the end.
The design note records both runs and what they already decide. -
Dev: Collect a macOS field report and push it in one command
The maintainer runs one command on the Mac: it brings dev into the field
branch, gathers today's logs (gitignored, so added with -f), their boot,
slow-segment, error and input digests, system and toolchain facts, the
input-source probe output, records every step's exit status, then commits
and pushes the branch for the coordinator to read. -
Diagnostics: Probe the ways around the refused input-source selection on macOS
The first run on a Mac showed that registering a loose keylayout works at
once and a rewrite stays cached, but selecting the fresh source answers
-50. Version two draws numeric ids outside every installed layout, prints the
source's select and enable capabilities, selects at once, after a run-loop
turn and after re-reading the list, installs the same layout as a bundle,
and with --write-prefs enables it through the user's preferences, restoring
them. One verdict line per question, with the same cleanup. -
Diagnostics: Add the macOS TIS register probe for the live-replace questions
A one-file Swift probe that registers, enables, selects, rewrites and
retires two throw-away keyboard layouts and prints one verdict line per
question M1 to M6 of the layout-option design note, then restores the
user's input source. It was written without a Mac or a Swift toolchain
and has not been compiled yet; the note says so and gives the command. -
Macos: Build a host-only launcher on Command Line Tools for local apps
A two-slice swift build needs Xcode's XCBuild, so a Mac with the Command Line Tools alone failed with 'xcbuild executable ... doesn't exist'. ERGOPTI_LAUNCHER_ARCHS=native builds and verifies only the uname -m slice and logs that the app is single-architecture; universal stays the default. A release refuses native, and any other value is refused. The developer app runner defaults to native.The CI package build pins ERGOPTI_LAUNCHER_ARCHS=universal and ERGOPTI_DEV_SOURCE=0, and the installed-archive step now verifies both launcher slices with lipo -verify_arch instead of only printing them. The other helpers (swiftc per target, clang -arch for LuaSocket) already build both slices without Xcode.
-
Macos: Replace the stock Hammerspoon bootstrap with a developer app runner
tools/dev/install-hammerspoon.sh made a stock Hammerspoon load the clone, which the instance admission now refuses. tools/dev/run-macos-dev-app.sh (npm run dev:macos-app) builds ErgoptiPlus.app in developer source mode and opens it, or with --open-only reopens a developer app only when it records this clone. It refuses off macOS and beside a running ErgoptiPlus, which the launcher would activate instead, and names a retired bootstrap init.lua without deleting user configuration.Its test runs the real runner from a fixture clone with recording shims and replaces the retired installer's.
-
Macos: Run a developer app's clone as a source run
A developer launcher hands its runtime the clone root. infra/dev_source.lua admits it only when it names the very driver that is running and raises otherwise, so the instance admission treats a mismatch as a foreign Hammerspoon. Updater.is_local_source() answers true for it: the embedded bundle id alone no longer makes a clone look packaged, so update checks, channel switches, in-app installs and uninstall stay off, and the managed runtime is still admitted.What the clone lacks comes from the app itself: LuaSocket through a searcher that serves only socket and socket.core from the payload, and the lease helper from the runtime bundle, with the same exact identity proof. The About commit comes from .git, as for any source run. A watcher on the clone's _shared/lua tree, armed only in this mode, joins the existing source watcher so a saved shared edit reloads too. The launcher README documents the mode and no longer claims that direct developer sessions are supported.
-
Macos: Point a developer launcher at its clone and start no Sparkle
LuaSourceSelection decides once where the embedded Hammerspoon reads its Lua: the signed payload, or the clone root a developer build recorded in Info.plist. A key that is not a plain absolute path, or a clone without init.lua, stops the launch with a dialog naming the problem instead of running the payload. A developer launcher logs its source root, never constructs Sparkle so no update can replace the app, and hands the root to the runtime; an inherited root is always removed so a packaged runtime cannot be told it runs from source.XCTest covers every selection branch and the child environment; it compiles only on macOS (CI launcher cells or swift test on the maintainer's Mac).
-
Macos: Build a developer app that runs the clone's Lua, never shipped
Since 0c954ed49a and a7bdb88a44 only the launcher's embedded runtime with the managed log environment may run the driver, so a stock Hammerspoon pointed at a clone stops at the native logger refusal. ERGOPTI_DEV_SOURCE=1 now records the clone root in the launcher Info.plist (dev_source.plist_key) before codesign and returns before any release archive is made.A release build refuses the switch, an unknown value is refused, and every entry of the archive producer CLI (archives, CI receipt, CI install) refuses a bundle that carries the key or whose plist cannot be proven free of it, so CI can neither publish nor launch-gate a developer app.
-
Shared: Declare the macOS developer source-mode names once
A developer build of ErgoptiPlus.app will run its Lua from the repository clone. The launcher Info.plist key that carries the clone root and the environment variable the launcher hands its runtime are read by the build script, the release archive producer, the Swift launcher and the Lua driver, so they join the generated identifiers instead of being spelled in each. -
Logger: Add the closed adapter error taxonomy to the shared vocabulary
PAR-84. Plan: about 50 Lua reason and phase literals are sentences that
other code compares (process runner and shell runner against the owned
process errors, the Linux hotstring output phases, the connector and
startup reasons on macOS). A matcher must read a code, not a sentence.
vocabulary.json, beside the closed enums and projected into contracts.json,
gains an adapter_errors section: 40 reason ids and 6 phase ids, each with a
developer description; the ids are also members of the closed reason and
phase enums, and the hotpath. phases of the declared hot-path
segments join the phase enum. A generator writes _shared/lua/adapter_errors.lua,
a strict read-only table of constants that raises on an unknown name.
Words a user reads still come only from the locales.This commit adds the vocabulary and the module; the producers and matchers
move in the following commits, then a gate refuses a new free-form reason.
A shared contract replays the module on both drivers. -
Llm: Add the agent admission decisions to the shared request plan with replay vectors
Design note (PAR-65, agent flows). The ports, token budget, info bar and live
prompt are already in the plan (34ff85e515, d73504b14d, c9ba504441, 2a641a8e2f);
the TODO line is stale on them. What the two drivers still decide twice is the
agent: the macOS runner (admit, auto_blocker, on_pause) and the Linux engine
(agent_refused, agent_blocked, agent_pause_elapsed). The plan now holds the
three pure questions: which refusal a manual action meets first (paused, AI
off, mode off, System 2 unusable, focus refused), what blocks the automatic
mode's triage in order, and whether a sentence is worth triaging. Facts in,
step out; notices and log wording stay with the drivers. 22 vectors are replayed
by both drivers. The next two commits re-point the macOS runner and the Linux
engine to it. -
Linux: Add the config-only keyboard layout scope participant to the global restore
Design note (TODO item 5, second half, option a). The Linux global restore
skipped the manifest's keyboard_layout category. A participant now restores or
clears the layout rows of config.toml through the shared scope transaction, with
a verified backup, an exact inverse and retained debt, and takes part in the
composition. It is config only on Linux: no runtime reader yet, so its native
receipt is empty and the code, the owner's header and the manifest comment say
so. The macOS owner applies the same rows to its live menu state.Option (b), for its own TODO item: make the rows live. The keyboard hook takes
its number-row mode only in keyboard_hook.start(opts), called once inline in
main() with about fifteen callbacks, and the daemon passes none; nothing reads
number_row_reorder_edges, and Reload restarts the whole daemon. A live
participant needs (1) the daemon to read layout.direct_access_digits and
number_row_reorder_edges from config.toml and pass them at hook start, (2) a
hook stop-and-start owner that rebuilds the start options and reports started
again, which is the participant's acknowledgement, and (3) a decision on keys
typed while the hook is down and on the hotstring buffer, since stop() closes
the grabbed devices and there is no existing guarantee to reuse. Restore then
restarts with the captured values and stays owed until acknowledged. -
Hotpath: Attribute nested slow segments to their parent on macOS and Linux
A slow segment was judged and reported on its wall clock alone, so a 12 ms
expansion inside a keystroke printed two slow lines (the expansion and the
dispatch or keydown around it) that billed the same 12 ms twice, and the
enclosing line could not say which of its parts spent the time.The shared inventory (hotpath_segments.lua) gains new_nesting(): every span
opens a frame and closes it, a segment is slow on its own time (wall clock
minus the spans closed inside it, against the 5 ms [logger]
hotpath_slow_ms), and a slow segment is reported once its nearest enclosing
declared segment is known, children before their parent. Spans that are not
declared segments (idle wait, tray build) are transparent, and a frame left
open by a raise closes with its enclosing frame.Linux perf_counters: clock() opens a frame, span() closes it; the .slow
counts follow the own-time verdict, the n/total/max figures keep the wall
clock. macOS hotpath_profiler: now() opens, log_if_slow() closes. Both log
one closed-vocabulary HotPath WARNING through the central logger:
"Slow : ms own, ms total, parent <segment|none>".
The macOS line no longer carries the typed characters, the buffer or a
free-form detail, which the inventory already forbade.Vectors: _shared/tests/corpus/diagnostics/hotpath_nesting_vectors.json,
replayed by both suites through test/hotpath_nesting_replay.lua; driver
tests prove a slow child inside a fast-own parent yields one line naming
the parent. Both fail before this change.Windows counterpart (not changed here): infra/hotpath_profiler.ahk
HotPath_LogIfSlow already computes the nested time (_HotPathNestedMs) but
still judges on the wall clock and names no parent. -
Hotstrings: Add a pure shared injection plan with replay vectors
Design note: both hotstring engines decided separately how many codepoints to
erase, what to type and which terminator to replay. The plan module answers
that in one place without I/O or timers: erase, type, then replay the tail.
The only driver-dependent input is event_on_screen (Linux always true; macOS
false unless the window is ignored), from which the erase count follows. The
vectors cover the ignored window, the extra no-break space, the cascade net
edit, consumed and replayed terminators (return, tab, text) and the clamp at
zero. No divergence between the drivers exists today. Engines are re-pointed
in the next commits. -
Linux: Verify the detached asset signature in the updater transfer
The transfer fetches .sig next to the checksum and asks the new signature gate after adoption, with the sealed size and verified SHA-256. A refusal retires the adopted archive through the normal failure path.Enforcement comes from the one shared policy (asset_signing.json), default off: an unsigned release still installs and the reason is logged in words. In required mode a bad, missing or undownloadable signature is refused in words. A broken policy refuses instead of skipping the check.
No separate warn value: with off and a key provisioned a bad signature is already reported without blocking, and a new policy value would not be admitted by the Windows verifier that reads the same file (AHK is out of scope).
macOS needs nothing beyond Sparkle's EdDSA: the appcast path is verified by Sparkle, and the in-app release installer checks the GitHub API digest and the staged app's code-signature requirement.
-
Fs: Spec the lease and atomic write of the file-system owner
PAR-76 R33. Plan: FileSystem.spec.js pins five plain operations by arity
only, while the config migration, the writers and the recovery journals
call a larger surface by name. A shared port module in the style of
llm/request_plan/ports.lua now lists it: the required classified read,
conditional publication (plain and admitted) and configuration_ports, plus
three optional capabilities an owner offers whole or not at all
(ConditionalRemoval, WriteLease, PublicationReceipts). check refuses an
owner missing a required method, check_capabilities refuses a capability
offered by halves, and check_owner does both.Both Lua owners (macOS and Linux adapters/file_system.lua) check themselves
against it as the last statement before returning, pinned by a source gate.
macOS offers all three capabilities; Linux offers none, because it
publishes through toml_codec.writer inside the single-threaded daemon, so
it holds no lease and issues no receipt. A shared contract replays
file_system/port_vectors.json on both drivers and checks each real owner
against the capabilities declared for it.Left for the PC: the AHK FileSystem owner is to offer the required names
and declare its capabilities in the same corpus. -
Linux: Refuse a foreign program loader or a staged C library in the AppImage
Approach: keep the recipient's glibc and loader and build on the oldest supported LTS (ubuntu 22.04, glibc 2.35), not bundle a second glibc. The host GTK, GL and NSS modules must agree with the loader that starts them, so a bundled loader would have to be newer than every host library, and a bundled libc breaks them; the existing symbol-version ceiling, closure check and plain-sh AppRun preflight already implement that. What was missing was a gate on the loader itself: the ceiling check now also refuses an executable whose requested loader is not /lib64/ld-linux-x86-64.so.2 (a musl or Nix build host) and any staged libc, libm, libpthread, libnss_*, ld-linux or ld-musl file, with the names taken from the glibc group of the recipient list. The test pins the rule with fake loaders and staged files, and pins that the README and the AppRun refusal text say plainly that musl hosts cannot run the image and that glibc 2.35 is the minimum. Only CI proves the distro matrix. -
Linux: Adopt role-named keyboard slots
PAR-41 step 6. Slot prefixes come from the shared role table (primary_, meta_, alt_ and their Shift forms); the shared native-publication contracts expect each driver's own prefix and slot count. -
Macos: Adopt role-named keyboard slots and retire the Ctrl-letter switches
PAR-41 steps 5 and 8 (macOS). Slot prefixes and the slot-only Caps Lock and apostrophe keys come from the shared role table; the 18 fixed Ctrl-letter and Cmd+Shift+V hotkey factories, their registry rows and menu rows are deleted, since the same chords are ordinary slots now. Tests follow the new ids. -
Shortcuts: Declare the role-named keyboard slots in the features manifest
PAR-41 step 3. The macOS Ctrl-letter switches and Cmd+Shift+V become shortcuts.keyboard slots (secondary_*, primary_shift_v) with their catalogue action as the recommended choice; the shipped prediction and ChatGPT slots take their role-named ids. Generated outputs are regenerated; Windows only gains the other-platform list entries. -
Config: Migrate keyboard slots and Ctrl-letter switches to role-named slots
PAR-41. Schema v15 renames every macOS and Linux slot id to its role-named id and turns each retired macOS Ctrl-letter switch into the slot and catalogue action of the same chord, with the closed op set only. A slot the user set wins over the switch. The per-slot action parameters of renamed slots are not renamed: that would take one op per slot and action. -
Shortcuts: Give the slot role table per-driver families and slot-only keys
PAR-41 step 2/3. A driver's accepted role combinations (the slot prefixes, in menu order) and the keys it accepts in slots beyond modifier_chords.json (Caps Lock and the apostrophe on macOS) now come from one table, replayed by the shared contract. -
Network: Show which route each Ollama origin takes in the network check
The Debug > Test network check reported the route of one feed request, but the
Ollama daemon pulls from two origins (the registry and its blob host) and holds
a single relay: behind a PAC that sends them different ways the daemon refuses
to start, and nothing in the report said so.The shared check has a new route_plan step, after the chain, on macOS and
Linux (the Windows list stays the seven steps its parity gate pins). Its port
looks up each origin of route_origins.json "ollama" one after the other, as the
daemon's start does - Linux through the GIO lookup and the shared proxy policy,
macOS through the launcher's route command and the shared lookup budget - and
the shared module judges the answers with network/route_plan. The value is a
closed token per origin (registry:direct+blob:relay), never an address; a plan
the daemon would refuse fails the step with the plan's closed reason
(route-plan-conflict, route-plan-unplanned-host) and a failed lookup with its
native stage or reason.The step label is localized in the 21 locales; the parity gate checks the macOS
and Linux only step apart from the Windows list and its labels.Tests: both drivers cover direct, mixed, conflicting and failing plans, the
privacy of the value, the macOS port's ROUTE and STAGE parsing, and that the
report names as many origins as the shared inventory holds. -
Shortcuts: Add the neutral slot role table and its resolver
PAR-41 step 2. Roles (primary, secondary, alt, meta, shift) name what a modifier does; the table maps each role to every driver's modifier, is projected to shared Lua by a generator, and resolves slot ids to native chords and back. -
Linux: Inject the HTTP client into the prediction engine and check its ports at init
The Linux engine already took its scheduler and overlay at init, but silently
fell back to the real scheduler when the one given was not a table, and the
remote client always used the HTTP adapter it required.engine.init now checks the scheduler (real one by default), the overlay when
given and an optional http_client against llm.request_plan.ports and raises
on a missing method, naming it. An injected http_client is handed to the remote
client, which uses it for inference requests and their cancellation (model
listing and Ollama's streaming client keep their adapters). The two test
fixtures that passed an overlay without show now give a complete one. -
Macos: Inject the timer and HTTP client into the prediction engine and check its ports at init
The macOS engine armed its debounce on the timer adapter it required and the
backends built their own inference HTTP clients, so a test could only watch
them by patching modules in place.engine.init(state, ports) now takes an optional TimerScheduler and HttpClient
(the real owners by default, as on Linux). Each given port is checked against
llm.request_plan.ports, and so is the prediction tooltip wrapped as the
TooltipRenderer, once at init: a missing method fails at wiring, not inside a
timer or a network callback. An injected HttpClient is handed to the inference
path of the Ollama, MLX and remote backends through core_llm.set_inference_client.The tests that initialised the engine on partial stubs (a timer scheduler
without cancel, a tooltip without show_predictions) now start from the complete
doubles of tests/support/port_fakes.lua. -
Macos: Give the prediction tooltip a TooltipRenderer adapter
The engine and the streaming handler drew predictions through five calls of
tooltip.show_predictions with twelve positional arguments each, so nothing
else could stand in for the tooltip and a test could not watch what the
engine asked it to draw.adapters/prediction_renderer.lua wraps the tooltip as the TooltipRenderer port
of the shared request plan (show, hide, is_visible); show takes one named view
and spells out the positional call, returning the tooltip's verdict unchanged.
The five sites build a view and call the port; the streaming handler builds its
renderer on first draw so an init with a tooltip that cannot draw keeps its own
refusal. No behaviour changes.Tests: the adapter's mapping of every view field to its position, the refusal
of a tooltip without show_predictions, and the macOS contract replay now checks
this owner against the shared TooltipRenderer contract. -
Layouts: Convert a canonical keylayout to XKB and XCompose in shared Lua
PAR-76 follow-up. layouts/xkb_converter.lua is the conversion rules of
keylayout_to_xkb.py on the canonical form (layouts/keylayout.lua): the
seven XKB levels from the layout's own modifierMap, keys through the shared
keycode table, dead keys and chained dead keys, borrowed keysyms for
multi-character outputs, XCompose lines in the Python converter's order.
Pure: the keysym table, symbols template, keycode table and the layout's
XKB hints are injected; a layout it cannot convert is refused with a reason.- tools/test/test-keylayout-canonical-parity.cjs now compares the Lua
converter with the Python one: byte-identical .xkb and .XCompose for the
five registry layouts, the Ergopti bundles, the Kalamine archive and the
composer corpus layout (34 files), or both refuse the file (9 do, the
Ergopti++ ones among them). The previous check (Python reading the Lua
parser's JSON) goes: nothing hands the converter that file any more. - Shared contract plus vector (xkb_conversion_vectors.json: dead keys, a
chain, a multi-character output, hints; expected files are Python's)
replayed by the macOS and Linux Lua suites, with refusal cases.
Nothing calls the Lua converter yet.
- tools/test/test-keylayout-canonical-parity.cjs now compares the Lua
-
Layouts: Add the canonical keylayout form and one Lua parser for macOS and Linux
PAR-76 (c), R23. A .keylayout was read four times (AHK parser, the Python
converter, the JS composer, and nothing on macOS). This defines the
canonical parsed form (schema 1, documented at the top of
_shared/lua/layouts/keylayout.lua) from the richest existing intermediate,
the Python converter's Keylayout: key maps by modifier index with
baseMapSet chains, key code to output or action, actions with their
dead-key states and next states, terminators, and the modifierMap selects
with folded modifier tokens. Absent fields are omitted, never null.- layouts.keylayout (shared Lua) parses to that form. It reproduces the
Python reader's rules exactly: first duplicate wins, except a repeated
baseIndex and a repeated modifierMap id, comments are dropped, entities
decoded, empty outputs omitted. - keylayout_to_xkb.py gains Keylayout.to_canonical / from_canonical and
convert_layout (convert is unchanged for callers) and a --canonical
input for the CLI. - Corpus _shared/tests/corpus/keylayout: hand-checked edge fixtures with
the canonical form they must give (and four refused files), and the real
composer layout with its pinned form. Replayed by the macOS and Linux Lua
suites and by the Python converter tests, which also prove that
converting through the form writes the same XKB and XCompose as
converting the file, for every registry layout.
The JS composer and the AutoHotkey parser replay the same corpus when they
adopt the form; nothing here changes their behaviour. - layouts.keylayout (shared Lua) parses to that form. It reproduces the
-
Llm: Define the HttpClient, TimerScheduler and TooltipRenderer ports of the request plan
The plan only decides; the engine arms the timers, sends the requests and
draws the tooltip. llm/request_plan/ports.lua writes down what the engine
needs from those three services (method names with their signatures) and
checkrefuses an owner that lacks one, so a missing method fails when the
engine is wired and not late inside a timer or a network callback.Both drivers' request plan contract tests check their real owners against
it: the timer scheduler and the HTTP client on macOS and Linux, and the
graphics renderer on Linux. macOS has no owner of the TooltipRenderer shape
yet (tooltip_llm draws through show_predictions with the text line as one
argument), so the port is left out of its replay and listed as the open
item rather than faked. -
Llm: Decide what a live keystroke asks for in the shared request plan
Live mode was resolved twice. Linux validated live.json strictly and macOS
accepted numeric strings and floored a fractional minimum word count. After a
live prompt was deleted, Linux stopped live mode and asked for nothing while
macOS stopped it and then ran that keystroke once with the menu's prompt.llm/request_plan/live.lua validates the decoded file (config) and decides one
keystroke (decide): the receipt's own count else the menu's, live.json's
minimum word count, or a stop when the prompt no longer exists. The corpus
request_plan_live_vectors.json pins it for both drivers and records the two
convergences with their reasons: live typing never falls back to a prompt the
user did not pick for it, and the file is read strictly everywhere (the
shipped file is valid, so nobody sees a change).Regression: a macOS end-to-end test deletes the live prompt and expects no
request after the stop; it fails on the old fallback. -
Llm: Decide the info bar pieces and the prompt's short name in the shared request plan
The line under the predictions was assembled twice. macOS cut a prompt label
at its first em dash (user prompts included); Linux cut only the translated
built-in labels and showed a user prompt's label whole.llm/request_plan/info_bar.lua returns the ordered pieces (model, backend,
short prompt name, position i/n) and owns the short-name rule; each driver
joins them with its own separator. The corpus
request_plan_info_bar_vectors.json pins the order and the rule and records
the deliberate differences with their reasons (separator, i/n position,
backend label, a model required on macOS) and the one convergence (the label
cut, now macOS's on both).Regression: a Linux overlay test feeds a user label with a long suffix and
expects the exact bar text, which fails on the old whole label. -
Llm: Decide the output token budget of a call in the shared request plan
The output token limit of one backend call was computed in four places with
two rules: macOS (Ollama, MLX, remote) gave a batch of n variants
n * (base + 5), Linux gave n * base. A batch of three on Linux could
therefore be cut short on its last variant where macOS and Windows completed
it.llm/request_plan/token_budget.lua owns the rule (for_call, plus for_retry
for the retry policy's extra room) and the corpus
request_plan_token_budget_vectors.json pins it, replayed by both drivers.
Linux converges to the +5 per variant of macOS and Windows; the written
reason and what the AutoHotkey engine must do are in the corpus.Regression: the Linux batch prompt test asserts the posted max_tokens is
2 * (base + 5) and fails on the bare product. -
Codegen: Generate the section and dynamic-table config keys code already spells
The generated KEY table held only schema properties and menu-manifest paths, so
code that names a whole section (shortcuts.caps_word, shortcuts.keyboard) or a
dynamic table or timing leaf the schema cannot declare (shortcuts.tap_keys,
gestures.action_parameters, tap_holds.timeout_ms) had no constant. Every ancestor
of a key is now a key, and a small source file, config_key_extras.json, lists the
few paths no schema property or manifest row declares. The three outputs
(Lua, JS, AHK) are regenerated by the generator only. LEAF stays the leaves of
declared properties plus these paths, and the codegen test covers extras and
ancestors.The raw-literal lint learns two structural classifications instead of blessing
comments: a section's own name (tap_keys, caps_word) is not a leaf word, and a
string after LOG =, APP_NAME =, label =, name =, get_app_geometry( or Logger.x(
is a homonym, not a key. Widening the vocabulary makes 57 already-spelled
literals countable, so the baseline is raised by exactly those and lowered by the
22 homonyms the new classification frees; the Lua and shared ones are swapped in
the commits that follow, and the AHK ones are left for the PC. -
Codegen: Generate the section and dynamic-table config keys code already spells
The generated KEY table held only schema properties and menu-manifest paths, so
code that names a whole section (shortcuts.caps_word, shortcuts.keyboard) or a
dynamic table or timing leaf the schema cannot declare (shortcuts.tap_keys,
gestures.action_parameters, tap_holds.timeout_ms) had no constant. Every ancestor
of a key is now a key, and a small source file, config_key_extras.json, lists the
few paths no schema property or manifest row declares. The three outputs
(Lua, JS, AHK) are regenerated by the generator only. LEAF follows (the last segment
of every key), and the codegen test covers extras and ancestors.The raw-literal lint learns two structural classifications instead of blessing
comments: a section's own name (tap_keys, caps_word) is not a leaf word, and a
string after LOG =, APP_NAME =, label =, name =, get_app_geometry( or Logger.x(
is a homonym, not a key. Widening the vocabulary makes 57 already-spelled
literals countable, so the baseline is raised by exactly those and lowered by the
22 homonyms the new classification frees; the Lua and shared ones are swapped in
the commits that follow, and the AHK ones are left for the PC. -
Linux: Refuse an AppImage ELF file that needs a library the image does not carry
"AppImage self-contained": the image already carries LuaJIT, curl, luv, the tray
library and their closure, is built and launched on the oldest supported LTS
(glibc 2.35) and a symbol-version check refuses anything newer, but nothing
proved the other half: that every DT_NEEDED entry of every ELF file resolves
inside the image. A library the build host linked by accident would only fail at
the first launch on a machine without it.check-elf-needed-closure.cjs reads each ELF file's NEEDED and SONAME entries
(readelf --dynamic) and fails, naming the file and the library, unless the name
is a file or SONAME of the image or is on the documented recipient list,
_shared/data/linux_appimage_recipient_libraries.json (glibc and its loader, GLib
and the C++ runtime, the GTK 3 stack the tray and web views share with the
desktop), each group with its reason and the glibc baseline and the musl
refusal recorded. The C library and loader are deliberately not bundled: they
must agree with the host's GTK, GL and NSS modules, so the image is built on an
older base instead. The check runs in the AppImage build after the glibc
ceiling check and on the extracted image and packages in the install job.The install job also launches the image on the distribution sweep (Ubuntu 20.04
to 26.04, Debian 11 to 13, Fedora, openSUSE, Arch, Alpine): glibc at or above
the baseline must run it, older glibc and Alpine's musl must get AppRun's
refusal in words. The sweep script is renamed appimage-distro-sweep.sh because
the CI evidence gate rejects any "matrix." token in a workflow. The README and
the memory note state the baseline and why glibc is not bundled.Tests: a gcc-built executable passes with its library in the image and fails
with the same library left out (real readelf), injected readelf output covers
SONAME resolution, an unbundled dependency and an empty tree, the list is
validated (a reason per group, no duplicate), and the wiring is pinned. Fixtures
allocate with os.tmpdir() and never assume TMPDIR.Only hosted CI can prove that the real image's closure stays inside the list:
the real AppDir cannot be built here. -
Keystats: Add typed value slots to the query catalogue and migrate the meta statements
The catalogue held only filter-driven reads. Write statements were typed out
in each driver, formatting their values into SQL by hand with two slightly
different helpers. The grammar gains typed slots: {{s name}} renders a TEXT
literal, {{i name}} an integer (floored), and a trailing ? ({{s? name}})
renders NULL for a missing value while every other slot refuses one. The
generated Lua renderer quotes through the one shared encoder (sqlite.literal),
so a quote, a carriage return or a NUL is spelled the same on both drivers.
{{merge column}} expands at generation time into the expression that adds a
stored JSON number map to the incoming one key by key (used by the aggregate
families that follow). The generated AHK table carries slot parts and its
renderer refuses them until Windows implements typed slots; existing queries
render exactly as before.First family, meta: meta.get, meta.set, meta.upsert, meta.seed and
meta.rev_bump replace the hand-built statements of macOS sqlite_writer.lua and
log_manager.lua and Linux sqlite_writer.lua (set_meta, get_meta, the meta seed,
the open-walk record, and the revision bump). The two historical revision bumps
(macOS TEXT cast, Linux integer cast) store the same text and are now one
statement. No behaviour change.Proof: test-keystats-write-catalogue.cjs runs the legacy statements and the
catalogue on two fresh schema databases and compares the meta rows, pins the
JSON merge, the slot refusals and the compiler refusals, and pins the exact
rendered text of every write statement in both dialects to
write_query_vectors.json, which both Lua suites replay through the generated
renderer. -
Linux: Record the toml.parse, menu.rebuild and boot.stage phases the macOS driver records
PAR-54 (audit P3-P5): the Linux PhaseStats already timed every child process as
process. and the boot profile already reports CPU next to wall time per
stage, so the audit rows were partly stale (macOS has had boot CPU, as
boot.stage_cpu, for a while). What Linux still lacked were the other phases of
the macOS summary line:- toml.parse: infra/toml/reader.lua wraps the shared reader's parse (the same
module name as macOS) and the hotstring loader and config now go through it;
it resolves the shared reader at every use, so replacing that reader is still
honoured; - menu.rebuild: the daemon's tray rebuild runs the builder through
ui/menu/timed_build.lua; the builder stays untouched because its tests read it
as the real tray builder; - boot.stage and boot.stage_cpu: every closed boot stage adds its wall and CPU
time, so one summary shows what the boot cost and where.
Tests: each phase is recorded exactly once per call, the wrapper is the shared
reader's own for everything but parse, and the daemon's rebuild goes through the
timed build (all fail on the old code). The loader delegation test now accepts
the timed wrapper and checks that the wrapper delegates.Windows still lacks all of these phases: no PhaseStats owner exists there, so the
AHK driver needs one (a port of phase_stats_core.lua) plus the process.,
toml.parse, menu.rebuild and boot.stage* call sites. - toml.parse: infra/toml/reader.lua wraps the shared reader's parse (the same
-
Manifest: Explain the last unexplained platform restriction and freeze the ratchet at zero
PAR-37: the reason ratchet has counted two-platform restrictions since 236337fc2c,
so the "98 entries" of the todo were the measurement before those reasons were
written. It reports 190 real restrictions, 189 explained and one left:
llm.profiles.auto_profile_for_model, absent on macOS. macOS applies the
recommended profile on request from the Profiles menu and never follows the model
on its own, so the restriction is true however much else is finished; it now
carries platform_reason.llm_profile_follows_model_is_not_on_macos, translated in
the 21 locales (drafts: native review wanted, like the earlier reasons).The baseline and the frozen identity ledger drop from 1 to 0; the gate fails on
any restriction without a reason from now on. The 77 two-platform rows the report
lists as "hide" are canonical menu frames, boundaries and separators that do not
exist on the omitted drivers; by the gate's rule they owe no reason, and none was
trivially missing on macOS or Linux. -
Metrics: Fold every localised spelling of the default category into general
app_categories.json stores the default category in the language active when it
was written, so a user who switched language kept, for example, Arabic or
German spelling on disk and the dashboard showed it as a custom category next
to the real General one. The generated alias file already listed the nineteen
spellings but only the retired script.js read it.The alias generator now also emits _generated/metrics_general_category_aliases.js
from the same document (a page cannot read the JSON), the dashboard loads it
before helpers.js, and categoryToId folds each spelling into the canonical
'general' id, which translateCategory then shows in the current language. A page
without the data fails at load instead of folding a partial list.Regression: the vector corpus is back (one vector per shipped locale plus a
user-invented category), replayed against the live helpers.js; the generated
script is part of the freshness check; the page fixtures load the new script.
The old helpers.js resolved none of the localised spellings. -
Menu: Let Quit win over a reload and wait for a running write on macOS and Linux
The maintainer decided on 2026-10-26 that a user who quits wants out: Quit
wins on every OS. Rework the shared quit admission around five facts. A missing
owner or an exit already under way still refuses with the single
notify.quit_refused notice. A running global writer or hotstring publication is
no longer a refusal: Quit waits for it to finish or roll back and starts
afterwards, polling up to the user-quit deadline and then refusing with the
notice. A reload in flight is abandoned, never refused and never replayed.macOS: the Quit row is marked bypasses_reload, so the click gate runs it at
once instead of holding it for the reloaded menu; the coordinator's existing
reload-to-exit upgrade turns the reload into the exit, and the menu asks it
directly because run_exclusive refuses on terminal quiescence. The coordinator
gains pending_kind() to tell a reload from an exit, and hotstrings_config
splits publication_pending() from the terminal admission it feeds.Linux: quit_request.lua follows the same decision. Abandoning a reload cancels
the restart it requested: a systemd restart is replaced by a stop job, and the
relay checks a cancellation marker beside the bootstrap storage file (never
XDG_RUNTIME_DIR) after the old process exits, so no successor follows a Quit. A cancel that fails is logged as an ERROR naming
that risk and the quit goes on. The wait reads
infra/scope_registry for any pending scope write.Regression: the menu test shows Quit exits at once during a reload, waits and
then exits for a writer and a publication, and refuses only for an exit under
way; the click gate test shows the Quit click is neither held nor handed to the
successor; the Linux tests cancel a systemd restart, a relay (with a real
process) and wait for scope writes. The old code held the macOS click, refused
the Linux one, and refused both for a running write. -
Linux: Register every scope owner so one probe can ask whether a write is pending
Each Linux scope module keeps its own transaction owner and its retained
rollback, with no common way to ask whether any of them is still pending. The
tray's Quit needs that answer to behave like macOS's global writer fence for
every category, not only for the global composition.Add infra/scope_registry.lua: a scope registers a probe over the owner it
already keeps (script, tap holds, shortcuts and their editor, AI, metrics,
hotstrings, dynamic hotstrings, key combinations, global) and pending() reads
them all. A probe registered again under its name replaces the first, and a
probe that raises is logged and read as not pending so Quit stays reachable.
The gesture scope registers from the gestures manager, which holds its owner.Regression: the registry test checks each of the nine modules registers under
its scope name when it loads. -
Llm: Supersede the request in flight when an explicit shortcut is mashed
Explicit requests stay immediate (the floor never holds them), but pressing the
same shortcut again while its previous request is still running must replace
that request, not stack a second concurrent one (maintainer, 2026-10-26,
evening). The decision is pure and shared: the request plan's new on_explicit
takes the shortcut's key and whether the engine still has a request of it in
flight, and answers a supersede (then the send) or just the send. Vectors pin it,
including the malformed requests.Linux: the prediction hotkey and prompt actions (one key per prompt), the
translation, the screen answers and the agent requests ask the plan when they
open their offer (a superseded request is dismissed, which cancels it at the
backend), and the tone step now tracks its request and cancels it explicitly
instead of relying on the next chat to push it out.macOS: the forced prediction, the answer surfaces (screen, translation, agent)
and the tone step ask the same decision; a superseded request is cancelled at
the backend after its callbacks were retired, so a cancellation that answers
synchronously reaches nothing current. The tone step tracks its own in-flight
request because it has no fetch counter.Tests: macOS cancels in all four paths (they fail on the old engine); on Linux the
old engine already replaced the request through the backend, so the wire-order
tests pin the explicit cancel-then-send order rather than a new outcome. -
Llm: Ship a 300 ms minimum request interval
The maintainer chose 300 ms (2026-10-26, evening) as the default of
llm.trigger.min_request_interval_ms, the value the local backends already had
and below the 500 ms the first version of the setting shipped. It is declared
once in manifest.toml (default and recommended) and defaults.json, from which
macOS takes its seconds; the generated templates and manifests are regenerated.
0 ms stays accepted as "no floor". The Linux and macOS tests pin 300 ms, and the
menu corpora show it as the checked preset and the displayed value.Windows still reads the fixed per-backend table of inference.json until it adopts
the setting. -
Llm: One request interval the user sets, shared by macOS and Linux
The minimum time between two automatic AI requests was a fixed per-backend
table in inference.json (300 ms local, 500 ms API) that no menu showed, and
the last-request time was kept per backend on Linux. The maintainer wants one
global last-request time and a value the user can set from the menu on every
system (2026-10-26).llm.trigger.min_request_interval_ms (default 500 in manifest.toml and
defaults.json, 0 to 10000, 0 = no floor) is the one interval, stored with the
other trigger settings: macOS shows it as a row next to the idle time with a
numeric prompt and a reset row, Linux as a preset submenu with a free numeric
entry. The request plan keeps one last-request time whatever the backend or the
kind of request and asks the engine for the interval through its port (the
backend argument of on_dispatch and on_sent is gone). An explicit request is
still never held. The Linux "Never" idle-time choice is now a manifest-declared
template row (llm_debounce_never_row) instead of a native one.A busy backend makes the Linux agent wait the user's interval, which can now be
0, so that wait is bounded below by the new timings constant
llm.backend_busy_recheck_ms. The macOS engine loses its api_common floor
lookup, Linux loses the rate-limit rule of its inference module, and the
inference.json section is kept only for the Windows driver, which still applies
it (the manifest entry is limited to macOS and Linux with a platform reason).New menu labels and the platform reason are translated in the 21 locales.
Generated manifests, config keys and templates are regenerated. -
Linux: Fire live mode instantly at a word end
Live mode on Linux kept its own 80 ms debounce at a word end even when the
"instant suggestion at word end" setting is on, while macOS (and Windows)
fire at once. The maintainer wants the instant word end on the three systems,
so the Linux request plan option live_instant_word_end is now on and the
corpus pins it: a word end arms a zero delay in live mode; with the setting
off, live mode keeps its debounce everywhere.Windows already fires instantly at a word end; its live-mode path needs no
change here. -
Linux: Offer the "never" idle time of the automatic prediction
macOS lets the user turn the automatic trigger off with a "Never" idle time;
Linux only offered 50 ms to 10 s. The maintainer wants the same choice on the
three systems, so the Linux menu gets a "Never" entry (shown as the current
choice and in the row's label), and llm.trigger.debounce_ms accepts the shared
sentinel -1 (Debounce.NEVER_MS), the representation the macOS fix stores. The
request plan now accepts a negative debounce on Linux: typing arms nothing,
while a typed trigger, a shortcut and live mode still do.Windows still needs the same: its debounce range is 50..10000 and the AHK
engine has no off state (see the request plan corpus, driver_options). -
Linux: Migrate tap_hold.toml at boot and drop the explicit_delays marker
The daemon now runs the shared tap_hold.toml migration before the tap-hold
engine reads the file: a byte-exact backup, one atomic publication of the
migrated document, the version stamped. A file of the current version is
never rewritten; a newer file, an unusable stamp or a malformed file is left
untouched, ERROR logged, and refused to every writer.The loader no longer reads
[tap_hold] explicit_delaysand the writer no
longer writes it. Every write migrates the document first, so a file the
build creates or changes is always stamped and every delay in a stamped file
is the user's own, whatever its value; a file the boot has not migrated yet
(the folder the first-run wizard inspects) is read as the migration leaves
it, without touching the disk. The wizard import and the scope restore go
through the same step.Tests: old file without the marker, with the marker, current file (no-op and
idempotent), a user per-key override that survives, absent, newer, invalid
and malformed files, the loader's in-memory read, the first write migrating,
the daemon boot order, and the shared contract replayed under the Linux
runner. The JS gate additionally fails if a Linux module reads or writes the
marker again. The scope clear test now expects the stamped neutral document. -
Shared: Add a versioned migration registry for tap_hold.toml
The cleanup of the per-key delays that earlier builds shipped relied on a
permanent[tap_hold] explicit_delays = truemarker that every read and
write had to look for. Replace it with a version stamp,[_meta] schema_version, and a data-only registry, _shared/tap_hold/migrations.toml,
whose step v0_to_v1 removes exactly the delays recorded in
[tap_hold.retired_delays]once, unless the file carries the old flag
(then every delay is the user's own), and deletes the flag.The config.toml registry cannot express a delete-if-equal and Windows
validates it at every boot (config_migrate.ahk refuses an op it does not
know), so tap_hold.toml gets its own registry and a small pure migrator,
_shared/lua/tap_hold/migrate.lua, over a closed op set. A stamp that is not a
non-negative integer, or a file newer than the build, is reported, never
rewritten.The corpus under tests/corpus/tap_hold_migrations (old file with and without
the flag, flag = false, user per-key override, emptied sections, empty file,
current, newer and invalid stamps) is replayed by a shared Lua contract and by
an independent JS reference interpreter, which also keeps the new op out of
the config.toml registry. Windows replays it once its loader and writer read
the registry; the registry header describes what that needs. -
Linux: Refuse Quit for the same causes as macOS
The maintainer decided on 2026-10-26 that the Linux Quit gets the refusals
macOS has. Until now the only one was a shutdown already owning the daemon.
Route the tray's on_quit through the shared quit admission with the Linux
owners as facts: a shutdown or a reload restart that owns the process, a
global scope composition that still retains a rollback, and a hotstring scope
transaction or personal-file inverse that is still pending. A refusal logs its
cause, answers false so the tray sends the same notify.quit_refused notice,
and no longer applies the pause layout before it, so a refused click cannot
leave the keyboard on the pause layout of a daemon that keeps running.The handler lives in infra/quit_request.lua and is held in privacy_owners
because main() already sits at LuaJIT's 60-upvalue limit. The reload owner now
also returns an in_flight probe. A Reload in flight is a refusal on Linux and
a held, replayed click on macOS (its click gate hands the click to the
reloaded menu); Linux restarts the process and has no hand-off.Regression: the Linux unit test replays the shared contract and refuses each
cause without applying the layout or starting the shutdown; the old on_quit
answered true to all of them but a pending shutdown. Windows still needs its
own refusal notice, see the shared-architecture memory entry. -
I18n: Give the greyed AI actions their own AI-off hint
The maintainer decided on 2026-10-26 that the reason shown on a greyed agent
action says the AI itself is off. macOS and Linux reused
llm.manual_prediction.disabled ("AI suggestions are off: ..."), the notice of
the suggestion features, as the picker's reason.New key dialog.action_picker.requires_ai in the 21 locales ("AI is off: turn
it on in the Artificial Intelligence menu."), read by the macOS action picker
rows (mark_ai_requirements) and the Linux gestures manager requirement check
(ai:on). The tests assert the hint is the new string and differs from the
suggestions notice. -
Hotstrings: Offer the 14 Windows colours on macOS and Linux
The maintainer decided on 2026-10-26 that the three OS offer the same 14
hotstring colours. macOS and Linux listed 8 (red, green, orange, blue, purple,
cyan, yellow, gray); pink, indigo, turquoise, lime, brown and the AI purple
existed only in the Windows picker.The shared palette source (shared/data/native_theme.toml, generated into
native_theme_data.lua) now holds the 14 colours in the Windows order. The AI
purple reads its hex from the tooltip constants' accent_colors.ai_loading_hex
(codegenhex_from) instead of typing it a second time. The labels already
exist in the 21 locales (hs_config.color* and menu.hotstrings.tooltip_ai).test-color-presets-parity now requires exactly the 14 colours and the same
order in the Windows copy; a shared Lua contract holds both Lua drivers to the
list by name. -
Llm: Add the shared request plan for the prediction debounce and batching
The prediction engines of macOS (3007 lines) and Linux (3927) each decide
by hand when the automatic trigger arms its timer, how long it waits, and
whether a request may go to its backend or must wait for the backend's
minimum interval (audit R1). Only prompt, parser, rewrite and tone were
shared._shared/lua/llm/request_plan holds the pure half of that: debounce.lua is
a state machine that takes events (typing, explicit trigger, hotstring
expiry, an override delay, a fired timer, a dispatch, a sent request) and a
clock value and returns decisions (arm, cancel, skip, fire, stale, send,
defer); batching.lua decides whether a request is one batch call or one call
per variant, by the rule of the shared prompt selector. Neither does IO or
reads a clock; the floor is a port.The places where the two drivers chose differently are not hidden in the
logic but named as options, each driver stating its own in
modules/llm/request_plan_policy.lua: a "never" debounce (macOS only),
whether an explicit request and the later variants of a sequential request
wait for the floor, whether the last request time is global or per backend,
and whether live mode keeps its debounce at a word end.The request transcripts live in _shared/tests/corpus/llm as JSON
(event sequence in, decisions out) and are replayed by one shared contract
test on both Lua suites, which also pins each driver's option set to the
corpus. The Windows port can replay the same files later. -
Lint: Detect inline path basenames and finish the helper burn-down
The single-use gate learns a basename detector: match("([^/]+)$") and
match("[^/]+$") are the last component of a path, which native_path
.basename owns. Its controls cover both quotes, the chained trailing-
slash form and the spellings that must stay silent (an extension-
stripping stem, a URL segment behind a leading slash, a two-capture
split). The judgement of one file is now a function, and a planted-site
self-test proves it refuses an inline basename and an inline trim,
accepts a baselined or allowed one, and refuses a baseline that is too
high, so a rewrite of the judge cannot turn the ratchet off.Fifteen sites moved to native_path.basename: the macOS file-system
adapter, personal-hotstring and field-check ports, the Linux config,
watcher, device-finder, loader, updater and app-chooser code, and the
shared config backup. loader.lua also drops its hand-rolled trailing
slash strip for without_trailing_separators.Six sites stay by design and are named in ALLOWED with their reason:
two URL segments (an Ollama library URL, a download address), the Linux
worker check that rewrites backslashes in arg[0] first, and the macOS
and Linux boot code that splits its own path before the shared Lua root
is on package.path. With every other inline site moved, the baseline is
empty and the gate only refuses new copies. -
Shared: Declare identifiers and hosts once in identifiers.json
The ErgoptiPlus.app/.exe/.ahk names, the launcher and Hammerspoon bundle ids,
the macOS service labels and the GitHub, Ollama and Hugging Face hosts were
typed in dozens of Lua files (PAR-83, audit K4 and K9). They now live in
_shared/data/identifiers.json; codegen-identifiers.cjs emits
_shared/lua/identifiers.lua, registered in the generator registry and shipped
in the Linux driver bundle.The macOS and Linux Lua sites that spelled them (updater, Ollama release and
registry URLs, launcher admission, remap service paths, Hugging Face links)
read the generated table. Tests that pinned the old literals now read the
shared file, and the macOS fixture scopes that snapshot package.loaded know
the new module. The Windows (AHK) and Swift copies stay as they are and are
held by the gate of the next commit. -
Lint: Fail a hex colour literal outside the theme
lint-conventions now runs the hex-colours scanner over the driver tree and
allows a colour only in the three theme owners (_shared/ui/theme.css,
native_theme.toml and its generated Lua). Every other file is held to
tools/lint/hex-literal-baseline.json: a rise is a new literal outside the
theme, a drop must lower the entry (--update-hex-baseline rewrites it), so the
debt can only shrink. The Windows sites, the metrics pages and the editors
that predate the theme are the baseline. -
Theme: Generate the native palette from one TOML
The hotstring colour palette was typed in the macOS window, the Linux bridge
and the Windows helper, and the download window's four accents were literals
in the macOS driver. _shared/data/native_theme.toml now owns them and
codegen-native-theme.cjs projects them into _shared/lua/native_theme_data.lua
(validated: six-digit hex, unique names, known locale keys). The Linux
(Changelog truncated — too many commits to display in full on this release page.)
Generated by CI
Windows signature qualification
WINDOWS AUTHENTICODE UNSIGNED / qualified:false (source 18d69a24c06586186a221c1afb5ce703732ac4a1): ErgoptiPlus.exe has no Windows code-signing certificate for this v1.0.7 release. All tests, build, package, source and asset integrity, and installation checks remain mandatory. Windows may show an unknown-publisher warning. Signature evidence. Future signing requires SignPath Foundation enrollment and qualified GitHub integration.