/var/lib survives a migration untouched, and a stray pre-Nomarchy
Linger=yes marker shaped the QA box's session behavior for six months
with nothing in the flake to show for it. Ship the class as guards:
- nomarchy-doctor: new machine-state-drift check after the flake-checkout
check — loginctl show-user -p Linger; "yes" warns (nothing is broken —
it is drift) and prints the disable-linger fix; unknown-to-logind skips.
- docs/MIGRATION.md § Post-migration cleanup: "/var/lib machine state
survives migration" — the worked example and the general suspicion rule.
- checks.doctor: linger sub-case — warn fires under enable-linger, exit
stays 0, warning clears after disable-linger.
Deliberately NOT hardening the session wiring against the portal-gtk
restart race linger enabled: a clean install cannot reach that state.
The Waybar exec-once loose thread survives as BACKLOG #149.
Verification: V2 — shellcheck clean, nix flake check --no-build green,
nix build .#checks.x86_64-linux.doctor green (both branches exercised).
Implementation by a Sonnet subagent; design and review on Fable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mkFlake prefers state.json and still accepts the legacy theme-state.json,
because a machine only migrates on its next menu write. Nothing evaluated a
checkout carrying ONLY the legacy name, so the shim could rot with every
check green — breaking rebuilds for precisely the users who have not opened
the menu since #107, i.e. the ones least likely to see it coming.
checks.state-legacy-name evaluates two fixtures: legacy-only must resolve,
and no-state-at-all must still throw. The gate is half the shim's contract
(it turns "no state file" into one readable error instead of a readFile
stack from inside the module system) and was equally unpinned.
Cheap by construction: mkFlake wraps its return in `builtins.seq
_themeState`, so forcing the attrset forces the state read and nothing else
— no module system, no home.nix. Hence a one-file fixture and no measurable
cost (flake check still ~35s). That file is a SYMLINK to the shipped
template's state.json: a copy would drift and the check would quietly start
testing a fossil. The fixture also asserts it has no state.json, or it would
pass while testing nothing.
Both assertions proven by breaking lib.nix — deleting the fallback, then
defeating the gate — and watching each fail by name. Delete this check
together with the shim and the nomarchy-theme-sync alias.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bernardo saw Recovery ellipsize on a 2560x1440 external, which falsified
the item's model (40% + Inter 11 → "only below 1920"). Real cause: text
menus render through themes/<slug>/rofi.rasi, where boreal + neon-glass
pinned width: 620px (fixed — a wide panel buys nothing) while seven used
40%, and the font is whatever that file says — mostly monospace, far
wider than the Inter that was measured. The modelled combination ships in
no theme.
The insight: a menu must fit its longest label, which is a count of
characters in the THEME's font — so the window must scale with the font,
not the screen. 40% gives a 1366 panel 546px and a 2560 one 1024px for
the same row, and a 14pt mono theme needs ~25% more room than an 11pt one
on both; a percentage cannot see either fact.
Rofi has the right unit (`ch` = width of one digit in the current font)
and `calc( a min b )` to cap it, so every text menu — generated and all
nine whole-swaps — is now `width: calc( 84ch min 65% )`. 84ch fits the
longest row we ship (69 chars) plus icon and padding; the cap is
Bernardo's condition (never sprawl on a low-res panel) and it is
measured, not assumed.
Verified on hardware, not by arithmetic: 84ch = 756px in GeistMono 11 and
924px in JetBrainsMono 14; `calc( 84ch min 300px )` → 300px, so the clamp
really clamps. Screenshots at both ends — the real Recovery menu at 756px
(29% of 2560) with every label complete, and the Acer worst case
reproduced pixel-exactly (888px = 65% of 1366, JetBrainsMono 14) also
complete. Grid views override width per-invocation and are untouched.
checks.rofi-text-width guards the class, proven by pinning boreal back to
620px and watching it fail by name. Acer V3 queued for its own fontconfig.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#139 — calcurse/doctor floated full-screen. Two faults stacked, which is
why it looked like the float rules were dead:
1. Hyprland 0.55.4 silently ignores percentage `size` rules. Matrix on
hardware (both orders, kitty memory off): `size 60% 65%` never
applies, `size 1536 936` applies either way, configerrors empty both
times. Rule order was a red herring.
2. Kitty defaults to remember_window_size=yes and replays the last OS
window's size into every float — so after a tiled terminal, a sheet
opens maximized. This is the Ghostty regression: Ghostty had no such
memory, so fault 1 stayed invisible until #95.
Fix: remember_window_size=no (floats must be deterministic) and the sheets
ask for their own size — term-sheet.nix reads the focused monitor and
hands kitty the px, because px in a rule cannot mean "a fraction of *this*
screen" and one pair cannot serve 2560x1440 and the 1366x768 Acer (#131).
Those windows keep float/center and deliberately carry no size rule.
#141 — the updates click did nothing: whole-swaps hand-wrote
`sh -c '$TERMINAL …'` and Waybar has no TERMINAL (home.sessionVariables →
login shells only; the bar is spawned by Hyprland). Fixed at the root:
`nomarchy-updates upgrade-window` opens its own window, so all five call
sites name one env-free command and a theme file stops having an opinion
about terminals. checks.waybar-swap-env guards the class.
Bernardo asked mid-task for the update window to float — it is now the
third caller of the sheet helper (com.nomarchy.updates, 45%x50%).
V3 on the dev box: calendar 1536x936, doctor 1408x1008, updates 1152x720
— exactly the intended fractions, floating and centred; #141 driven under
`env -i` with no TERMINAL, matching Waybar's real environment; the guard
proven by reintroducing the bug. Acer V3 queued.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Human decision: 1h delay, battery only, Preferences enable/disable.
Wire systemd suspend-then-hibernate + HibernateDelaySec=1h +
HibernateOnACPower=false when settings.power.suspendThenHibernate is on
and boot.resumeDevice is set. Lid: s2h undocked, plain suspend on AC.
nomarchy-suspend for hypridle + Power menu (live state). LUKS unlock
before encrypted hibernate so s2h→disk is not double-password.
state-bridges assert the policy; HARDWARE-QUEUE #115 + suggested order.
V1: nix flake check --no-build. V3: bag-carry on hardware.
Weekly nomarchy-gen-prune deletes only generations that are both older
than 14 days and beyond the three most recent past gens (current always
kept), for system and Home Manager profiles. Stock nix.gc no longer uses
--delete-older-than. checks.gen-prune covers selection self-test and
unit wiring; doctor points at the new tool when disk is tight.
System is Connectivity / Devices / Recovery / Preferences under the same
six-entry root. Recovery lists desktop (HM), system boot, and BTRFS files
once each with cost labels. Control Center is gone: blur/gaps live in Look
& Feel; update checks and Bluetooth/Printing package toggles in Preferences
(with rebuilds). docs/RECOVERY paths updated.
Move nomarchy-airplane to pkgs/ so checks can import it. checks.airplane
covers the session state machine, menu/keybind surfaces, and whole-swap
waybar parity (mocked nmcli; live radios stay V3). checks.fastfetch-logo
asserts the baked mark is sextant-based with the module flags locked in.
The machine flake's git-tracked settings file is system state, not
"theme" only — rename it to state.json. CLI becomes nomarchy-state-sync
with a nomarchy-theme-sync symlink for scripts and muscle memory.
Eval (mkFlake, doctor, lifecycle) still accepts theme-state.json; the
next write migrates to state.json and removes the legacy file.
Documented in MIGRATION.md; drop the CLI alias after release notes.
Every bind in keybinds.nix carries a group; SUPER+? renders section
headers. checks.keybinds-cheatsheet asserts all groups are valid and
every description appears in the sheet (same source as Hyprland binds).
Verified: V0 flake check; built cheatsheet shows ── Window ── / etc.
A singleton default naming code.desktop left the live ISO with no text
handler — GIO skips missing .desktop files and offers nothing. Prefer
vscode when present, then org.gnome.TextEditor.desktop. Live also gains
mpv (video defaults had the same hole; free via the template pin).
checks.live-baseline-apps now requires every Default Applications key
to resolve to a present .desktop on live (HM + system path) and on the
default template install. Proved to fail on a vscode-only text/plain.
Verified: V2 (live-baseline-apps green + prove-to-fail); V0 flake check.
BACKLOG #118. Bernardo booted the live ISO and the Waybar health icon was red,
reporting smartd. Everything downstream turned out to be working correctly,
which is the part worth recording: smartd's config is DEVICESCAN, and where no
drive answers SMART it exits 17 ("Unable to monitor any SMART enabled
devices"), systemd marks the unit failed, nomarchy-doctor faithfully reports a
failed system unit, and Waybar paints @bad. The doctor was telling the truth.
smartd was the bug.
Scope was never live-only, which is why this sat in NOW rather than as a live
nit: services.smartd.enable mkDefaults true on every machine and QEMU virtio
exposes no SMART, so every VM install has been booting to a health warning
about a daemon with nothing to do — and every V2 run had been showing it as
noise.
Fixed with the distro's own self-gate convention: an ExecCondition running
`smartctl --scan`, which prints nothing exactly when smartd would find nothing.
A failed condition leaves the unit inactive rather than failed. Deliberately
NOT SuccessExitStatus = 17: that would also swallow exit 17 from a machine that
does have drives, which is the entire reason the daemon ships.
V2. checks.smartd-gate boots the REAL distro module rather than a restatement
of it (its nixpkgs.config needs mkForce to yield to the test's pkgs) and
asserts both halves, because they pull in opposite directions: a gate that
never skips leaves the red icon, and a gate that always skips silently disables
drive-health monitoring on real hardware — the failure nobody notices until a
disk dies quietly. So the no-SMART node must go ActiveState=inactive, unfailed,
and absent from `systemctl --failed` (what the doctor actually reads); and the
gate's logic is driven against a scan that DOES find a device, since QEMU
cannot answer SMART honestly and pretending otherwise would test nothing. The
check was proved to fail by unwiring the condition. flake check, doctor,
hardware-toggles, live-baseline-apps, option-docs and state-bridges pass.
V3 pending: that smartd still RUNS where drives have SMART (dev box, real
NVMe). Queued with an explicit fail-condition — if it skips there, revert the
gate rather than tune it.
Also swept: #120's size table said the duplicate chromium was gone; #121 was
reverted, so it is back and the table says so.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bernardo on #121 (shipped in 0b464af): "Let's leave it honestly. Sounds too
much work for a negligible gain." Agreed, and his own reasoning is the
argument: the fix worked, but it substitutes into a buildCommand nixpkgs owns,
which couples us to wrapper internals and has a SILENT no-op failure mode —
so it also needed a permanent check, because nothing about a returning
duplicate breaks a build. That standing maintenance cost buys ~195 MiB of
download and ~19 MiB of disk. Boring beats clever at that exchange rate.
Removed the overlay and checks.chromium-single-closure. The template HM closure
is back to 9.38 GiB with the duplicate present — verified by closure, not by
reading the diff. overlays.default now carries a comment saying the duplicate
is known and deliberate, where the next person will look for it.
The ROADMAP entry stays, downgraded from ✓ to ✗ decided-against: it holds the
measurement, the approach that worked (self-contained -wv copy; repoint the
wrapper's share/* symlinks; .override composes with overrideAttrs), and the
commit to recover it from. The bug is real and worth an upstream patch — the
wrapper should take its desktop entry from the variant it wraps, which helps
every NixOS user with enableWideVine, not only us. Revisit locally if #120's
netinstall ships, since download is the one axis this moves.
What survives is worth more than the fix, and is stamped on #120: closure size
is not disk size and is not image size — measure the artifact. Removing a
687 MiB path moved the ISO by 8 KiB, because mksquashfs dedupes duplicate files
and auto-optimise-store hardlinks them. Without that caveat, #120's "drop the
pin, save 4 GiB" would have evaporated at the squashfs exactly as this did.
V0: nix flake check --no-build passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BACKLOG #121. nixpkgs' chromium wrapper with enableWideVine = true (what the
template ships, for DRM) runs the `-wv` copy but links its share/* from the
PLAIN unwrapped build. A symlink is a store reference, so a second 687 MiB
chromium rode along for a directory of .desktop files nothing executes.
overlays.default now points those symlinks at the copy we already run; the
template needs no change, since lib.nix, the repo pkgs and nixosModules all
apply the overlay.
Checked first, because it decided whether the item was possible at all:
whether the `-wv` copy references the plain build. It is `cp -a` of it, so it
plausibly would have — and then no wrapper change could have dropped the
duplicate. It does not. Also verified rather than assumed: `.override
{ enableWideVine = true; }` composes with the overlay's overrideAttrs (the
order usually matters), so the template's existing line picks the fix up
untouched; the wrapper still execs the -wv build with WidevineCdm present; and
share/{applications,icons,man} match stock with the .desktop byte-identical.
I claimed this was "687 MiB off every install". It is not, and measuring the
artifact instead of the closure is what corrected it:
nominal closure -687 MiB (9.38 -> 8.71 GiB)
ISO image -8 KiB of 8.078 GiB
installed disk ~-19 MiB
cache-install download -195 MiB
Two dedupes that closure arithmetic cannot see. mksquashfs detects duplicate
files, so the ISO had already stored the near-identical blocks once. And
auto-optimise-store (on by default here) hardlinks identical files: the two
paths share inodes — verified by stat, and du counts 639 MiB for the pair
against ~620 for one. The only thing that cannot dedupe is the wire, where the
extra path is its own 195 MiB NAR.
So this is a DOWNLOAD fix — precisely what #120's netinstall cares about — and
close to a no-op for the offline ISO that is the default today. Whether that
justifies coupling an overlay to nixpkgs wrapper internals is Bernardo's call;
it is guarded and no-ops if upstream moves, but it is his to weigh. Worth an
upstream patch regardless: the wrapper should take its desktop entry from the
variant it wraps.
The guard is the load-bearing part, because the failure mode is a SILENT no-op
that no build complains about: checks.chromium-single-closure asserts exactly
one full unwrapped chromium in the template closure, and was proved to fail by
neutering the overlay — it reports "found 2", names both paths, and warns off
the tempting "fix" of dropping enableWideVine, which silently removes DRM.
The rule this establishes, now recorded in ROADMAP and on #120 (whose entire
size table is closure arithmetic): closure size is not disk size and is not
image size. Measure the artifact.
V2: flake check, chromium-single-closure, live-baseline-apps, template-sot,
option-docs, state-bridges all pass. Three ISOs built from one tree for the
numbers above.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BACKLOG #103. Bernardo, live ISO 2026-07-14: still no browser, no office —
"that makes a live iso useful". He is right, and it is the thing a user judges
the distro by before installing, and what they boot to rescue a machine that
won't start. It could do neither.
The live HM user now names its own set: chromium, libreoffice-fresh,
gnome-text-editor, amberol, snapshot. Firefox deliberately excluded (Bernardo's
call): chromium already owns the HTTP mime default and ships in the installed
template, so it is the browser that matches what a user gets post-install.
I asked him to choose on a "~+2 GB" premise. The premise was wrong, and
measuring rather than assuming is what corrected it: system.extraDependencies
ALREADY pins the template's HM closure into the ISO for offline installs, so
chromium/libreoffice/amberol were already in the image's store — merely absent
from the live user's profile, so nothing put them on PATH or in the launcher.
The live and template chromium resolve to the SAME store path, verified, so
reusing the template's exact `chromium.override { enableWideVine = true; }`
costs zero; a plain `chromium` would have been a second 2.5 GiB closure. Only
gnome-text-editor and snapshot are new: 9 paths, 133 MiB uncompressed. His
Firefox call still held for the right reason — it was the one item genuinely
unpinned.
V2. Measured ISO delta, both built from the same tree — the item's pass
condition: 8.038 → 8.078 GiB = +41.2 MiB (+0.50%). New
checks.live-baseline-apps asserts each app is on PATH *and* has a .desktop the
launcher can see (a binary without one is invisible, which is the failure that
matters), that HTTPS resolves to a chromium entry actually PRESENT (#94's exact
trap — a mime default naming a package nothing ships), and that firefox has not
crept back, since that is a size decision not a drive-by. The guard was proved
to fail: dropping snapshot makes it name the missing entry. flake check,
live-install-entry, option-docs, template-sot green.
V3 pending: that the apps LAUNCH needs hardware — queued on the Acer M5-481T,
from the launcher only, since that is the claim a file-level check cannot make.
Also filed, from measuring the 8 GiB rather than speculating about it:
* #120 (NEXT, Bernardo's call) — a netinstall ISO beside the offline one.
Starts from numbers: the offline pin is only 4.02 of 18.03 GiB uncompressed
(~22%), so dropping it still leaves a 14 GiB desktop (~6.3 GiB compressed).
"No pin" is not the lighter ISO; and without a binary cache for Nomarchy's
own outputs a netinstall trades a download for a from-source install.
* #121 — the Widevine wrapper drags a SECOND 687 MiB unwrapped chromium in
for its share/applications alone. Pre-existing, on every installed machine.
* #119 — text/plain names vscode, which the live ISO never ships.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BACKLOG #116. `nomarchy.settings` is declared in exactly one place —
modules/home/options.nix:412, the Home Manager side. On NixOS the attribute
does not exist, and `or <fallback>` swallows the missing-attribute error, so
four options that "defaulted from the state" had silently been their fallback
on every machine ever built.
The item said three options, and called them benign. Both halves were wrong,
and re-grepping rather than trusting the account is what found it:
* There were four. The original enumeration read options.nix instead of
modules/nixos/ and missed services.nix's printing.enable — the same
mistake in miniature as the bug it was filing.
* Two were live user-facing bugs. Control Center is shipped
(default.nix:337) and reachable from the menu; its Bluetooth and Printing
toggles wrote settings.{bluetooth,printing}.enable and printed "requires
rebuild", and the rebuild changed nothing. They had never worked.
The fix is one shape, now uniform: the option declares a STATIC default, and
the implementing module reads the state via theme-state-read.nix (fails closed
on bad JSON, unlike greeter.nix's raw fromJSON — also moved onto the reader
here) and mkDefaults it behind `mkIf (state != null)`. An absent key leaves the
option default as the single source of the fallback; a hand-set system.nix
value still pins it. batteryChargeLimit gets no bridge and loses its dead read:
power.nix's oneshot already reads that key with jq at RUNTIME and prefers it
over the baked value, which is why that menu worked all along.
V2. The bug is proved real before/after on the same flipped state: BEFORE,
bluetooth stays true and printing stays false; AFTER, both flip, and a hand-set
value still outranks the state. Nothing in a build fails when a bridge dies, so
the guards are the point — checks.state-bridges asserts 11 eval cases, and
checks.printing-from-state boots a VM whose only input is the state file and
waits for a running cups.service. The guard was itself proved to fail:
re-breaking the bluetooth bridge makes it throw, naming both assertions. A
check that passes whether or not the property holds is worse than no check
(625b7e3). flake check, option-docs, template-sot, downstream-template-*,
installer-safety, hardware-toggles and battery-charge-limit all pass.
No V3: the mechanism is fully proved headlessly. Design record in ROADMAP §
NixOS-side state bridges (#116); new #117 (PROPOSED) for the control-center
toggles still leaving the rebuild to the user.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The indicator answers "which layout am I typing in?", so it is worth bar
space exactly when that question has more than one answer. It was gated on a
per-device override merely *existing*, which is the trap this box walked
into: the junk rows a keyboard's extra HID collections leave behind are all
remembered at the session layout, so they added no second answer and still
raised an indicator that could only ever show one value.
Gate on layouts rather than devices, and read the runtime-remembered
settings.keyboard.devices alongside the declared option — the new-keyboard
watcher writes those without a rebuild and they only graduate into
keyboard.devices later, so a bar reading the option alone was wrong for
exactly as long as the memory was fresh, which is when it matters most.
keyboard.layouts is now excluded on purpose: hyprland.nix keeps it as the
pool the picker offers first and deliberately never merges it into kb_layout,
so listing candidates cannot make the current layout ambiguous. A device
actually using one of them still does.
All four whole-swaps list the module unconditionally, so a single-layout
setup saw on a swapped bar what the generated bar hides — the parity rule was
already broken. Filter a swap through the same answer instead of asking four
hand-written files to re-derive it.
Logic is pure in modules/home/waybar-language.nix, matching monitor-rules /
dock-audio-rules; checks.waybar-language unit-tests both halves against
boreal's real swap, including a fixture-drift guard so the parity assertion
cannot pass vacuously.
Verified V2: new check passes, docking-ux and theme-wholeswap still pass,
`nix flake check` is clean, and the live config's generated bar now resolves
modules-right without hyprland/language on its single-layout default.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bernardo's retest on the AMD dev box. Two of the four reports were never
running the new code — both watchers are Hyprland `exec-once`, so a
`nomarchy-home` switch leaves the old processes alive until relogin; only
`nomarchy-dock-audio`, a user service, had picked the fix up. The journal
showed the old watcher's `event=monitoradded` next to the new audio tool's
`result=no-available-sink`, which is what gave it away. The other two were
real.
Automatic dock mode did not exist. The watcher auto-*undocked* when the last
external left, but on plug it only ever matched a display profile — and with
no profile saved and displayProfileAuto unset it did nothing at all, while
`Dock mode` was reachable only from the menu. Half the pair being automatic
is what made the dock feel broken: the session came home by itself but never
left. Mirror it, letting a matched profile win as the more specific layout.
The BenQ's audio was unreachable by any means because WirePlumber had the
GPU's DisplayPort card pinned to `pro-audio`, whose raw `pro-output-N` sinks
carry no ports, no routing and no volume — so no sink matched, nothing was
selectable, and the profile is stored per card and so survived forever. The
monitor's own USB card is input-only (every profile has sinks: 0), leaving
DisplayPort as the only path to its speakers. Repair the card when a plug
finds nothing routable, which is both the symptom and the only moment worth
touching profiles: narrow to cards carrying an *available* HDMI/DP port —
the monitor asking for audio over the cable — so the internal analog card is
never touched and a considered pro-audio setup elsewhere survives.
Verified V2: checks.docking-ux and checks.dock-audio pass with guards for
both; the repair filter picks exactly the GPU card → HiFi against the dev
box's live graph, leaving the BenQ USB, internal analog and Bluetooth cards
alone. Hotplug itself still needs hardware — HARDWARE-QUEUE round 6, which
now leads with the relogin requirement that invalidated round 5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four failures from Bernardo's round-4 dock pass on the AMD dev box, plus a
headphone regression, tracked to three causes.
The display watcher ran socat with `-T 1`, which closes Hyprland's IPC
socket after one second of *inactivity*. A dock is normally plugged into an
idle desktop, so the socket was almost always mid-reconnect at that moment
and `monitoradded` was lost outright — and the wallpaper repaint, the
docking profile, and the dock-audio reprobe all hung off that single event.
The reconnect path recovered only the lid inhibitor, so the dock stayed
half-configured until something else poked the watcher.
Make the connection long-lived and take the periodic tick from a 1s `read`
timeout instead, then let the *output set* — not the event — decide: a new
`reconcile` diffs it and is called on both ticks and monitor events, so an
event now only makes the reaction instant rather than necessary. A newly
present external output is as unambiguous a physical plug as the event was,
so it may still override a manual sink choice. Wallpaper paints are retried
over the first few seconds because awww registers an output on its own
schedule, not the compositor's.
Hyprland calls every key-capable evdev node a "keyboard": the lid and power
buttons, a monitor's control channel, and each extra HID collection of a
real keyboard. One dock with one keyboard asked for a layout four times.
udev already draws the distinction properly, so take ID_INPUT_KEYBOARD from
it, map the names into Hyprland's spelling, and ask once per physical
keyboard — applying the answer to every node it brought with it.
Finally, a sink WirePlumber has been told to prefer is stored as the
configured default and outranks every priority rule. On UCM cards the
headphones are a sink of their own rather than a route, so once anything
pinned a sink (this feature on a dock plug, or the user in the Audio menu),
plugging headphones in could no longer take the audio back. The `watch`
loop already subscribed to exactly the right events with an empty handler;
finish it. Unplug needs no undo — the pin goes stale and WirePlumber falls
back by priority on its own, to the dock when docked and the speakers
otherwise.
Verified V2: checks.docking-ux and checks.dock-audio pass with new guards
against each regression; the keyboard filter and both sink selectors run
against the dev box's real devices and sink names. The hotplug paths need
hardware — queued as docking round 5.
Also queues #107, renaming theme.json now that it holds the system state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BACKLOG #99: the live-session seed (nomarchy-seed-flake) did a bare
`cp -r` with no `git init`, so `~/.nomarchy` was not a repo in the live
session and nomarchy-doctor's `git ls-files --error-unmatch
theme-state.json` false-alarmed "theme-state.json is NOT git-tracked"
(installed systems commit at nomarchy-install.sh:654 and were always
fine). The seed now init+add+commits, exactly like the installer.
Verified: V2 — focused serial-log VM run
(/tmp/nomarchy-v2-theme-99/): doctor prints "theme-state.json is
git-tracked" and "flake checkout is clean"; GIT_REPO_OK / TRACKED_OK
markers. A drv-equivalence test proves the seed change is
build-neutral: homeConfigurations.<user>.activationPackage is
byte-identical path-seeded vs git-seeded
(bvl30ggn...-home-manager-generation.drv). nix flake check green.
The same run exposed a separate, pre-existing failure — offline theme
switching in the live session rebuilds from source and dies offline —
filed as BACKLOG #113 (NOT caused by this change: same drv either way).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 1 of #101's V3 failed on TuringMachine (2026-07-13 18:16): a
SPACED dock/AC event storm — each run finishing before the next event —
never coalesces, so 5 successful sub-second starts inside systemd's
default 10s window tripped start-limit-hit and marked the unit failed
(doctor badge) although every run succeeded. The write is idempotent
and cheap; rate limiting it only manufactures false failures, so the
unit sets StartLimitIntervalSec=0.
checks.battery-charge-limit now asserts the exemption and runs 8
sequential starts expecting a clean, non-failed state; the original
mid-run coalescing phase is unchanged.
Verified: V2 — the check passes under KVM (exit 0). V0 flake check
green. V3 round 2 re-queued in HARDWARE-QUEUE.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Treat the picker-only (none) row as an empty XKB variant at the shared input boundary and again in the template patcher. Guard the real downstream output and console keymap, and carry the sentinel through the offline installer VM.
Verified: V0 full flake evaluation plus shell/Python/Nix/diff checks; V1 installer-keyboard, installer-safety, and template-SoT builds; V2 full KVM offline LUKS+swap install and themed first boot. No V3 required.
USB-C power event bursts could repeatedly restart and SIGTERM the settling oneshot until systemd reached its start limit. Let successful runs return inactive and use coalescing start jobs for immediate and delayed reapplication.
Verified: V0 nix flake check --no-build, module parse, and git diff --check; V2 focused battery-charge-limit KVM test with a 12-event active-run burst. V3 hardware item #101 remains queued.
Restore the old flake's external-only workspace handoff, protect closed-lid cable removal with a verified low-level logind inhibitor, and use monitor-added as the settled audio reprobe boundary.
Verified: V2 — nix flake check --no-build; docking-ux, option-docs, and template-sot checks; KVM tools/monitor-fallback.nix lifecycle test.
V3 pending: closed-lid BenQ round 4 in agent/HARDWARE-QUEUE.md.
Real dock QA exposed pointer-bound menus, an absent default rescue watcher, the unsafe zero-output panel-off path, missing keyboard/audio hotplug coverage, and unpainted new outputs.
Route Rofi to the focused output; replace panel disable with safe workspace migration; always run and reconnect the display watcher and repaint hotplugged outputs; expose a default per-device keyboard picker; handle audio card availability transitions.
Verified: V1 — nix flake check --no-build; checks.docking-ux real Home Manager artifact build; option-docs/template-sot; Nix parse, py_compile, and git diff --check. V2 unavailable: no readable KVM. V3 pending in agent/HARDWARE-QUEUE.md.
Bernardo promoted the PROPOSED item live: with fingerprint PAM on,
sudo/login should accept whichever factor comes first instead of
pam_fprintd's wait-for-the-reader-then-password. Stock PAM cannot
express parallel factors (linux-pam#301), so this packages
pam-fprint-grosshack v0.3.0 (pkgs/, pinned from GitLab — the
field-standard fprintd fork), source-reviewed before packaging: every
failure path (no reader, no prints, fprintd absent/hung, timeout,
password typed) returns PAM_AUTHINFO_UNAVAIL and falls through; a
typed password is only ferried via PAM_AUTHTOK to the stock
`auth sufficient pam_unix.so … try_first_pass` rule — the module never
validates passwords itself, so it cannot lock out password login.
New option nomarchy.hardware.fingerprint.parallel, default TRUE (the
better UX is what opting into fingerprint PAM buys; false = stock
sequential). Wiring swaps the modulePath of stock fprintd's rule slot
(mkForce) so the sufficient-before-pam_unix ordering is inherited, not
recomputed. README + downstream template rows added.
Verified: V2 — checks.hardware-toggles extended to three nodes, green:
parallel node asserts the grosshack auth line precedes pam_unix in
/etc/pam.d/sudo and that with NO reader a correct password still
passes sudo while a wrong one fails (the lockout-safety invariant);
seqpam node gets stock pam_fprintd and no grosshack; nopam gets
neither. flake check + option-docs + template-sot green.
V3 pending (HARDWARE-QUEUE, AMD dev box): the real type-or-touch race,
fprintd-stopped fallback, hyprlock/greeter after a fingerprint win.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With settings.autoCommit on, only menu/theme mutations were committed
(theme-sync pathspec-limits to theme-state.json by design), so hand
edits to system.nix/home.nix and lock bumps stayed forever-dirty.
New internal nomarchy-autocommit (nomarchy-lifecycle): live-reads the
same flag, commits everything dirty as "nomarchy: auto-commit before
<pull|rebuild|home switch>" with the swept file list in the body,
fallback identity, never fatal (|| true at call sites). Called at the
top of all three lifecycle commands; before the ff-only pull it also
un-dirties the tree. Exposed via passthru.autocommit + package export;
guarded by checks.lifecycle-autocommit (sandbox repos: sweep, no-op on
clean/off/non-repo). Sync sweep: README, RECOVERY, ROADMAP, rofi
toggle, control-center prompt, theme-sync docstring, JOURNAL.
Verified: V1+ — flake check --no-build green; the new check builds;
end-to-end run of the built nomarchy-rebuild (stubbed sudo/rebuild)
against a copy of a real dirty ~/.nomarchy: 4 files → one commit,
clean tree. No VM boot — no session-visible surface.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-migration annoyances from TuringMachine:
- Force fprintAuth from fingerprint.pam (NixOS defaults it on with fprintd)
- First-boot toast waits for swaync, retries, only marks after success
- Night light status/toggle handles masked units + hyprsunset/wlsunset
- VPN menu: define rofi_menu in nomarchy-vpn (was a silent no-op)
- Left arrow = ↩ Back in list menus; GTK menu contrast via Stylix
- Mic-mute LED udev + sync after wpctl; EasyEffects starts after tray
- Migration guide: HM before GUI login, fingerprint/browser/VPN notes
Verified: flake check --no-build; checks.first-boot; checks.hardware-toggles
(pam=false node). V3 session items pending after user rebuild.
Raise priority.session for HDMI/DisplayPort (1100) and USB (1050) ALSA
outputs so the default sink follows dock plug/unplug; built-in analog
wins again when externals disappear. Rules live in
modules/nixos/dock-audio-rules.nix; checks.dock-audio covers the contract.
Verified: V2 — flake check --no-build; checks.dock-audio green.
V3 pending: HARDWARE-QUEUE #87 real HDMI/USB-dock hotplug.
Explicit HandleLidSwitchDocked=ignore under services.logind so closing
the lid with an external display connected does not suspend. Undocked
lid path left to upstream defaults. checks.clamshell-logind asserts the
baked logind.conf and active logind; real multi-monitor lid V3 queued.
Verified: V2 — flake check --no-build; checks.clamshell-logind green.
V3 pending: HARDWARE-QUEUE #86 lid+external on a laptop.
If the installer left /var/log/nomarchy-hm-preactivate.log and no Home
Manager generation exists, doctor reports ✖ with the recovery one-liner
(home-manager switch --flake ~/.nomarchy -b bak). Clears once a
generation is present. checks.doctor covers both arms.
Verified: V2 — checks.doctor green; local smoke.
nomarchy-what-changed wraps nvd into "N added, M removed, K updated"
summaries. nomarchy-rebuild / nomarchy-home print full nvd and toast
the one-liner; System › What changed? offers toast + floating report.
checks.what-changed fixtures nvd via NOMARCHY_NVD.
Verified: V2 — flake check --no-build; checks.what-changed green;
local smoke on real HM generations.
One-shot user unit fires a dismissible "You're set" notification on the
first graphical session (SUPER+M / SUPER+T / SUPER+? + Network + Doctor),
then writes settings.firstBootShown into the flake checkout. Skips the
live ISO hostname (hosts/live.nix keeps its own toast). Toggle:
nomarchy.firstBootWelcome.enable. checks.first-boot covers the gate.
Verified: V2 — flake check --no-build; checks.first-boot green; local
shim smoke; downstream-template-home ships the unit.
Report-only cycle_count + retained capacity (charge_* or energy_*) as
% of design on system batteries; soft warn below 70% of design; skip
when attrs missing. NOMARCHY_POWER_SUPPLY_ROOT for the checks.doctor
fixture (test_power has no cycle/design attrs).
Verified: V2 — nix flake check --no-build; checks.doctor green (fake
BAT0 cycles/% + Device-scope ignore); local BAT0 smoke (energy_*).
Set BOTH nomarchy.nightlight.latitude/.longitude and the night light
switches backing unit: wlsunset computes sunrise/sunset from the
location daily (the fixed .sunrise/.sunset are ignored;
.temperature feeds the night temp). Everything user-facing is
unchanged — same nomarchy-nightlight toggle script, Waybar moon, and
live-state ExecCondition gate; the unit name is a single let-binding
so all three follow together. Declarative-only on purpose: coordinates
are machine config like the keyboard layout, so home.nix (commented
template example), not a menu writer.
checks.nightlight-geo guards the wiring at pure eval: rendered
wlsunset ExecStart carries the coords + night temperature, the
ExecCondition gate landed on the swapped unit, hyprsunset stays off in
geo mode.
Verification: V1 — nix flake check --no-build exit 0 (evals the new
asserts + option-docs sync); built downstream-template-home (default
hyprsunset path unregressed). V3 pending: visible warm shift at the
location's night + instant toggle/off-persistence on the swapped unit
(HARDWARE-QUEUE § Any machine).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Push #76's swapfile-resume residual into a VM as far as headlessly possible.
A full hibernate→resume from a RUNTIME swapfile turned out not to be
headless-reachable: systemd reports CanHibernate=no for a swap that isn't
declared in the boot config, and a runtime-computed resume_offset can't be
fed into the boot cmdline (the genuine chicken-and-egg). So the check now
verifies, bounded and deterministically, what the partition test
(checks.hibernate) can't and what a VM CAN prove: the installer's btrfs
@swap swapfile is built correctly and its offset is valid — NOCOW
mkswapfile, file-type swapon, a good `map-swapfile -r` offset the kernel
accepts (/sys/power/resume_offset), and zram (prio 100) above it.
The literal encrypted-swapfile power-cycle + LUKS-initrd unlock stays a V3
laptop check (the dev box runs exactly this layout, /proc/cmdline verified).
(First cut had an unbounded wait_for_shutdown that hung on the CanHibernate
refusal; rewritten with a thread-bounded wait + a CanHibernate pre-check,
which gave the clean verdict. Device is /dev/vdb without useNixStoreImage.)
Verification: V2 PASS (nix build .#checks.x86_64-linux.hibernate-swapfile,
fresh, exit 0); flake check --no-build green. #76 residual narrowed in
BACKLOG/ROADMAP.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bernardo rightly pushed back on deferring #79 to a real session: a
simulated sunset IS VM-testable. Add checks.auto-theme, a runNixOSTest
that installs nomarchy-theme-sync, points NOMARCHY_DEFAULT_THEMES at the
presets, stubs the rebuild via NOMARCHY_REBUILD (a marker), configures
the day/night pair + schedule, then WALKS THE VM CLOCK across the
boundary: 19:55 -> 20:05 -> 20:06 -> next-day 07:30, asserting the active
theme goes summer-day -> night (1 rebuild) -> night (idempotent, still 1)
-> day (2 rebuilds).
This isolates the auto decision + apply + rebuild-trigger; the actual
home-manager switch is stubbed (that generic apply path is exercised by
every manual theme change). The only on-hardware residual is the user
timer firing on its OnCalendar schedule, which systemd-analyze validates.
#79 drops its [blocked:hw] tag — it's V2-verified. ROADMAP updated;
lesson recorded in MEMORY (don't default timer/session features to V3:
stub the generic step, simulate the clock).
Verification: V2 PASS (nix build .#checks.x86_64-linux.auto-theme, fresh,
exit 0); V0 flake check green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nomarchy-doctor gains a read-only hibernate/sleep section (the highest-ROI
Day-2 gap, a natural #76 follow-on): resume device on the kernel cmdline
(+ resume_offset for a swapfile), disk swap >= RAM, zram active, and any
suspend/hibernate error in the previous boot's kernel log. Self-gates
(skip on swap=0) and emits only ok/warn/skip, so it never fails the sheet.
Adds `hibernate` to the checks.doctor probe list so the row is asserted.
Fixes a set -euo pipefail hazard the first VM run caught (exit 1, no
verdict): a no-match grep inside $(...) and a `cond && action` both abort
under set -e — now guarded with || true / || echo 0 / if. Recorded in
MEMORY as a reusable gotcha for writeShellScriptBin scripts.
Verification: V2 PASS — checks.doctor VM test green (fresh build, exit 0;
asserts the hibernate row renders, healthy run stays 0). V1 — ran on real
LUKS+@swap hardware: all three ok rows render (resume set, swap 34G >= RAM,
zram 16G) and the sheet reaches its verdict. V0 bash -n + flake check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add checks.hibernate: a runNixOSTest modeled on nixpkgs' hibernate.nix,
wired to the actual risk #76 introduces — coexistence with the zram swap
that oom.nix now enables by default. It imports oom.nix, puts the disk
swap at a lower priority than zram with an explicit boot.resumeDevice,
and uses systemd-initrd. The test drives a full
hibernate -> power-off -> resume -> crash cycle: a volatile ramfs marker
survives resume but NOT a crash-boot (proving genuine resume), and it
asserts zram sits at priority 100 while the disk swap is lower, so the
hibernate image lands on disk rather than volatile zram.
What QEMU genuinely can't do — feed a boot-time resume_offset for a btrfs
swapfile (offset only exists after first-boot allocation) or drive a
LUKS-initrd unlock — stays a V3 laptop check (HARDWARE-QUEUE, dev box
already runs that exact layout), plus the no-swap notify in a real
session. With this, all V0/V1/V2 work on #76 is done; #76 collapses to a
[blocked:hw] stub and the design record moves to ROADMAP.
Verification: V2 PASS — nix build .#checks.x86_64-linux.hibernate built
the driver fresh and exited 0. nix flake check --no-build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of BACKLOG #76 (hibernation + zram, split): zramSwap on by
default in modules/nixos/oom.nix — the memory-pressure layer before
earlyoom. zstd @ 50% RAM (nixpkgs defaults, explicit for reproducibility),
priority 100 so day-to-day paging fills zram first and any disk swapfile
stays reserved for #76's hibernate image (hibernation can't resume from
volatile zram). Follows the earlyoom mkDefault precedent — no toggle.
Adds checks.zram-swap runNixOSTest importing oom.nix, asserting the device
is an active swap, uses [zstd], and carries priority 100.
Verified: V2 checks.zram-swap PASS (/dev/zram0 in /proc/swaps, zstd,
prio 100); V1 full distro toplevel builds; V0 flake check --no-build green.
#76 remainder (disk swapfile default + resume + installer) still open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract nomarchy-pull/rebuild/home to pkgs/nomarchy-lifecycle; install
via system + home. Home profile wins on PATH so home-manager switch
installs the fixed pull (skip git when no upstream) without waiting
for a system rebuild that needed a working pull first.
V0: flake check --no-build green.
Package the installer’s pure hardware-db probe as nomarchy-detect-hw:
prints suggested hardwareProfile and system.nix snippets (or --raw
protocol lines). Does not rewrite the flake. On PATH via the distro
module; docs/HARDWARE.md §8 updated. Close#58.
Verified: V1 (build + run on AMD laptop with fingerprint/NPU/IR cam).
#54: permanent checks.installer-safety; test-install-safety harness with
offline compose-fail, LUKS-signature disk warn, and HM-hint FORCE paths.
#33: enable themed rofi scrollbars (generated + all whole-swap .rasi).
#43: MOTD + first-boot tip for System › Firmware (V3 LVFS stays hw-queue).
V2: compose-fail/disk-warn/hm-hint logs green offline; theme-shot
boreal+summer-night menus OK. V0 flake/installer-safety.
Extend checks.doctor: the bare test VM lacks every probe tool, so the
healthy nomarchy-doctor run drives all six hardware checks in skip mode.
Assert the self-gated skip rows appear (NetworkManager, fingerprint,
fwupd, VA-API) and the sheet still exits 0 — proving the section runs
without erroring under set -euo pipefail. No node changes.
Verified V2: nix build .#checks.x86_64-linux.doctor passes. Closes#44.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#50: Bluetooth menu row + handler self-gate on `command -v
blueman-manager` (no-op when bluetooth is off, like Printers); Control
Center rollback list /tmp/nomarchy-gens → mktemp; usbutils on the
nomarchy-install PATH (fingerprint lsusb probe); export
nomarchy-doctor/control-center/battery-notify as flake packages.
Verified V0 (flake check) + V1 (packages, installer, HM generation build).
Also records #46: the swap=0 + unattended-LUKS fail-closed contracts
(208b8d4) V2-verified via a headless two-variant install — fail-closed
exits before wipe with the disk blank; swap=0 installs with no @swap
subvol and boots to a themed desktop. Both items removed from NEXT.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>