aboutsummaryrefslogtreecommitdiff
path: root/todo.org
blob: 78c35b32a6f6130fb096254462257a25de75d8fa (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
#+TITLE: ArchSetup Tasks
#+AUTHOR: Craig Jennings
#+DATE: 2026-02-14


* Archsetup Priority Scheme
Four levels, matching the Emacs config (=org-highest-priority ?A=, =org-lowest-priority ?D=, =org-default-priority ?D= in =modules/org-config.el=). Priority answers "how much does this matter"; a date answers "when". They are independent — assign both deliberately. Org priority alone never schedules anything, which is why undated [#A]/[#B] tasks feel ungrounded.

- [#A] Must happen. Broken install, data loss, security, or a blocker for other work. An [#A] REQUIRES a SCHEDULED or DEADLINE date — if it can't be dated, it isn't really an A; drop it to B. (The main agenda always shows open A's.)
- [#B] Should happen, this cycle. Real improvement or fix with no hard date. Surfaces in the agenda's priority-B block only while undated; add a SCHEDULED date when you commit to a week and it moves into the schedule.
- [#C] Nice to have / someday. Kept for the record, low urgency. Date it only when it graduates to B.
- [#D] Default / unsorted. A bare TODO with no cookie is D. Stays out of the agenda — the inbox of priorities. Triage D's up to A/B/C or let them sit.

Rule of thumb: A = dated-and-must; B = the active backlog; C = parking lot; D = untriaged. Fixing the undated A/B tasks means either dating them or demoting to C.

** Tags

The vocabulary is open — topic tags are coined as needed — so these are conventions, not a closed set. A task carries at most one type tag, optionally the effort/autonomy tags, and any number of topic tags. Because the set is open, the task audit leaves topic tags alone (it doesn't strip "unknown" tags).

- *Type* (one per task where the kind is clear): =:feature:= new capability, =:bug:= fix for broken behavior, =:test:= test coverage or test infra, =:refactor:= restructure with no behavior change, =:chore:= tooling / meta / housekeeping.
- *Effort / autonomy*: =:quick:= a spare-moment fix (minutes, not a sitting); =:solo:= Claude can carry it end to end — there's a build path, a test path, and no upfront decision needed (a leftover manual spot-check doesn't disqualify it).
- *Topic / area* (open): the subsystem a task touches — e.g. =:hyprland:= =:waybar:= =:mpd:= =:music:= =:network:= =:tooling:= =:llm:= =:eask:= =:pocketbook:= =:cmail:=. Coin a new one when it aids filtering.
* Archsetup Open Work
** TODO [#B] Bake captive-portal login into the net panel :feature:network:
Make the captive-portal login a first-class net-panel feature instead of the one-off =~/.local/bin/hotel-wifi= script. When the engine sees a held portal, offer "Log in to this network" that runs the plain-DNS + clean-browser flow reversibly (disable DoT -> recover the portal URL from the redirect -> open a clean Chrome profile -> restore DoT when online). Reconcile with the existing =net portal= / =captive= helper, whose DNS-hijack-to-gateway model did NOT match the real Hyatt portal.

Full mechanism writeup, the working script, and the integration plan: [[file:docs/design/2026-06-30-captive-portal-login.org]]. From the 2026-06-30 Hyatt saga.

*** 2026-06-30 Tue @ 11:40 -0400 Engine core landed (dotfiles a7d7559)
Replaced =net portal='s old captive-helper hand-off with a =portal-login= repair tier: drop DoT to plain DNS, probe the portal URL (302 / meta-refresh), open a throwaway browser profile, spawn a detached watcher that restores DoT once online (or on timeout). =net portal --restore= is the manual fallback. 7 tests. So =net doctor= / the bar's =net portal= hookups already run the real flow now. Remaining: (1) name the DoT-blocking cause in =net diagnose=; (2) a dedicated "Log in to this network" button in the panel's Diagnose/Repair tab (today it rides the generic =net portal=); (3) live validation against a real captive portal (unit-tested only — didn't run it live to avoid disrupting a meeting).

*** 2026-06-30 Tue @ 14:59:53 -0400 Live test on velox surfaced two fixed bugs + a deeper follow-up
Force portal (panel Repair tab) = =net-popup net portal= = the same portal-login tier. Tested live on @Hyatt_WiFi (already authorized, so no real intercept). Two bugs fixed in dotfiles (TDD, full suite green):
- Chrome first-run wizard fired on every launch — =_open_portal= made a fresh tempfile profile but passed no first-run flags. Added =--no-first-run --no-default-browser-check= + a unit test.
- Flashing sudo prompt for the DoT drop + pointless resolved restart on velox, where the DoT drop-in the code looks for (=/etc/systemd/resolved.conf.d/dns-over-tls.conf=) doesn't exist. Guarded =_disable_dot=/=_restore_dot= to be true no-ops (no sudo, no restart) when there's no DoT drop-in to move; tests assert no systemctl call fires.

** TODO [#B] Consistent red=off across waybar toggle modules :waybar:
Extend the red=off convention (just added to the touchpad/mouse indicator) to the other toggles — sound volume, microphone mute, and caffeine — so a disabled / muted / off state reads red across the board. Skip the "cross"/slash; the color alone carries it. Origin: roam inbox capture.

** TODO [#B] Microphone-mute keybind :feature:waybar:quick:
A keyboard shortcut to toggle the mic mute. The pulseaudio#mic module shows the state but there's no hotkey to flip it. Wire a hyprland bind to a mic-mute toggle. Origin: roam inbox capture.

** TODO [#B] File-manager swallow pattern :feature:hyprland:
When the file manager launches another app, it should hide to a special workspace (the "swallow" pattern) and return when that process ends, rather than vanishing. Today it disappears with no signal of whether it's coming back, so the user can't tell success from failure — they should quit explicitly instead. Origin: roam inbox capture.

** TODO [#C] Keybind hints in waybar module tooltips :waybar:
Every module's hover tooltip should list its keyboard shortcut(s), for discoverability. Audit the modules and add the bindings to each tooltip. Origin: roam inbox capture.

** TODO [#C] Smooth waybar expansion animation :waybar:
The cluster expansion jumps instead of animating, and a few systray icons pop in one-by-one afterward, which reads as glitchy. Animate the expansion smoothly if waybar allows it — width transitions are limited, so feasibility is uncertain (hence [#C]). Origin: roam inbox capture.

** TODO [#C] Alarm tooltip shows time remaining, not alarm time :bug:waybar:quick:
The =wtimer= alarm tooltip displays the countdown (time remaining) instead of the alarm's wall-clock fire time. For an alarm set to 2:00pm, the tooltip should name the target time, not "1h 23m left". Fix the tooltip rendering in =wtimer= (dotfiles repo). Origin: roam inbox capture.

** TODO [#C] Waybar right-cluster module order :waybar:quick:
Move the timer module to the rightmost position, just left of the systray, and move the battery/sysmonitor module to second-to-rightmost. Config edit in the waybar config (dotfiles hyprland tier). Origin: roam inbox capture.

** TODO [#B] Scrolling/Carousel layout: frame fit + wrap-around :hyprland:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-13
:END:
Disabled 2026-06-12 (bind and cycle entry points removed; Super+Shift+S reassigned to whole-desktop screenshot). The layout needs real work before it earns its chord back:
- What fits in each frame: column/frame sizing so windows land at usable widths instead of arbitrary slices.
- Wrap-around: navigating past the last frame should wrap to the first (and vice versa).
- Whatever else surfaces in daily use once the above land.

The support machinery was deliberately kept for this task: =layout-navigate= and =layout-resize= retain their scrolling branches, =waybar-layout= still renders the scrolling state, and the unbound legacy =cycle-layout= script still lists it. Re-enabling is two lines: add =scrolling= back to =LAYOUTS= in =layout-cycle= and restore a direct-jump bind (the old chord is taken now — pick a new one). The =tests/layout-cycle= suite pins the disabled state and will go red on re-enable, which is the reminder to update it.

** TODO [#B] Pocketbook finish-or-cancel decision :pocketbook:
SCHEDULED: <2026-08-23 Sun>
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
Decide whether to finish the pocketbook app or close and cancel the project. Removed from the waybar setup 2026-06-23 (the org-capture popup covers quick reminders and text for now), so it's out of daily use — this is the checkpoint to commit to it or retire it. Backlog above: [[*Pocketbook development backlog][Pocketbook development backlog]].

** TODO [#B] Provision Eask in archsetup :tooling:eask:
:PROPERTIES:
:LAST_REVIEWED: 2026-05-26
:END:
Add =@emacs-eask/cli= to archsetup's provisioning so fresh machines get it. Eask is installed by hand today and declared nowhere in archsetup or the dotfiles repo, yet both chime and linear-emacs depend on it (their =make setup/test/coverage= shell out to =eask=). Source: handoff from linear-emacs 2026-05-23.

- Add a global npm install after the node block (=archsetup= ~2030, after =aur_install nvm=), modeled on the claude-code native-install block: run as =$username=, wrapped in =display=/=error_warn=, output to =$logfile=. Roughly =sudo -u "$username" bash -c 'npm install -g --prefix "$HOME/.local" @emacs-eask/cli'=.
- Pin the prefix to =~/.local= so eask lands at =~/.local/bin/eask= (already on PATH) and the install runs as the user, not root. On the current machine =npm config get prefix= returns =/usr=, so eask was installed with an explicit =--prefix=.
- Decision: also set a persistent user npm prefix (=~/.npmrc= with =prefix=${HOME}/.local=)? If yes, that =~/.npmrc= is a legitimate dotfile to stow; if no, rely on the explicit =--prefix= flag alone. =~/.eask/= is a regenerable cache — leave un-stowed.
- Acceptance: fresh run leaves =eask= on PATH at =~/.local/bin/eask= (no root); =cd ~/code/chime && make setup && make test= works.

** TODO [#B] Network panel redesign — no terminals, verify-everything, full failure coverage :feature:waybar:network:
Major evolution of the shipped =custom/net= module ([[file:docs/design/2026-06-29-waybar-network-module-spec.org]]).
Reverses the spec's "privileged tiers run in a net-popup terminal" decision. Origin:
design conversation 2026-06-30.

*** Locked decisions
- *No terminals anywhere in the module.* Delete =net-popup= entirely. Every action and
  every result renders in the panel.
- *Passwordless privileged path (the enabler).* A single root-owned helper runs net's
  specific privileged commands (rfkill unblock, nmcli modify/up, networking off/on,
  systemctl restart NetworkManager/systemd-resolved, resolvectl dns/revert, DoT toggle),
  installed by archsetup with a narrow NOPASSWD sudoers rule scoped to that helper only
  (never blanket mv/systemctl). =repair.py= calls =sudo <helper> <verb>=. This supersedes
  and absorbs the earlier [#C] "Passwordless DoT toggle" follow-up. Without it an in-panel
  worker thread can't prompt for a password, so this gates the whole no-terminal goal.
- *Verify every action.* Every mutating op confirms its effect before reporting success
  (doctor already re-probes; generalize so each repair, connect, forget, add, and DNS
  override re-checks and surfaces pass/fail in the panel).
- *Detect + respond to every failure mode below* (auto-fix where we can, else report the
  helpful text), including the edge cases.

*** Navigation (confirmed)
- Top tabs: =Connections= | =Diagnostics= | =Performance=.
- Connections: saved + in-range list, connect / add / forget.
- Diagnostics: sub-row =Diagnose= | =Get Me Online= | =Advanced=; shared area below shows
  diagnose items AND streams repair progress (replacing the terminal). =Advanced= reveals
  the individual repair buttons, renamed with tooltips describing each.
- Performance: Speedtest (+ live throughput later).

*** Failure-mode catalog — detect / correct-or-report (the completeness backbone)
Organized by the connectivity stack, bottom-up. "Fix" = auto-correct + verify; "Report" =
the in-panel text when there's no safe auto-fix. Audit this list for completeness; it is the
contract for what diagnose must detect and what the panel must say.

**** Radio / hardware
- rfkill soft block — Detect: rfkill soft. Fix: unblock + =nmcli radio wifi on=, verify radio unblocked.
- rfkill hard block — Detect: rfkill hard. Report: "WiFi is off at the hardware switch — flip the physical switch or Fn key."
- No WiFi adapter present — Detect: no wifi device in nmcli + rfkill absent. Report: "No WiFi adapter detected — use ethernet, or check the driver (dmesg | grep firmware)."
- Driver/firmware not loaded — Detect: device present but errored / no operational state. Report: "WiFi driver or firmware didn't load — check dmesg for the adapter."
- USB WiFi adapter unplugged — Detect: device disappeared since last scan. Report: "WiFi adapter was removed — reconnect it."
- Airplane mode on — Detect: airplane state file set. Fix: offer toggle off (Super+Shift+A), verify radios back.

**** Association (L2 link)
- Not connected / disconnected — Detect: link down, device disconnected. Fix: reset (reconnect saved), verify link up.
- Stuck "connecting" — Detect: device state connecting > budget. Fix: reset, verify; if it persists Report: "Stuck connecting to <ssid> — the AP may be rejecting us."
- Weak signal / high loss — Detect: associated but signal below threshold (dBm) or heavy packet loss. Report: "Signal is weak (<dBm>) — move closer to the access point."
- Saved network not in range — Detect: profile active target not in scan. Report: "<ssid> isn't in range here."
- AP roaming flap — Detect: BSSID bouncing. Report: "Connection is unstable — switching between access points."

**** Authentication
- Wrong WPA password / missing secret — Detect: NM state 120 (snapshot; live detection is a known limit). Report + in-panel re-enter: "Saved password for <ssid> was rejected — re-enter it."
- Enterprise / 802.1X cert or identity failure — Detect: 802.1X profile + activation failure. Report: "Enterprise auth failed — check the certificate or identity (edit the profile)."
- Randomized MAC rejected by AP — Detect: reset-with-random-MAC fails where a prior connect worked. Fix: retry reset with the permanent MAC, verify; else Report.
- WPA3/SAE incompatibility — Detect: SAE key-mgmt + association failure. Report: "This network needs WPA3 and the adapter or profile may not support it."

**** IP / DHCP
- No IPv4 lease (DHCP timeout) — Detect: connected, no IP4.ADDRESS. Fix: reset → bounce, verify lease.
- APIPA / link-local only (169.254.x) — Detect: only a link-local IPv4. Fix: reset/bounce, verify real lease; else Report: "DHCP server didn't answer — switch network."
- IPv6-only network (no IPv4 by design) — Detect: no IPv4 but IPv6 address + online via v6. Report (not a failure): "Online over IPv6 (no IPv4 here)." Requires making diagnose IPv6-aware.
- IP but no gateway — Detect: IP4.ADDRESS present, IP4.GATEWAY empty. Fix: bounce, verify gateway; else Report.
- Duplicate IP / ARP conflict — Detect: kernel ARP-conflict signal. Report: "Another device is using our IP address — reconnect to get a new lease." (edge)

**** Gateway (L3 local)
- Gateway unreachable — Detect: no route out, gateway no ICMP. Fix: try one bounce (renew route), verify online; else Report: "No route to the gateway — switch network." (closes the spec/code gap where bounce was never tried)

**** DNS
- No resolver configured — Detect: IP4.DNS empty. Fix: bounce to re-pull DHCP DNS, verify; else Report.
- Venue DNS broken, public DNS works — Detect: name fails to resolve but 1.1.1.1 resolves (dns-test). Fix: set a PERSISTENT resolver override (1.1.1.1 / 9.9.9.9), verify resolution + online, offer revert. (closes gap #1 — today dns-test reverts and misreports as upstream.)
- DNS hijack (resolves to gateway / private IP) — Detect: classify_resolution hijack. Treat as captive → portal-login flow.
- DNSSEC validation failure — Detect: resolution fails with SERVFAIL where public resolver succeeds without DNSSEC. Report: "DNS security checks are failing on this network." (edge)
- Encrypted DNS (DoT/DoH) hiding the portal — Detect: captive suspected + DoT on. Fix: portal-login drops DoT, opens portal, auto-restores. (existing)

**** Egress / internet
- Upstream / AP outage (no uplink) — Detect: link/IP/DNS fine, http-probe fail, not a redirect. Report: "This network has no internet — switch network or contact the venue."
- Captive portal (redirect) — Detect: probe redirected. Fix: portal-login opens the page; verify online after login.
- Captive blocked pre-auth (no portal URL) — Detect: probe blocked, no URL. Fix: fresh MAC + open trigger; verify.
- Proxy-required network — Detect: probe fails but a PAC/proxy is advertised (WPAD/env). Report: "This network requires a proxy — configure it in settings." (edge)
- MTU / MSS blackhole (PMTUD broken) — Detect: small probe ok, large transfer hangs. Fix: lower the interface MTU, verify; else Report. (edge)
- Clock skew breaking TLS — Detect: HTTPS/portal fails with cert-time errors + system clock far off. Fix: trigger a time sync, verify; else Report: "System clock is wrong — fix the date/time." (edge)

**** Routing / multi-homing
- VPN owns the route, no internet through it — Detect: VPN device connected + http-probe fail. Report: "Internet is routed through a VPN (<dev>) — check the VPN, not WiFi."
- VPN up but dead — Detect: VPN device up, no traffic/handshake. Report: "The VPN is connected but not passing traffic." (Phase 5 territory)
- WiFi + tether/ethernet both active — Detect: which iface owns the default route + whether the system is online by any path. Report: "You're online through <other iface>; WiFi itself has no internet," or let the user pick. (closes gap #4)

**** Infrastructure / system
- Wedged NetworkManager — Detect: nmcli fails / API unresponsive. Fix: restart NetworkManager (bounce escalation), verify.
- NetworkManager not running — Detect: service inactive. Fix: start it, verify; else Report.
- systemd-resolved down — Detect: resolved inactive / DNS via it fails. Fix: restart, verify.
- resolv.conf not resolved-managed — Detect: /etc/resolv.conf not the resolved stub. Report: "DNS isn't managed by systemd-resolved — manual resolv.conf in play." (edge)

**** Tooling / environment
- nmcli / NM API unavailable — Detect: nmcli error or timeout. Report: "Can't reach NetworkManager — is it installed and running?"
- Slow / hung tool — Detect: step exceeds budget. Fix: degrade that step, retry within budget.
- Stale / corrupt cache — Detect: schema/age mismatch. Fix: self-heal (atomic write + invalidation).
- Missing speedtest backend — Detect: speedtest-go absent. Report: "Install speedtest-go to run a speed test."
- Privileged op fails (helper missing / sudo declined) — Detect: helper exits non-zero or absent. Report: "Couldn't get admin rights for this repair — <install/fix the helper>."

*** TODO Sudo helper + NOPASSWD sudoers (gates everything; archsetup-installed)
Root-owned =/usr/local/bin/net-priv= (or similar), NOT stowed/user-writable, dispatching net's
fixed privileged verbs; narrow NOPASSWD sudoers scoped to that helper only. =repair.py= calls
=sudo net-priv <verb>=. Must also fix the latent bug it unblocks: the detached
=portal_restore_watch= runs with no tty and can't prompt, so today =_restore_dot= silently fails
when sudo creds aren't cached, leaving DNS unencrypted until a manual =net portal --restore=.
Separately reconcile where velox's DoT actually lives (currently -DNSOverTLS, no drop-in, so the
"drop DoT" step is a no-op there; =NET_DOT_CONF= overrides the path) — decide whether velox should
run DoT at all.
*** TODO Merged Diagnostics panel + nav restructure (Connections | Diagnostics | Performance)
**** 2026-06-30 Tue @ 17:36 -0400 Dispositioned the 4th-review findings into the spec
Codex's 9 fourth-review findings (8 accept, 1 modify) are folded into the spec's
"V2 panel UX — the target design" section (cookie [40/40]): single nav target,
saved-vs-available groups, join-from-row instead of Add, the auth-class join matrix,
progressive loading, future-tense + verified Forget, a findable redacted diagnostics
report, the Waybar visual contract, and a lightweight inline latency probe (full speed
test stays under Performance per decision 19). The V2 build below implements that
design: [[file:docs/design/2026-06-29-waybar-network-module-spec.org::*V2 panel UX][V2 panel UX]].
*** TODO Make diagnose IPv6-aware and multi-homing-aware
*** TODO Close every detect/correct gap in the catalog, with post-action verification
*** TODO Automatic diagnostic verbose-capture (failing diagnose + Advanced toggle)
On =overall: fail=, elevate the underlying stack (NM =WIFI,DHCP,DNS,CORE= / systemd-resolved /
wpa_supplicant) to debug at runtime, run the escalation, capture the journal + dmesg window +
=curl -v=, then restore every level. Also a manual "Debug on/off" toggle in Advanced for
reproducing intermittent failures. HARD: restore is guaranteed (try/finally) AND crash-guarded
(next run detects a left-elevated stack and restores it, like the DoT-restore watcher); the
captured journal is REDACTED before the bundle is written/shown (raw wpa_supplicant/NM debug
carries the PSK/EAP secret in cleartext) with a secret-leak test; log-level toggles run via the
V2 sudo-helper. Bonus: wpa_supplicant debug catches wrong-password/EAP failures the current NM
state-120 snapshot misses, so it also closes the auth live-detection gap. Spec: Observability →
"Automatic diagnostic verbose-capture". Origin: Craig 2026-06-30.

** TODO [#B] Waybar network module — custom/net :feature:waybar:network:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-29
:END:
Unifies the old wifi-no-internet indicator (was =[#C]=) and the network-manager
dropdown (was =[#B]=) into one =custom/net= module: a tested Python =net= engine
(nmcli + diagnostics), a thin bar indicator, and a GTK4 layer-shell panel. Code
lives in the dotfiles repo (hyprland tier + a =net/= package like pocketbook);
archsetup only installs deps. Secrets stay in NetworkManager's own store (no
separate credential store). The =captive= script becomes the diagnostics engine.
Full design, acceptance criteria, and the failure-mode coverage table:
[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]].

Phases below, dependency order. Engine/unit work is agent-verifiable (=unittest=
+ fakes on PATH, coverage via venv); the live-network and visual states need real
conditions, filed under "Manual testing and validation".

*** 2026-06-29 Mon @ 20:19:11 -0400 Phase 1 shipped — indicator + console recovery
Shipped to the dotfiles repo (10 commits, =5254bd8=..=c095a22=, pushed to main).
The =net= engine is a src-layout Python package in-tree, imported by a bin shim
that resolves the stow symlink back to the repo — so it runs from a bare TTY with
no install, which the recovery path depends on.

Landed: =net status= (fast path, one nmcli call + sysfs, degraded fallback in
budget) + =net probe= (native captive probe, single-flight flock, atomic cache,
fresh/stale/expired/unknown classes, iface/SSID/UUID invalidation); =waybar-net=
replacing =custom/netspeed=, throughput → tooltip, CSS states in both themes +
live; =net diagnose= (read-only steps) + =net repair= (rfkill/reset/bounce/
dns-test, cleanup-verified) + =net doctor [--fix]= with the four terminal
classifications; =net portal= + the =captive --probe-json= refactor; redacted
JSONL event log; Makefile recovery targets (=make online= etc.); =~/.config/net/
config=. Verified live: =make net-status= reads the real wlp170s0 / @Hyatt_WiFi.

Airplane (Craig's call, option 1): =custom/net= absorbs only the *display* — net
reads the airplane-mode state file and shows an airplane state/glyph. The
airplane-mode toggle stays (it's a low-power mode — radios + CPU + brightness +
services — not a radio switch), now on =custom/net='s right-click + signal 15.
Deleted: =waybar-airplane=, =waybar-netspeed=, =custom/airplane=, their tests +
css. =airplane-mode= kept.

Tests: 160 in =tests/net/= (fake nmcli/curl/rfkill/resolvectl/ping/getent/
systemctl on a temp PATH; doctor-classification fixtures; degraded-under-slow-
nmcli benchmark) + the =captive= probe-mode tests; full dotfiles suite green (32
suites). Coverage-gap pass via throwaway venv: pure modules ≥90% branch
(classify 100%), IO-error branches excused in the test docstring.
Deferred to Phase 2/3: archsetup deps (gtk4-layer-shell/python-gobject Phase 2,
speedtest-go-bin Phase 3 — not added before the code that needs them).
Verify (manual, live): see Manual testing and validation.

*** 2026-06-29 Mon @ 22:19:25 -0400 Phase 2 shipped — panel shell + connection management
Shipped to dotfiles (commits =4e7740f=..=24bcac5=, pushed). Engine: =net list= (saved
MRU + in-range wifi scan, infrastructure types filtered), =net up/down= (UUID-keyed,
mutation safety — keep prior link until target activates, classify wrong-password vs
generic, report auto-reactivation), =net add/edit/remove/rescan= (open + WPA-PSK;
enterprise activate-only; secret to NM's store, never our JSON/log — tested).

Panel: a GTK-free PanelModel (selection, four state machines, the UX-flow enable
rules, terminal states) + a GTK4 gtk4-layer-shell window (=net panel=) anchored
top-right under the bar — Connections section with MRU list, active marked, signal
glyph, row-click select, Connect/Add/Forget/Rescan, confirm-on-forget, worker-thread
engine calls via GLib.idle_add. GTK imported lazily so the CLI/tests stay GTK-free.

Bar interactions (settled with Craig over live iteration): left = =net-panel= toggle,
middle = =net portal=, right = =net-fix= (notify the doctor result when one-way; open
a terminal only when the outcome is fixable — the sudo/interactive case). Airplane on
Super+Shift+A. archsetup adds =gtk4-layer-shell= + =python-gobject= (this commit);
already on velox.

Tests: 204 in tests/net (merge ordering/dedup, up/down mutation safety, no-secret-leak
on add/edit, panel model + state machines, gui row-format helpers). Full dotfiles suite
green (32 suites). Live-verified on velox: panel opens/toggles, list shows real 24
profiles, right-click notification delivers (Craig confirmed). Phase 3 (diagnose/repair/
speedtest IN the panel) is next; the engine for it already exists from Phase 1.

*** 2026-06-29 Mon @ 22:43:40 -0400 Phase 3 shipped — diagnostics + speed test in the panel
Shipped to dotfiles (=91277cf=..=691abcb=) + archsetup (=48052d6=, speedtest-go-bin),
pushed. Engine: =net speedtest= (parses speedtest-go --json → ping from latency ns,
down/up from per-server byte rates; missing-backend / offline / malformed → error
envelope per the failure table). Panel grew a section switcher with four pages:
- Connections (Phase 2).
- Diagnose: =net diagnose= on a worker thread, each step a row (✓/✗/… glyph + title +
  redacted evidence), read-only; Open-portal button when captive.
- Repair: "Get me online" (=net doctor --fix=) + tiers (rfkill/reset/bounce/dns-test)
  + force portal. Confirmations in-panel with the spec's exact wording; the privileged
  tiers run via =net-popup= terminal (where the sudo prompt + step output, incl.
  cleanup-verified, show) — a panel has no tty, and pkexec would mean a prompt per op.
- Speed test: in-process =net speedtest= (no privilege → inline result: ↓/↑ Mbps + ping
  + server), Run/Cancel (Cancel pkills the child), error envelope shown.

213 net tests; pure helpers (step_indicator, format_speedtest) unit-tested. Full
dotfiles suite green (32 suites). One unverified assumption: speedtest-go's dl/ul unit
(taken as bytes/s; =BYTES_PER_SEC= flips it) — needs one real run vs a reference. The
in-panel repair streaming (vs terminal) is a named future polish once the GUI-privilege
story settles.

The waybar network module ([#B] parent) is now COMPLETE through Phase 3. Phase 4
(in-app help + user guide) and Phase 5 (VPN/WireGuard) remain as future work; the core
feature (indicator + recovery + panel + diagnostics + speed test) is done.
Verify (manual, live): see Manual testing and validation.

*** TODO Phase 4 — docs + rollout :network:
Deliverable: in-app help (=net --help= + per-command, panel help affordance);
README/user-guide (commands, indicator states, panel, config keys, make targets,
troubleshooting from the failure table, rollback); archsetup Hyprland dep install
(=gtk4-layer-shell=, =python-gobject=, =speedtest-go-bin=); ratio manual dep +
stow step.
Verify: =net --help= and each subcommand complete; user-guide covers every command
+ the recovery targets.

*** TODO Phase 5 — VPN / WireGuard (vNext) :network:
Fold the existing archsetup wireguard tooling into the panel + CLI (=net vpn ...=).
Out of the v1 milestone; spec separately when picked up. (v1 only detects +
classifies a VPN-routed failure, it doesn't repair it.)

** TODO [#B] Desktop-settings dropdown panel :waybar:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
One waybar dropdown gathering the desktop toggles and sliders into a single settings panel, opened from a gear/settings glyph on the bar. Incorporate:
- *Auto-dim* toggle (the =custom/dim= feature just shipped — fold in here, or keep the standalone indicator and mirror it).
- *Brightness* slider (backlight, via brightnessctl).
- *Keyboard-backlight* brightness slider (brightnessctl on the kbd_backlight class).
- *Mouse* enable/disable toggle — shown only when a mouse is connected.
- *Trackpad* enable/disable toggle — shown only when a trackpad is connected (mirror =toggle-touchpad= / =touchpad-auto=).
- *Idle inhibitor* (the =custom/idle= module that replaced the built-in =idle_inhibitor= 2026-06-24 — toggles the hypridle daemon, state-synced icon).
- *Airplane mode* (the existing =airplane-mode= toggle; laptop-only).

The conditional rows (mouse, trackpad, airplane) appear only when their hardware/context applies — reuse the laptop/device detection the airplane and touchpad indicators already do.

Design / open questions (propose before building):
- Panel tech: sliders need a real toolkit (waybar can't host a slider), so a GTK4 + gtk4-layer-shell app like pocketbook is the likely shape.
- Which existing standalone bar modules (dim, touchpad, airplane, idle_inhibitor) collapse INTO this panel vs. stay on the bar as quick-access indicators. Craig's call.

Implementation notes: a small GTK layer-shell app (mirror pocketbook's structure: src-layout Python package, pytest, Makefile) talking to brightnessctl / hyprctl / the touchpad + airplane helpers. Lives in the dotfiles repo or in-tree like pocketbook. TDD the backing toggle/slider logic. Sizable — worth a design doc first.

** TODO [#B] Local offline LLM runtime + per-host model cache :tooling:llm:
:PROPERTIES:
:LAST_REVIEWED: 2026-05-29
:END:
Add a local-LLM provisioning track so machines can run an offline coding agent when there's no network. Install =llama.cpp= (CPU + Vulkan where practical) and prefetch per-host model files while network is available; expose OpenAI-compat local endpoints (=127.0.0.1:8081= coding, =:8082= general; =:11434= reserved for =ollama= if used). Per the rulesets generic-agent-runtime design pass — rulesets becomes runtime-neutral and owns the runtime manifests + project instructions; archsetup owns machine provisioning + the per-machine model inventory. Source: handoff from rulesets 2026-05-28 ([[file:assets/outbox/2026-05-28-from-rulesets-local-llm-install.org][outbox copy]]).

Per-host model targets (from the handoff):
- *ratio* (Strix Halo, 128 GiB) — Qwen3-Coder-30B Q6_K (default) + Q4_K_M (compat) + Qwen3-Next-80B Q4_K_M (long-context fallback).
- *velox* (i7-1370P, 64 GiB iGPU) — Qwen3-Coder-30B Q4_K_M + an 8B fallback for low-latency triage.

Install behavior: prefetch idempotent (skip if file exists, match size/hash); download failure must NOT fail the install — surface a clear "local LLM support incomplete" follow-up instead. Ship a smoke-test command (boot endpoint + short prompt).

Decisions to resolve before code:
*** TODO Decide model cache location: per-user vs system-wide
Handoff lists both =~/.local/share/llm/models= (per-user) and =/srv/models/llm= (system-wide). Per-user matches the existing archsetup user-config style and avoids root ownership of large model files. System-wide matches the "machine-local model inventory" phrasing and shares cache across users on multi-user boxes (not the case here — single user per machine). Pick one as the default; the other stays available via =LLM_MODEL_CACHE=.
*** TODO Decide whether =ollama= ships by default or is opt-in
Handoff calls =ollama= "optional". Likely shape: =llama.cpp= is the only mandatory runtime; =ollama= behind =INSTALL_OLLAMA= (default no) for users who prefer its model-manager API. Confirm.
*** TODO Define config keys for the LLM block in =archsetup.conf.example=
Likely: =INSTALL_LOCAL_LLM= (default yes), =LLM_RUNTIME= (=llama.cpp= / =ollama=), =LLM_MODEL_CACHE= (path), =LLM_MODELS= (space-separated, or empty → per-host autodetect). Lock names + defaults before writing install code.
*** TODO Decide per-host model selection: auto-detect by =uname -n= vs explicit =LLM_MODELS=
Auto-detect against a known-host table (ratio → Q6_K + 80B, velox → Q4_K_M + 8B) is simple for current machines but brittle for any new host (silently picks no models). Explicit =LLM_MODELS= per machine in =archsetup.conf= is more verbose but never surprises. Pick the default; the other stays available.
*** TODO Decide network-down behavior for model prefetch
Three shapes: (a) emit =error_warn= and write =/var/lib/archsetup/state/llm-models-pending= for inspection; (b) install a one-shot systemd unit that retries on next boot with network; (c) just log and forget — user re-runs the prefetch helper manually when network returns.

Implementation work (gated on the decisions above):
*** TODO Install =llama.cpp= with CPU + Vulkan backend where supported
Add to the appropriate install section in =archsetup= (=llama.cpp= / =llama.cpp-vulkan= in AUR). Decide CPU-only vs Vulkan per host from the hardware detection already used for GPU drivers.
*** TODO Install =ollama= behind config flag (if Decision 2 = opt-in)
Add =ollama= package install gated on =INSTALL_OLLAMA=yes=.
*** TODO Configure shared model cache + OpenAI-compat local endpoints
Create =$LLM_MODEL_CACHE= with the right ownership; configure llama.cpp (and ollama if installed) to serve =127.0.0.1:8081= (coding) and =:8082= (general). Likely systemd user units; decide launcher pattern when implementing.
*** TODO Prefetch per-host models (idempotent, non-fatal on network failure)
Download the per-host model set (from Decision 4) into the cache; skip files that exist with matching size/hash. On failure, fall back per Decision 5. Models from HuggingFace GGUF mirrors (URLs locked at implementation time).
*** TODO Ship a local-LLM smoke-test command
Boot the configured endpoint and send a short prompt; surface success/failure + timing. Useful as both a post-install check and a triage tool when something later breaks. Likely =scripts/llm-smoke-test.sh=; runs at end of install if =INSTALL_LOCAL_LLM=yes=.

Acceptance: fresh VM install of the ratio profile reaches an endpoint on =:8081= that answers a smoke prompt; velox profile gets Q4_K_M + 8B and answers a prompt within reasonable laptop latency; network-down install completes successfully with the pending-models warning surfaced.

** TODO [#B] Review post-archsetup laptop setup steps (velox 2026-04-10)
:PROPERTIES:
:LAST_REVIEWED: 2026-06-09
:END:
Items discovered during velox setup that needed manual intervention after archsetup.
Decide which should be automated in archsetup vs documented as post-install steps.

*** TODO Review: rfkill soft blocks bluetooth and wifi at boot
Both bluetooth and wifi were soft-blocked by rfkill. Fix was ~rfkill unblock bluetooth/wifi~.
~systemd-rfkill~ persists state, so unblocking once should stick, but new installs may default to blocked.
Consider: add ~rfkill unblock all~ to archsetup post-install or a firstboot script.

*** TODO Review: /efi mount permissions world-accessible (security)
Default vfat mount had ~fmask=0022,dmask=0022~. Fixed to ~fmask=0077,dmask=0077~ in fstab.
~bootctl~ warned about world-accessible random seed file.
Consider: set restrictive fmask/dmask in archsetup's fstab generation.

*** TODO Review: tmpfs layered over ZFS /tmp causing systemd-tmpfiles failures
~systemd-tmpfiles-clean.service~ failed repeatedly with "Protocol driver not attached".
Root cause: systemd's ~tmp.mount~ (tmpfs) mounted before ZFS's ~/tmp~ dataset, creating a stale layer.
Fix: ~systemctl mask tmp.mount~. Consider: mask tmp.mount in archsetup when ZFS is used.

*** TODO Review: intel-ucode not installed
CPU running old microcode. Installed ~intel-ucode~ and rebuilt initramfs.
Consider: add intel-ucode (or amd-ucode) to archsetup package list based on CPU vendor.

*** TODO Review: syncthing installed but not enabled
Package was installed but service was not enabled. Fixed with ~systemctl enable --now syncthing@cjennings~.
Consider: enable syncthing service in archsetup post-install.

*** TODO Review: awww-daemon crashes at boot (coredump)
Wallpaper daemon crashed with abort() shortly after boot. Hyprland also coredumped at same time.
May be a race condition. Restarting awww-daemon fixed it. Monitor for recurrence.

*** TODO Review: touchpad-indicator missing (X11 only, no Wayland equivalent)
Old ~touchpad-indicator-git~ was X11-only and removed as broken.
Created ~touchpad-auto~ (auto-disable touchpad when mouse connected) and ~toggle-touchpad~ scripts.
~touchpad-auto~ watches Hyprland socket for mouseadded/mouseremoved/configreloaded events.
Device name ~pixa3854:00-093a:0274-touchpad~ is hardcoded — will differ on other machines.
Added to exec-once and $mod+F9 keybinding.
Consider: add scripts to stowed dotfiles, make touchpad device name auto-detected.

*** TODO Review: Bluetooth mouse pairing is manual post-install
Paired Logi M650 via ~bluetoothctl scan on~, ~pair~, ~trust~.
This is inherently interactive (scan, select device, pair, trust).
Consider: document as post-install step. No automation possible.

*** 2026-05-26 Tue @ 13:32:31 -0500 pocketbook install concern moot — pulled from publication, folded in-tree
Resolved by removing pocketbook from archsetup's provisioning entirely. It's nowhere near ready, so the github mirror + cjennings.net repo were deleted and the project was folded into the archsetup tree at =pocketbook/=. Dropped the =gtk4-layer-shell= dep + =pip_install= from =archsetup= and the clone from =scripts/post-install.sh=. No fresh install pulls pocketbook now, so "not installed on velox" no longer applies. Re-wiring the install is tracked in the new pocketbook development backlog.

*** TODO Review: Tailscale needs login after install
~tailscaled~ service was enabled but needed ~tailscale up~ for interactive auth.
Old machine entry needed cleanup in admin console.
Consider: document as post-install step.

*** TODO Review: docs/ directories need manual sync from existing machine
docs/ dirs (gitignored) for ~/code and ~/projects repos needed scp/rsync from ratio.
Same for ~/.emacs.d/docs/. Not in git, so not available after clone.
Consider: document as post-install step or create a sync script.

** TODO [#B] Test + CI infrastructure :test:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-28
:END:
Umbrella for the test-harness and CI-automation buildout. Consolidated from the 2026-06-28 task audit: these were scattered top-level tasks circling one effort, re-homed as children so the work reads as a unit. Each child ships independently and keeps the priority it carried before. No CI runner exists yet, so the CI/CD-pipeline child gates several of the others.

*** TODO [#B] Build CI/CD pipeline that runs archsetup on every commit
:PROPERTIES:
:LAST_REVIEWED: 2026-06-13
:END:
Core automation infrastructure - enables continuous validation
*** TODO [#B] Generate recovery scripts from test failures
:PROPERTIES:
:LAST_REVIEWED: 2026-06-13
:END:
Auto-create post-install fix scripts for failed packages - makes failures actionable
*** TODO [#B] Establish monthly review workflow
:PROPERTIES:
:LAST_REVIEWED: 2026-06-13
:END:
The diff engine now exists (=scripts/package-inventory= / =make package-diff=), so what remains here is the cadence, not the tooling: a scheduled prompt to run the diff and act on it. Subtasks 1-2 are the recurring human judgment the engine feeds; subtask 3 is the automation to schedule it.
**** TODO [#B] For packages in archsetup but not on system: determine if still needed
**** TODO [#B] For packages on system but not in archsetup: decide add or remove
**** TODO [#B] Schedule monthly package diff review
*** TODO [#B] Set up automated test schedule
:PROPERTIES:
:LAST_REVIEWED: 2026-06-28
:END:
Weekly full run to catch deprecated packages even without commits
*** TODO [#B] Implement manual test trigger capability
:PROPERTIES:
:LAST_REVIEWED: 2026-06-28
:END:
Allow on-demand test runs when automation is toggled off
*** TODO [#B] Create test results dashboard/reporting
:PROPERTIES:
:LAST_REVIEWED: 2026-06-28
:END:
Make test outcomes visible and actionable
*** TODO [#B] Block merges to main if tests fail
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Enforce quality gate - broken changes don't enter main branch
*** TODO [#B] Add network failure testing to test suite
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Simulate network disconnect mid-install to verify resilience
*** TODO [#B] Keep VM base images up to date
:PROPERTIES:
:LAST_REVIEWED: 2026-06-28
:END:
Regular updates to the Arch base VM image (qemu, built by =create-base-vm.sh=) with a review process and schedule. The harness is VM/qemu-based, not containers.
*** TODO [#B] Persist test logs for historical analysis
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Archive logs with review process and schedule to identify failure patterns and trends
*** TODO [#B] Implement automated deprecation detection
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Parse package warnings and repo metadata to catch upcoming deprecations proactively
*** TODO [#B] Monitor and optimize test execution time
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Keep test runs performant as installs and post-install tests grow (target < 2 hours)
*** TODO [#B] Set up alerts for deprecated packages
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Proactive monitoring integrated with testing
*** TODO [#B] Fix VM cloning machine-ID conflicts for parallel testing
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Currently using snapshot-based testing which works but limits to sequential test runs
Cloned VMs fail to get DHCP/network even with machine-ID manipulation (truncate/remove)
Root cause: Truncating /etc/machine-id breaks systemd/NetworkManager startup
Need to investigate proper machine-ID regeneration that doesn't break networking
Would enable parallel test execution in CI/CD
Priority C because snapshot-based testing meets current needs

** TODO [#B] Fix install errors surfaced by the 2026-05-11 VM test run
:PROPERTIES:
:LAST_REVIEWED: 2026-06-15
:END:
*** 2026-06-28 Sun @ 13:29:29 -0400 Audit reconcile: 2026-06-28 btrfs+zfs runs reproduce the same residual set
Newer full runs landed since the 2026-06-11 reconcile below: the 2026-06-25 zfs run (Testinfra 96/0) and the 2026-06-28 btrfs+zfs runs (97/0, "zero attributed issues"). The residual four were NOT fixed and reproduce unchanged: =enabling firewall= (archsetup:1496-1498, carries a VM-kernel note), =enabling gamemode for user= (archsetup:2221, non-critical), and =tidaler (AUR)=. Zero archsetup-attributed Testinfra issues across both profiles confirms these are environment / non-critical, not archsetup bugs. Bare-metal confirmation of the firewall pair is still the open thread.

*** 2026-06-15 Mon @ 23:53:21 -0500 Audit reconcile: latest VM run (2026-06-11) confirms the surviving error set
The most recent VM run (=test-results/20260611-113904/=) carries four error-summary entries: =enabling firewall= + =verifying firewall is active= (the iptables/nf_tables "Could not fetch rule set generation id" pair, still unconfirmed on bare metal), =enabling gamemode for user= (non-critical), and =tidaler (AUR)=. The earlier fontconfig/dconf fixes held — none reappear. So the count is down from the 7→6 anchor below to four, all of them the known-residual items already itemized.
Errors logged during the VM install. Status as of the 2026-05-11 18:36 run (=test-results/20260511-183643/archsetup-output.log=) after the =48c9439= fontconfig/dconf fix: 7 → 6.
- refreshing font cache — RESOLVED in =48c9439= (now installs =fontconfig= before calling =fc-cache=).
- configuring GTK file chooser — RESOLVED in =ecab29f= (switched to a system-wide dconf db at =/etc/dconf/db/site.d/=; needs no session bus during install).
- configuring GNOME interface settings in dconf — RESOLVED in =ecab29f= (same fix as the GTK file chooser above).
- enabling firewall — exit 1: =iptables v1.8.13 (nf_tables): Could not fetch rule set generation id: Invalid argument=. Still present in the 18:36 run; likely a VM-kernel/nf_tables artifact — confirm on bare metal before treating as an archsetup bug.
- verifying firewall is active — exit 1 (follow-on from the firewall-enable error).
- enabling gamemode for user — exit 1 → step "gaming" FAILED — non-critical.
- tidaler (AUR) — logged in the error summary with exit code 0 (odd; logging quirk or transient AUR build noise?).
Also seen in the 18:36 run's log-diff (post-install systemd noise, probably VM-environment): =pam_systemd … CreateSession failed= / =logind: Failed to start session scope … Permission denied=, and =Failed to start Proton VPN Daemon= (no VPN config in the test VM).

*** 2026-05-19 Tue @ 13:18:56 -0500 Fixed AUR exit-0 logging bug at the root
Root cause was in =retry_install=: =last_exit_code=$?= ran AFTER =if eval ...; then return 0; fi=. Bash defines an if-compound's exit status as zero when no condition tested true, so a failing eval's exit code got overwritten with 0 before reaching =error_warn=. Fix in =8221c54=: capture =$?= from =eval= directly into a local var, then compare against the captured value in the if. VM-verified in =test-results/20260519-115318/=: =mkinitcpio-firmware (AUR)= and =tidaler (AUR)= now report =error code: 1= (yay's actual exit) instead of the misleading =error code: 0=. The same packages still appear in the summary because yay returns non-zero when sub-deps fail to build (e.g. =aic94xx-firmware=), but the codes are accurate now. If the underlying sub-dep failures stay noisy, that's a separate concern — open a new task.

*** 2026-05-16 Sat @ 09:00:41 -0500 AI Response: Surfaced the expanded AUR-exit-0 pattern
2026-05-16 07:40 VM run passed (52/0/5) with the same warning profile as the 2026-05-11 18:36 run. Error count went 7 → 13: 5 fixed/unchanged, +5 new AUR-exit-0 entries (broadens the existing tidaler item into the dedicated =[#B]= subtask above), +1 genuinely new error in =setting up emacs configuration files= (=git pull= ran in =~/.emacs.d= which existed from stow but had no =.git=). Patched =archsetup:1932-1945= with a three-branch check: clone if missing/empty, pull if =.git= exists, =git init=/=fetch=/=checkout= in place if the dir came from stow.

*** 2026-05-19 Tue @ 01:25:26 -0500 Verified the b9907c7 emacs-stow fix end-to-end
=make test= 21:44 → 22:29 (42 min), =test-results/20260518-214516/=. 52/0/5, =ArchSetup Exit Code: 0=. The third-branch path fired correctly — install log =archsetup-2026-05-18-21-45-46.log:14358-14365= shows =From https://git.cjennings.net/dotemacs= → =[new branch] main -> origin/main= → =Reset branch 'main'= → =branch 'main' set up to track 'origin/main'=. No exit-128, no =fatal: not a git repository=. Error Summary down to 7 (was 13 on 2026-05-16); the emacs entry is gone. AUR exit-0 logging triggered for 2 packages this run (mkinitcpio-firmware, tidaler) vs 6 on 2026-05-16 — same bug class, fewer triggers, still tracked under =[#B] AUR exit-0 logged as error=. Issue Attribution: 1 ARCHSETUP entry (Proton VPN Daemon failed — known VM-no-VPN-config artifact). Cleanup ran clean via the normal path.

** TODO [#B] Review undeclared ratio packages for installer inclusion
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
Triggered by the 2026-06-14 =make package-diff= run on ratio: 62 packages are installed but not declared in archsetup. Stripped of the structural buckets — pacstrap base/boot/kernel (base, linux*, grub, efibootmgr, sudo, btrfs-progs, fwupd, logrotate, ex-vi-compat, linux-lts-strix, zram-generator), the =make deps= VM set (qemu-full, virt-manager, virt-viewer, libguestfs, bridge-utils, dnsmasq, archiso), and the yay bootstrap — these 40 remain. Check the ones to add to the installer, then rerun =make package-diff= to confirm they clear.

Some entries are libraries likely pulled in as dependencies (blas-openblas, openblas, eigen, tk, lib32-openal, pkcs11-helper, gtk4-layer-shell, webkit2gtk, sane, freerdp, rust-bindgen) — check those only if you want them declared explicitly rather than left to dependency resolution.

- [ ] aws-cli-v2
- [ ] bats
- [ ] blas-openblas
- [ ] drawio-desktop
- [ ] eigen
- [ ] emacs
- [ ] flatpak
- [ ] freerdp
- [ ] geeqie
- [ ] git-lfs
- [ ] github-cli
- [ ] gtk4-layer-shell
- [ ] hugo
- [ ] imv
- [ ] lc0
- [ ] lc0-network-sm
- [ ] ledger
- [ ] lib32-openal
- [ ] libreoffice-fresh
- [ ] minidlna
- [ ] openai-codex
- [ ] openblas
- [ ] pacoloco
- [ ] pkcs11-helper
- [ ] proton-vpn-cli
- [ ] proton-vpn-daemon
- [ ] protontricks
- [ ] python-lyricsgenius
- [ ] python-pip
- [ ] python-pipx
- [ ] python-sphinx
- [ ] rust-bindgen
- [ ] sane
- [ ] shortwave
- [ ] spotify-launcher
- [ ] tidal-dl-ng
- [ ] tk
- [ ] typescript-language-server
- [ ] webkit2gtk
- [ ] whisper.cpp

** TODO [#B] All error messages should be actionable with recovery steps
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
Currently just reports errors without guidance on how to fix them

** TODO [#B] Improve logging consistency
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
Some operations log to ~$logfile~, others don't - standardize logging
All package installs should log, all system modifications should log, all errors should log with context
Makes debugging failed installations easier

** TODO [#B] Security hardening + audit :security:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-28
:END:
Umbrella for the security-hardening and audit effort. Consolidated from the 2026-06-28 task audit, re-homing the scattered security tasks as children so the work reads as a unit. Each child ships independently and keeps its prior priority.

*** TODO [#B] Test security + functionality together
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
**** TODO [#B] Verify no unexpected open ports or services
*** TODO [#B] Security audit tooling
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
**** TODO [#B] Implement port scanning check
**** TODO [#B] Create security posture verification script
**** TODO [#B] Set up intrusion detection monitoring
*** TODO [#B] Document threat model and mitigations within 6 months
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Identify attack vectors, what's mitigated, what remains
*** TODO [#B] Complete security education within 3 months
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
Read recommended resources to make informed security decisions (see metrics for Claude suggestions)
*** TODO [#B] Create security checklist for cafe/public wifi scenarios
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Practical guidelines for working in public spaces

** TODO [#B] Test each modernization thoroughly before replacing
:PROPERTIES:
:LAST_REVIEWED: 2026-06-28
:END:
Ensure new tools integrate with the Hyprland environment and don't break workflow (the fleet is all Hyprland now; archsetup still supports DWM/X11 but no current machine uses it)

** TODO [#B] Add NVIDIA preflight check for Hyprland
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Detect NVIDIA GPU and warn user about potential Wayland issues:
- Require driver version 535+ or abort
- Document required env vars (LIBVA_DRIVER_NAME, GBM_BACKEND, etc.)
- Prompt to continue or abort if NVIDIA detected

** TODO [#C] Wlogout exit-menu buttons are rectangular, not square
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
The wlogout exit menu renders its buttons taller than they are wide on velox, so the cells read as vertical rectangles instead of squares. They render square (centered) correctly on ratio, so this is a per-host / resolution difference, not a flat bug. Fix the button sizing in the wlogout style (=~/.dotfiles/hyprland/.config/wlogout/style.css=) so each cell is square on both hosts. Noticed 2026-05-21. Related: the [#D] VERIFY about wlogout sizing across displays.

The wlogout config uses fixed pixel margins, which is the likely reason sizing differs across the two displays — adjusting them for the laptop screen is part of the fix (folded in from the former "Test wlogout menu on laptop" VERIFY, 2026-06-24).

Add a regression test so the square-cell fix doesn't silently break on a resolution change: assert the rendered (or computed) wlogout button cells are square across ratio's and velox's resolutions. Dropped :quick: — the cross-host test pushes this past a spare-moment fix.
** TODO [#C] Window focus lost when unhiding stashed windows :bug:hyprland:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
From the roam inbox: hiding a window (e.g. the org-capture popup) then unhiding it should leave the unhidden window focused, but another window typically takes focus. Also =ctrl+j/k= (layout-navigate) can't reach the unhidden window afterward — it should always reach any visible window except the waybar. Involves stash-restore + layout-navigate; needs interactive reproduction with Craig.

** TODO [#C] Pocketbook development backlog :pocketbook:
:PROPERTIES:
:LAST_REVIEWED: 2026-05-26
:END:
Pocketbook (GTK4 layer-shell notes panel, toggled via waybar) was pulled from publication 2026-05-26 — github repo + cjennings.net repo deleted, mirror hook removed — and folded into this repo at =pocketbook/= until it's ready to spin back out. Src-layout Python package with pytest tests and a Makefile. Develop it in-tree; the backing modules are =store/note/panel/layer_shell/app/note_widget= + =style.css=.

Backlog (unordered; promote items to their own dated tasks as they're picked up):

- Configurable options, possibly a dedicated configuration panel.
- Lose-focus hides pocketbook — configurable on/off.
- Configurable display order: chronological by creation date (asc/desc), manual, alphabetical (asc/desc).
- Search / filter notes.
- Global toggle keybind (Hyprland =bind=) alongside the waybar click; document the waybar integration.
- Note CRUD polish (create/edit/delete) + optional markdown rendering.
- Pin / favorite notes.
- Tags or notebooks / categories.
- Persistence: confirm store format + =~/.local/share/pocketbook/= location, add versioning/migration, decide a backup/sync story.
- Theming: track the dupre/hudson theme system so =style.css= follows =set-theme=.
- Layer-shell geometry config (anchor edge, width, margins) + HiDPI / multi-monitor behavior — ties into [[file:docs/PLAN-per-host-overrides.org][per-host overrides]] scaling work.
- Config file format (toml) + reload-without-restart.
- Expand test coverage (TDD per testing standards; =tests/= already exists).
- Release prep for the eventual spin-back-out: pyproject metadata, version, license.
- Re-wire the archsetup install (gtk4-layer-shell dep + install step + post-install clone) when pocketbook ships. Removed 2026-05-26 — see git history of =archsetup= / =scripts/post-install.sh=.

** TODO [#C] Fn+F9 toggles pocketbook — source unlocated :hyprland:pocketbook:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-23
:END:
On velox, pressing Fn+F9 (physical function key) toggles the pocketbook panel. It shouldn't. Raised from a home-project session 2026-06-23.

Investigated 2026-06-23 and could not locate the trigger in any config. Ruled out, three ways:
- No F9 bind (bare / $mod / keycode) in the live =hyprland.conf= (now a stow symlink), the velox host tier =conf.d/local.conf=, or the waybar config.
- =hyprctl binds= runtime (all 90 active binds, authoritative) execs pocketbook on ONLY =SUPER+P=. No F9/XF86 path reaches it. The old touchpad toggle that used to sit on =$mod+F9= was moved to =$mod+M=, so F9 is unbound in Hyprland.
- No input remapper (keyd/xremap/input-remapper) and no hotkey daemon (sxhkd/swhkd) running or configured; pocketbook's own source has no F9 / GlobalShortcuts / portal / dbus listener (its GTK ShortcutController binds only Esc/Ctrl-n/Ctrl-j/Ctrl-k/Del/Return). pocketbook is a single-instance Gtk.Application, so any path that re-runs =pocketbook= toggles it.

Parked at Craig's call (not worth deeper investigation now). If it resurfaces, the one unfinished step is to capture what keysym Fn+F9 actually emits (=wev -f wl_keyboard:key=, press Fn+F9, read the =sym:= / =code:=) and grep for that. Most likely folds into removing pocketbook from the waybar setup — if pocketbook leaves the bar, retire this with it.

** TODO [#C] Ensure sleep/suspend works on laptops
:PROPERTIES:
:LAST_REVIEWED: 2026-06-09
:END:
Critical functionality for laptop use - current battery drain unacceptable
*NOTE:* This applies to Framework Laptop (velox), not Framework Desktop (ratio)
Add kernel parameter: ~rtc_cmos.use_acpi_alarm=1~ (will become systemd default)
Consider: ~acpi_mask_gpe=0x1A~ for battery drain, suspend-then-hibernate config
See Framework community notes on logind.conf and sleep.conf settings

** TODO [#C] Re-check python-lyricsgenius --skipinteg workaround :solo:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
archsetup installs =python-lyricsgenius= with =--mflags --skipinteg=, skipping makepkg integrity + PGP checks — a workaround originally for an expired-signature issue upstream (surfaced by the 2026-06-23 --noconfirm audit). Periodically test whether the cause has cleared: if a plain =aur_install python-lyricsgenius= builds without complaint, drop the =--skipinteg= workaround. Removal needs a real AUR build to confirm, so it isn't a blind change.

*** 2026-06-24 Wed @ 17:55:34 -0400 Rechecked: still needed, but the cause changed
Ran =makepkg --verifysource= on the current AUR PKGBUILD (3.7.0-1). The package tarball =lyricsgenius-3.7.0.tar.gz= now passes its b2sum — the original expired-PGP-signature problem is gone (the PKGBUILD no longer carries any =validpgpkeys=). But integrity still FAILS, on a different file: =LICENSE.txt=, which the PKGBUILD fetches from the project's github master and pins a b2sum for. github master is a moving target, so that b2sum drifts and =--skipinteg= is still required. This is structural (not a transient upstream fix away), so it likely won't clear until the maintainer pins the LICENSE to a tagged release. Updated the archsetup comment to the real cause. Keep rechecking, but lower expectations of it clearing.

** TODO [#C] Review theme config architecture for dunst/fuzzel
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
The active dunst config is stowed from dotfiles/common/ but theme templates
live in dotfiles/hyprland/.config/themes/. set-theme copies the templates to
the stowed locations at runtime, so edits to the common file get overwritten
on theme switch. This split between stowed configs and theme templates is
error-prone — changes must be made in both places. Consider:
- Having set-theme be the single source of truth (remove common dunstrc from stow)
- Or symlinking the stowed config to a theme-managed location
- Same situation applies to fuzzel.ini
The goal is a single place to edit each config, not two.

** TODO [#C] Review current tool pain points annually
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Once-yearly systematic inventory of known deficiencies and friction points in current toolset

** TODO [#C] Waybar emacs-service status + control :feature:waybar:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
From the roam inbox (2026-06-22): with Emacs integrated into the system as file manager and instant note-taker, make bouncing it trivial. A waybar component showing the emacs service status, with detail on hover, that turns the server on / off / bounce via right-click. Pairs with running the Emacs daemon as a managed systemd user service.

** TODO [#C] set-wallpaper detaches waypaper config from its stow symlink :bug:hyprland:quick:solo:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-28
:END:
=set-wallpaper= persists with =mv "$tmp" "$CONFIG"=, which replaces the =~/.config/waypaper/config.ini= stow symlink with a real file. After the first run the live config is detached from =~/.dotfiles/hyprland/.config/waypaper/config.ini=, so a later =git pull= + restow won't update it and set-wallpaper changes never flow back to the repo. Fix: write in place rather than =mv= over the symlink — e.g. =cp "$tmp" "$CONFIG"= (follows the symlink to the real dotfiles file), or resolve the link target and write there. Lives in =~/.dotfiles/hyprland/.local/bin/set-wallpaper=; it has a test suite, so add a Boundary case for "CONFIG is a symlink".

** TODO [#D] Consider Customizing Hyprland Animations
Current: windows pop in, scratchpads slide from bottom.

Customizable animations:
- windows / windowsOut / windowsMove - window open/close/move
- fade - opacity changes
- border / borderangle - border color and gradient angle
- workspaces - workspace switching
- specialWorkspace - scratchpads (currently slidevert)
- layers - waybar, notifications, etc.

Styles: slide, slidevert, popin X%, fade
Parameters: animation = NAME, ON/OFF, SPEED, BEZIER, STYLE
Speed: lower = faster (1-10 typical)

Example tweaks:
#+begin_src conf
animation = windows, 1, 2, myBezier, popin 80%
animation = workspaces, 1, 4, default, slide
animation = fade, 1, 2, default
animation = layers, 1, 2, default, fade
#+end_src

** TODO [#D] Parse and improve AUR error reporting
Parse yay errors and provide specific, actionable fixes instead of generic error messages

** TODO [#D] Improve progress indicators throughout install
Enhance existing indicators to show what's happening in real-time

** TODO Manual testing and validation
*** wtimer: color states + click/scroll interactions on the live bar
What we're verifying: the timer module's interactions and CSS state colors render right on the live bar. The glyph, position (right of battery), countdown, and "+N" badge are already verified live; the per-state colors and the real mouse/scroll bindings are what's left. The logic is unit-tested (86 cases); this is the human-in-the-loop visual + input check.
- Left-click the timer module — a fuzzel menu offers timer / alarm / stopwatch / pomodoro; pick timer, enter =5s=.
- Watch it count down; at under a minute it should turn the urgent color (dupre orange #d47c59).
Expected: the timer reaches 0:00, a persistent notification fires, and the item disappears from the bar.
- Create two timers (e.g. =3m= and =10m=); a =+1= badge shows; scroll over the module.
Expected: scrolling cycles which item is primary (the displayed time/glyph changes); the badge count stays correct.
- Middle-click the module while a timer runs.
Expected: it pauses (dimmed paused color #5f5c52) and the countdown freezes; middle-click again resumes from where it left off.
- Right-click the module with items present.
Expected: a fuzzel menu lists the items; choosing one cancels it.
- Start a pomodoro (left-click → pomodoro); let a work phase elapse (or set short test phases by editing state).
Expected: the glyph + color switch between work (gold #d7af5f) and break (#8a9a5b), a notification fires at each phase change, and the cycle count advances.
*** Sysmon right-click cycles the visible metric (live waybar)
What we're verifying: right-clicking the collapsed sysmon module rotates the visible metric and the bar refreshes at once, left-click still opens btop, and the cpu/temp/mem icons render as real glyphs (not tofu boxes). The cycle logic is unit-tested; this is the live-waybar + visual confirmation.
- Reload waybar so it picks up the new =signal= / =on-click-right= config (Super+B relaunches it, or =pkill waybar; waybar &= from a terminal)
- Right-click the sysmon module several times, watching the visible metric
- Left-click the sysmon module once
Expected: each right-click advances the visible metric battery → cpu → temp → mem → disk → back to battery (velox is a laptop, so battery is in the ring) and the bar updates immediately. Every metric shows a sensible icon plus its value, no tofu. Left-click still opens the btop popup. The tooltip still lists all metrics.
*** Give the README a final read before public release
What we're verifying: =README.md= reads cleanly and accurately for a first-time reader, with no stale personal info and consistent public-fork placeholders.
- Open =~/code/archsetup/README.md=
- Read it end to end as if you've never seen the project
Expected: every section is accurate, the personal-project disclaimer reads right, the placeholders (=<your-domain>=, =github.com/yourusername=) are consistent, and nothing personal leaked into the public-facing draft.
*** 2026-06-28 Sun @ 12:54:47 -0400 Live-update guard verified on velox (live Hyprland)
Verified the =hypr-live-update-guard= PreTransaction hook end-to-end on velox
with Hyprland running (pid 1997). velox predated the feature, so the guard was
absent — placed =/usr/local/bin/hypr-live-update-guard= (755) and
=/etc/pacman.d/hooks/hypr-live-update-guard.hook= (644), byte-matching the
archsetup hyprland-step install. The guard now ships on velox permanently.

Results:
- Quick contract (=printf 'mesa\nhyprland\n' | guard=) → exit=1, BLOCKED banner,
  sorted pkgs, correct TTY remedy + sentinel path.
- Not-running branch (=HYPR_GUARD_RUNNING=0=) → exit=0, silent.
- Env override (=HYPR_ALLOW_LIVE_UPDATE=1=) → exit=0.
- Sentinel (=touch /run/archsetup-allow-live-gpu-update=) → exit=0; removed →
  re-armed exit=1.
- Real firing through pacman: =sudo pacman -S mesa= (same-version reinstall =
  Upgrade op on a guarded target). pacman ran the hook, fed =mesa= via
  =NeedsTargets=, the guard aborted, =AbortOnFail= stopped the transaction
  ("no packages were upgraded"); mesa unchanged at 1:26.1.3-2. This is the
  authoritative proof pacman parses + wires the hook.
- Full-logout end-to-end (guard quiet, upgrade completes after logout): covered
  by construction — the not-running branch exits 0, and a 0-exit PreTransaction
  hook lets pacman proceed normally (proven by the mesa abort showing the hook
  path runs). Not re-run under a real logout; no separate residual.
*** Wallpaper survives relogin (waypaper --restore)
What we're verifying: the hyprland =exec-once= now runs =waypaper --restore= instead of a hardcoded =awww img=, so a wallpaper chosen via =set-wallpaper= / waypaper / dirvish persists across a relogin. The exec-once only fires at Hyprland startup, so this can't be confirmed without a real relogin. (Mechanism already verified: =waypaper --restore= applied the persisted wallpaper via the awww backend, exit 0.)
- Set a wallpaper different from the current one (or pick one in waypaper, Super+Shift+P):
#+begin_src sh :results output
set-wallpaper ~/pictures/wallpaper/trondheim-norway.jpg
#+end_src
- Log out of Hyprland and back in (or reboot)
Expected: the wallpaper you just set is what comes back after login — not whatever was showing before, and never the old hardcoded default unless that's what you set.

*** velox per-host env applies after Hyprland restart
What we're verifying: the velox tier's env lines (GDK_SCALE/QT_SCALE_FACTOR 1.5, XCURSOR_SIZE 36) only apply at Hyprland startup, and the foot font moved to host.ini — neither can be confirmed over ssh.
- On velox, log out of Hyprland and back in (or reboot)
- Open a foot window — text should render at 12pt (same as before the migration)
- Launch Zoom (ideally from a browser link) — it should open at normal size with no per-app patch
- Check the cursor isn't tiny on the HiDPI panel
Expected: foot at 12pt, Zoom normally sized, cursor 36px — all from the velox tier, no local real files involved.

*** Dupre Chrome theme renders correctly
What we're verifying: the new Chrome theme's colors look right in a real browser — palette mapping can't be eyeballed from a manifest.
- Open chrome://extensions in Chrome
- Enable "Developer mode" (top right)
- Click "Load unpacked" and select =~/code/archsetup/assets/color-themes/dupre/chrome-theme/=
- Look at the window frame, toolbar, tab strip, and a new tab page
Expected: near-black frame (#151311), dark toolbar/omnibox (#252321), gold links on the new-tab page, steel-gray inactive tab text — coherent with the rest of the dupre desktop.

*** 2026-06-10 Wed @ 17:46:34 -0500 velox post-trim reboot verified; realtek firmware restored
Craig rebooted velox (passphrase at console); checks ran over SSH after boot. Wifi connected, TLP active, graphics fine. One dmesg hit: r8152 failed to load rtl_nic/rtl8156b-2.fw — the Framework Ethernet expansion card (RTL8156B) is Realtek, so the trim list wrongly dropped linux-firmware-realtek (a Realtek laptop camera is on USB too). Reinstalled the package on velox (its hook rebuilt the initramfs) and removed realtek from archsetup's trim list. The driver worked even without the blob (internal-defaults fallback), so this was correctness, not breakage.

*** Super+F dirvish popup: launch, float, dismiss-on-focus-loss, q
What we're verifying: the physical keychord opens a floating Dirvish popup; opening any file launches it independently (never inside the popup frame) and the popup auto-dismisses when focus leaves; navigating dirs keeps it; q is the manual close. The Wayland focus event that drives the auto-dismiss can't be driven headlessly — only a real keypress + real app launch confirms it.
- Press Super+F
- Expected: a Dirvish frame opens floating and centered, rooted at ~/ (home)
- Navigate into a directory with RET (or right-arrow)
- Expected: the popup stays open and shows that directory (browsing keeps it up)
- Open a video with RET (or o)
- Expected: the video opens in its player and the popup vanishes on its own — no q needed, nothing left in the way, and q never lands on the video
- Press Super+F again, open a PDF or image with RET
- Expected: it opens in zathura / feh (externally), NOT inside the popup frame; popup dismisses
- Press Super+F again, open a .txt or .org file with RET
- Expected: it opens in a NEW emacsclient frame (separate from your working session), not adopted into your current session; popup dismisses
- Press Super+F, then click another window without opening anything
- Expected: the popup dismisses on focus loss
- Press Super+F, then press q
- Expected: the popup closes completely (manual dismiss still works; no empty leftover frame)
- Press Super+F, then press Super+F again while it's still open
- Expected: still exactly one popup — the second press focuses the existing one, no second frame, no stray buffer (for several independent file managers, use C-x d)
- Press Super+Shift+F
- Expected: GUI nautilus opens (the binding nautilus moved to)
*** Network module Phase 1 — indicator states on the live bar
What we're verifying: =custom/net= shows the right state for each real network condition. The engine logic is unit-tested; this is the live-bar + visual check (states can't be faked on the running bar). Phase 2-3 tests get added under this task as those phases land.
- Reload waybar to pick up =custom/net=. Super+B does NOT reload a running bar — it only toggles visibility (SIGUSR1), and the bar reads a generated runtime config, so a stale copy keeps the old module. The correct reload regenerates the runtime config then restarts:
#+begin_src sh :results output
waybar-active-config && killall waybar && waybar-toggle
#+end_src
- On a normal connected network, read the module.
- Expected: wifi glyph + signal + SSID; tooltip shows IPv4, gateway, throughput, and a recent "online" probe result.
- Join the hotel/captive network (or any portal network).
- Expected: the module shows the captive state (distinct glyph + warning color), tooltip names the portal host.
- Unplug to a network with no internet (or block egress).
- Expected: the no-internet state (distinct from captive and from disconnected).
*** Network module Phase 1 — net doctor recovers rfkill from a TTY
What we're verifying: the console-recovery path works with no GUI, and recovers the framework's post-power-loss soft-block.
#+begin_src sh :results output
rfkill block wifi        # simulate the soft-block
rfkill list wifi         # confirm Soft blocked: yes
#+end_src
- Switch to a TTY (Ctrl+Alt+F3) and log in (no Hyprland).
#+begin_src sh :results output
make -C ~/.dotfiles online   # or: net doctor --fix
#+end_src
- Expected: doctor reports the rfkill block, runs =rfkill unblock wifi= + =nmcli radio wifi on=, reconnects, and ends "online" — all from the TTY.
*** Network module — bar clicks + airplane keybind (FINAL scheme)
What we're verifying: the custom/net clicks and the airplane keybind. Clicks (settled with Craig over live use 2026-06-29): left = =net-panel= toggle (the GTK panel), middle = =net portal= (floating terminal), right = =net-fix= (notify the doctor result when one-way; open a terminal only when fixable). Airplane = Super+Shift+A.
- Left-click =custom/net=.
- Expected: the GTK connection panel toggles open (left-click again, or Esc, closes it).
- Right-click =custom/net= while online.
- Expected: a desktop notification "Network / Online" (success), no terminal. When a repair is needed it instead opens a terminal running =net doctor --fix=. (Craig confirmed the notification delivers, 2026-06-29.)
- Middle-click =custom/net= on a captive network.
- Expected: =net portal= runs in the floating terminal — reset + opens the portal page.
- Press Super+Shift+A.
- Expected: airplane engages (wifi off, dim, low-power); =custom/net= shows the airplane glyph in gold. Super+Shift+A again restores everything.
- Check =airplane-mode= is still present (=ls ~/.local/bin/airplane-mode=), and =waybar-airplane= / =waybar-netspeed= / =custom/airplane= are gone.
*** Network module Phase 3 — panel Diagnose / Repair / Speed test tabs
What we're verifying: the four-tab panel works end to end. Left-click =custom/net= to open it.
- Diagnose tab → "Run diagnose".
- Expected: a list of steps (link, DHCP, gateway, DNS config, DNS resolution, internet) each with a ✓/✗/… glyph + evidence; on a captive network an "Open portal" button appears.
- Repair tab → click Reset (or Bounce, or DNS override test).
- Expected: a confirmation dialog with the exact wording (Reset names the network + new-MAC warning; Bounce "links drop briefly"; DNS test "reverts automatically"). Proceed opens a floating terminal that runs the repair (sudo prompt there) and shows the step output incl. cleanup-verified for the DNS test.
- Speed test tab → "Run speed test" (uses ~30s + data — do it on real wifi, not the metered hotspot).
- Expected: ↓/↑ Mbps + ping + server shown inline.
- Byte-rate→Mbps unit: VERIFIED 2026-06-30 (velox). Raw =speedtest-go --json= dl_speed read ~3.66M, unambiguously bytes/s (29 down / 80 up Mbps); =net speedtest= reported 33.62 / 77.99 through the wired path. =BYTES_PER_SEC = True= + =* 8 / 1e6= are correct, no flip needed. Remaining here is only that the panel renders the inline result.

** DOING [#B] Prepare for GitHub open-source release
:PROPERTIES:
:LAST_REVIEWED: 2026-06-28
:END:
Remove personal info, credentials, and code quality issues before publishing.
*** 2026-06-16 Tue @ 00:55:39 -0500 Six dotfiles-scoped sub-tasks moved to the ~/.dotfiles project
Per the 2026-06-16 task audit, the six sub-tasks targeting files now owned by the standalone =~/.dotfiles= repo were handed off to that project (newly bootstrapped as its own AI project) and removed from this epic: "Remove credentials and secrets from dotfiles", "Remove/template personal info from dotfiles", "Remove binary font files from repo", "Move battery out of waybar sysmonitor group", "Resolution-adaptive scratchpad sizing", and "Dynamic waybar/foot config based on screen resolution". Handoff: =~/.dotfiles/inbox/2026-06-16-0053-from-archsetup-dotfiles-release-prep-handoff.org=. This epic now covers archsetup-proper release work only (scripts personal-info, device-specific config, history scrub, shellcheck, SPDX headers, README/LICENSE). The 2026-06-09 reconciliation note below is the prior state.
*** 2026-06-09 Tue @ 19:21:36 -0500 Reconciliation: six sub-tasks now target the ~/.dotfiles repo, not archsetup
Phase 3.2 removed the in-repo =dotfiles/= tree, so six sub-tasks below no longer describe archsetup content — they target files now owned by the =~/.dotfiles= repo (=git.cjennings.net/dotfiles.git=): "Remove credentials and secrets from dotfiles", "Remove/template personal info from dotfiles", "Remove binary font files from repo", "Move battery out of waybar sysmonitor group", "Resolution-adaptive scratchpad sizing", and "Dynamic waybar/foot config based on screen resolution". Their paths are relative to that repo now. Kept here for tracking per Craig (2026-06-09); he'll re-scope the archsetup-vs-dotfiles split shortly. archsetup-proper release work (scripts personal-info, device-specific config, shellcheck, and scrubbing the pre-=b10cba5= dotfiles secrets from archsetup's own history) stays this task.
*** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Open-source-prep source audit
Checked each subtask below against the source / git state. Bottom line: almost nothing is fully done. =LICENSE= and =README.md= were added this session (see those subtasks); the rest still stands.
- *Remove credentials and secrets from dotfiles* — NOT DONE. All five named files still tracked: =dotfiles/common/.config/.tidal-dl.token.json=, =.config/calibre/smtp.py.json=, =.config/transmission/settings.json=, =.msmtprc=, =.mbsyncrc=. =.gitignore= lists none of them; no =.example= templates exist.
- *Remove/template personal info from scripts* — PARTIALLY DONE. Repo URLs ARE config-driven (=archsetup:141-146= use =${dwm_repo:-https://git.cjennings.net/...}=, documented in =archsetup.conf.example=). Still personal: =archsetup:2-3= (email/website header), =init:8,21= (=root:welcome=), =scripts/post-install.sh:17-56= (personal repos).
- *Remove/template personal info from dotfiles* — NOT DONE. =.gitconfig= has =c@cjennings.net=, =name = Craig Jennings=, =github user = cjennings=, =safe.directory= and employer creds; =.config/mpd/musicpd.conf= + =mpd.conf= still use =~cjennings/= / =/home/cjennings/= paths; =.ssh/config= has personal/employer hosts; =.config/yt-dlp/config:2= has =c@cjennings.net=; =hyprland.conf:3= has personal attribution.
- *Scrub git history of secrets* — NOT DONE. 275 commits; history not fresh, no filter-repo evidence.
- *Remove device-specific configuration* — NOT DONE. =archsetup:1486-1493= still creates the Logitech BRIO udev rule unconditionally; no config flag.
- *Add README.md for GitHub* — DONE (this session — initial draft, pending review). See subtask below.
- *Add LICENSE file* — DONE (this session — GPL-3). See subtask below.
- *Remove binary font files from repo* — NOT DONE. =dotfiles/common/.local/share/fonts/= still tracks 8 PragmataPro =.ttf= files, =AppleColorEmoji.ttf=, and other commercial fonts (Cartograph, MonoLisa, ComicCode, etc.).
- *Make claude-code installation optional* — NOT DONE. =archsetup:1817-1818= runs =curl -fsSL https://claude.ai/install.sh | sh= unconditionally; no flag.
- *Add input validation for username and paths* — PARTIALLY DONE. =archsetup:326-328= validates =$username= against =^[a-z][a-z0-9_]*$= (plus reserved-names check, marked DONE separately). No validation of =$source_dir= or other path vars.
- *Move battery out of waybar sysmonitor group* — NOT DONE. =dotfiles/hyprland/.config/waybar/config:27-37= still has =battery= inside =group/sysmonitor=.
- *Resolution-adaptive scratchpad sizing* — NOT DONE. No size/move windowrules for scratchpads in =hypr/conf.d=.
- *Dynamic waybar/foot config based on screen resolution* — NOT DONE. No resolution-detection/generation script.
- *Bulk shellcheck cleanup* — PARTIALLY DONE. =shellcheck archsetup= still shows 68 findings: 30×SC2329, 16×SC2174, 15×SC2024, 4×SC2086, 1 each SC2155/SC2129/SC2005. The 4 SC2086 (unquoted) are the ones a reviewer would flag — those are the priority.
- *Document testing process in README* — NOT DONE. =scripts/testing/README.org= exists but isn't the project README. (Now unblocked — root README exists.)
- *Add guard for rm -rf on constructed paths* — DONE 2026-05-20. All three constructed-path deletes routed through a =safe_rm_rf= guard (absolute / no-=..= / inside-allowed-prefix / real-dir checks); unit-tested in =tests/safe-rm-rf/=.
- *Standardize boolean comparison style* — NOT DONE. Mixed: =[ "$var" = "true" ]= at =archsetup:542,544,569= vs bare =if $var;= form ~7 places elsewhere.
- *Replace eval with safer alternatives* — NOT DONE. =archsetup:442= still =if eval "$cmd" >> "$logfile" 2>&1;= in =retry_install=.

*** 2026-06-28 Sun @ 13:34:03 -0400 Cancelled: calendar-feed URL rotation
Craig's call — not rotating. The three private iCal URLs (Google personal, Proton with PassphraseKey, Google DeepSat) sat in git history from =500b1f5= (2026-05-13) until the 2026-05-20 filter-repo scrub, which removed them from local + remote history. The residual exposure is only to anyone who cloned the repo in that 2026-05-13..05-20 window; Craig accepts that window rather than regenerating all three tokens on ratio. The history scrub already happened; the live =calendar-sync.local.el= is owned by the emacs project. Closing without rotation.

*** 2026-05-20 Wed @ 12:09:32 -0500 Scrubbed the calendar secret from git history
=dotfiles/common/.emacs.d/calendar-sync.local.el= (private Google/Proton/DeepSat ical URLs, added in =500b1f5= for stow distribution) was discovered while folding tmux-util into stow. Sent the file back to the emacs project's inbox, =git rm='d it, then =git filter-repo --invert-paths --path= purged it from all 29 affected commits. Force-pushed (=0921e4d...618e6cc=, with lease) and ran =reflog expire= + =gc --prune=now= on the bare repo at =/var/git/archsetup.git=. Verified: the file is in zero commits, the secret tokens return zero matches across all history, and =500b1f5= / =0921e4d= are unreachable on both local and remote. Rotation of the URLs tracked as the sibling TODO above. This also proves =filter-repo= works cleanly here — relevant precedent for the broader [[*Scrub git history of secrets (or start fresh)][history-scrub task]] below (the 5 credential files are still in history).

*** TODO [#B] Remove/template personal information from scripts
- =archsetup= lines 3-4: personal email and website in header
- =scripts/post-install.sh=: personal git repos and server URLs (the old =scripts/gitrepos.sh= was consolidated into this script in =dae7659=, so its personal =git.cjennings.net= clone targets now live here)
- =init= line 9: hardcoded password =welcome=
**** 2026-06-28 Sun @ 13:29:29 -0400 Reconciled: dotfiles repo URLs already config-driven
Dropped the "lines 141-146 hardcoded =git.cjennings.net= URLs" bullet. archsetup:138-140 reads =DOTFILES_REPO= / =DOTFILES_BRANCH= / =DOTFILES_DIR= overrides (defaults only, documented in =archsetup.conf.example=), so that item is already done. Refreshed the stale line numbers on the remaining bullets (header email/site now lines 3-4, init password now line 9, after the SPDX headers shifted the files).

*** TODO [#B] Scrub git history of secrets (or start fresh)
Even after removing files, secrets remain in git history.
Options: =git filter-repo= to rewrite history, or start a fresh repo for the GitHub remote.
Recommend: fresh repo for GitHub (keep cjennings.net remote with full history).
**** 2026-06-28 Sun @ 13:29:29 -0400 Reconciled: 589 commits, 5 credential files still in history
History is now 589 commits (the 2026-05-11 note's "275" is stale). Only the calendar-feed file has been filter-repo'd so far (2026-05-20). The five credential files remain in history at their pre-=b10cba5= paths: =.tidal-dl.token.json= (5 commits), =calibre/smtp.py.json= (6), =transmission/settings.json= (5), =.msmtprc= (8), =.mbsyncrc= (9). None are tracked in the current tree. The scrub-or-fresh-repo decision still stands.

*** 2026-06-24 Wed @ 19:41:56 -0400 Gated device-specific udev rules behind a flag
The Logitech BRIO udev rule is now wrapped in =if [ "$install_device_udev_rules" = "true" ]=, fed by a new =INSTALL_DEVICE_UDEV_RULES= key (default yes, opt-out — still mainly a personal project). Added the var default, the config read, a =validate_config= check, and an =archsetup.conf.example= entry. Verified: default/yes writes the rule, no skips it, bogus is rejected; =bash -n= clean.

*** 2026-06-28 Sun @ 13:37:33 -0400 Added README.md — full draft complete, final read filed
=README.md= is substantively done at repo root (10.9 KB), covering project description, features, requirements, installation, the =archsetup.conf= configuration guide, security considerations, contributing, and license, with generic placeholders for the eventual public fork. The 2026-05-11 "first pass" note below is superseded. Craig's final read before public release is filed under "Manual testing and validation"; closing as code-complete pending that human check, per the audit rule.

**** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Initial README draft
Drafted =README.md= at repo root, modeled on =~/code/chime/README.org=. First pass — review and run a voice/style pass before committing. Personal info (emails, =cjennings.net= URLs, personal repo names) intentionally replaced with placeholders for the eventual public release.

*** 2026-05-19 Tue @ 01:54:29 -0500 Added GPL-3 LICENSE file at repo root
GPL-3 chosen. Canonical GPLv3 text landed at =LICENSE= on 2026-05-11 (commit =f80e664=). README already links to it. SPDX/license headers across source files (or a NOTICE file) split out as a new sub-task below for the eventual public release.

*** 2026-06-24 Wed @ 19:41:56 -0400 Added SPDX headers to all shell scripts
Swept =# SPDX-License-Identifier: GPL-3.0-or-later= in right after the shebang of all 24 shell scripts in the repo (=archsetup=, =init=, =scripts/**/*.sh= incl. =scripts/testing/=). The dotfiles are a separate repo now, so they aren't swept here. Verified the header sits at line 2 (after the shebang) and syntax still passes.

*** 2026-06-09 Tue @ 19:21:36 -0500 Made claude-code install optional
Shipped in =f2dad22= (feat: make the claude-code install optional). The =curl | sh= from claude.ai now sits behind a config flag instead of running unconditionally.

*** 2026-06-09 Tue @ 19:21:36 -0500 Input validation added (validate_config + validate_username)
validate_config + validate_username shipped (detail in the 2026-05-11 note below). The =$source_dir= path check was judged unnecessary — it derives from the now-always-validated =$username= (=/home/$username/.local/src=). Closed as done.

**** 2026-05-11 Mon @ 18:20:49 -0500 AI Response: validate_config + validate_username added
Added two pre-flight validators to =archsetup= (right after =load_config=, before any install step):
- =validate_username()= — the lowercase / starts-with-letter / =[a-z0-9_]= / not-reserved check, extracted from the inline block in =preflight_checks()=. Fixes an existing gap: the inline check only ran on the *prompted* path, so a config with =USERNAME=root= (or =USERNAME=foo bar=) slipped through unvalidated. Now both =preflight_checks= and =validate_config= call it.
- =validate_config()= — runs whenever =--config-file= is used: rejects unknown =DESKTOP_ENV= (must be dwm/hyprland/none) early instead of dying in step 7-9; rejects =AUTOLOGIN=/=NO_GPU_DRIVERS= values that aren't =yes=/=no= (currently silently ignored); basic shape check on =LOCALE=; and a scheme + no-whitespace/no-leading-dash check on the six =*_REPO= URLs that get passed to =git clone= (rejects e.g. =--upload-pack=…= injection). Plain =echo …>&2; exit 1= (the logging helpers aren't defined that early). =$source_dir= needs no separate check — it's =/home/$username/.local/src=, derived from the now-always-validated =$username=.
Not a security boundary (=load_config= sources the config as bash; a hostile config can already run anything) — it's typo-catching. Verified with =bash -n= and a smoke-test matrix of good/bad inputs through both functions. The next =make test= run confirms valid configs still install. Leaving as DOING for review.

*** 2026-05-20 Wed @ 06:50:25 -0500 Swept shellcheck across the shell scripts
Census across the 16 shell scripts (=archsetup=, =init=, =scripts/*.sh=, =scripts/testing/=): 124 findings, zero errors. Triaged against "what matters for public review" and confirmed the 2026-01-24 read — most are intentional or documented-acceptable:
- SC2024 (14, sudo redirects), SC2174 (16, =mkdir -p -m=), SC1091 (13, unfollowable sources), SC2329 (32, functions invoked indirectly via the =STEPS= dispatch array), SC2153 (1, =DISK_PATH= sourced from =vm-utils.sh=) — all false positives or accepted.
- SC2086 on =$SSH_OPTS= in =vm-utils.sh= (×4) and =$TEMP_DISKS= in =cleanup-tests.sh= — intentional word-splitting; quoting would break them. The SSH_OPTS-as-array refactor is the proper fix, deliberately deferred (codebase-wide, one atomic change).
- SC2086 integer tests in =[ ]= (=archsetup=, =cleanup-tests=) — safe, note-level style; left to avoid churn in the just-fixed =retry_install=.
- SC2015 (×2, =vm_exec && success || warn=) — =success=/=warn= return 0, so C won't spuriously fire. Idiomatic.

Fixed the four that are genuine: =init= (a =#!/bin/sh= script) used =$(</etc/hostname)= (SC3034 bashism → =$(cat ...)=) and an unquoted =$interface_up= (SC2086 → quoted); =shellcheck init= now clean, =sh -n= passes. Suppressed the two =VM_IP= SC2034 warnings with documented =# shellcheck disable= directives (consumed by the sourced =validation.sh=, which shellcheck can't follow). 124 → 120; the remaining 120 are the triaged-acceptable set above.

*** 2026-05-20 Wed @ 06:32:17 -0500 Documented the testing process in the README
The README only covered the VM integration harness; the unit-test layer under =tests/= (Python =unittest=, fake-binary-on-PATH, one dir per script — =layout-navigate=, =tmux-util=) was undocumented. Added a =make test-unit= target that runs every =tests/*/test_*.py= suite explicitly (=unittest discover= can't find them — hyphenated dir names aren't valid package paths), then rewrote the README Testing section into "Unit tests" and "Integration tests (VM harness)" subsections, including how to add a suite for a new script. Updated Contributing to point at =make test-unit= for script changes. 61 unit tests pass via the new target.
*** 2026-05-20 Wed @ 18:22:42 -0400 Added safe_rm_rf guard on constructed-path deletes
Added a self-contained =safe_rm_rf <path> <allowed_prefix>= helper to =archsetup= and routed all three constructed-path deletes through it. The guard refuses to run unless the target is absolute, free of =..=, deeper than a bare top-level dir, strictly inside the allowed prefix (not the prefix itself), and a real directory (not a symlink); otherwise it prints the reason and returns non-zero without deleting. On the happy path it delegates to =rm -rf=.

Sites converted (the line numbers in the original task body were stale — actual sites located by grep):
- =--fresh= state-dir wipe — prefix =/var/lib/archsetup=.
- =git_install= clone-retry cleanup (=build_dir= under =$source_dir=).
- =aur_installer= yay clone-retry cleanup (same prefix).

The helper is defined before the top-level =--fresh= handler (which runs at load time, before the logging helpers exist), so it carries no =error_warn= dependency and reports refusals to stderr itself. The two in-function sites keep their existing =|| error_warn= / =|| error_fatal= handling.

Tests: =tests/safe-rm-rf/test_safe_rm_rf.py= sources the real function out of the script and exercises Normal/Boundary/Error cases (13 tests) against real temp dirs. =make test-unit= green (61 tests), =bash -n= clean, no new shellcheck warnings.
*** 2026-06-24 Wed @ 19:41:56 -0400 Standardized boolean comparisons on the explicit form
Rewrote the bare =if $var= boolean conditionals (=show_status_only=, =fresh_install=, =skip_gpu_drivers=, =detected_intel/amd/nvidia=, plus two =! $var= negation chains) to the explicit =[ "$var" = "true" ]= / =!= "true"= form, and quoted the one unquoted =install_claude_code = true=. Left =if $step_func= alone — that's the STEPS function-dispatch, not a boolean. Verified: only =step_func= remains bare, all comparisons are quoted, =bash -n= clean.

*** 2026-05-26 Tue @ 15:27:09 -0500 eval task moot — the line-434 eval is gone, the survivor is deliberate
Verified: the only =eval= left in =archsetup= is line 578 in =retry_install=, and it's intentional and documented — it captures =$?= directly from =eval "$cmd"= to dodge the if-compound-swallows-exit-code trap. Replacing it with an array would reintroduce that bug. The line-434 eval this task pointed at no longer exists. Nothing to change.

* Archsetup Resolved

** DONE [#B] Idle-inhibitor keybind + synced waybar indicator :hyprland:waybar:
CLOSED: [2026-06-23 Tue]
Shipped 2026-06-23 as dotfiles commit =a004201=. Super+I toggles the hypridle daemon (kill = inhibit, relaunch = restore). The built-in waybar =idle_inhibitor= module was replaced with a =custom/idle= module backed by a =waybar-idle= script, so the keybind, the bar click, and the icon share one source of truth (whether hypridle is running) and stay in sync. Icons 󰒳 inhibited / 󰒲 active, with a 5s poll safety net. Freed =Super+I= by pruning the unused ai-term pyprland scratchpad from both host configs. TDD'd (=waybar-idle= + =hypridle-toggle= suites); dupre/hudson theme CSS updated. From a home-project handoff 2026-06-23; Craig confirmed it works live.
** DONE [#B] Verify package signature verification not bypassed by --noconfirm
CLOSED: [2026-06-23 Tue]
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Audited 2026-06-23. =--noconfirm= does not bypass signature verification — it only auto-answers interactive prompts. Signature checking is governed by =SigLevel= in =/etc/pacman.conf=, which archsetup leaves at the Arch default (=Required DatabaseOptional=): its only pacman.conf edits are ParallelDownloads, Color, and enabling multilib (=archsetup:913,917=), none of which touch =SigLevel=. So every repo package stays signature-verified regardless of =--noconfirm=.

One real integrity bypass exists, and it is not =--noconfirm=: =archsetup:2403= runs =yay -S --noconfirm --mflags --skipinteg python-lyricsgenius=, where =--skipinteg= skips makepkg's checksum and PGP-signature checks for that one AUR package (a documented workaround for an expired-signature issue upstream). It's scoped to a single package, not global. Tracked for periodic re-check below.
** DONE [#C] Harden sshd in the installer (explicit prohibit-password) :solo:
CLOSED: [2026-06-24 Wed]
Done 2026-06-24: the openssh block (=archsetup:1271-1277=) now writes =/etc/ssh/sshd_config.d/10-hardening.conf= with =PermitRootLogin prohibit-password= and reloads sshd, right after starting the service. =PasswordAuthentication= left untouched so ssh-copy-id to the user still works. Makes the posture intentional rather than dependent on the upstream default. Velox and ratio (which carried an explicit =PermitRootLogin yes= at =sshd_config:33= from earlier provisioning) were already fixed by hand 2026-06-23. Verified =bash -n= + =shellcheck -S error= clean; full drop-in-on-fresh-install confirmation is VM-deferred (the unit harness covers helpers, not inline install steps).
** DONE [#C] Build security dashboard command :solo:
CLOSED: [2026-06-23 Tue]
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Shipped 2026-06-23 as dotfiles commit =1b9b205=: =security-status= (=common/.local/bin=, on PATH). Read-only dashboard showing disk encryption (LUKS *and* ZFS native — the fleet runs ZFS, so a LUKS-only check would have falsely reported "no encryption"), ufw state, externally-reachable ports (counts all listening, lists only the non-loopback exposures), and running/failed service counts. Command lookups are env-overridable; parsing covered by unit tests against canned output. New file, so ratio needs =git pull && make stow hyprland= to link it.
** DONE [#C] Teach archsetup to stow the host tier :solo:
CLOSED: [2026-06-23 Tue]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-11
:END:
Already implemented in =user_customizations()= (=archsetup:1049-1058=): after stowing =common= + the DE package, it derives =host_tier="$(cat /etc/hostname 2>/dev/null || uname -n)"= and stows that package when =$dotfiles_dir/$host_tier= exists, else prints "no host tier for '<host>' — skipping". The =/etc/hostname=-first detection is the right call for install time (=uname -n= still reports the ISO's name until reboot), and it's the same skip-if-absent semantics as the dotfiles Makefile. Verified by reading the installer 2026-06-23; no code change needed.
** DONE [#C] Waybar indicators unevenly spaced :quick:solo:waybar:
CLOSED: [2026-06-24 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
The right-side module icons don't sit at even intervals — spacing reads as inconsistent across the group. Noticed 2026-05-21 after adding the airplane indicator.

Done 2026-06-24: a screenshot showed the standalone module icons were already even — the unevenness was the tray, whose icons clustered tight (tray =spacing: 4= vs the ~0.3rem margins on every other module). Bumped tray =spacing= 4 → 10 in the waybar =config=; restarting waybar and re-screenshotting confirmed the row reads even. The lever was the tray spacing, not the per-module CSS the original body guessed at.
** DONE [#B] Separate mpd playlist_directory from music_directory :mpd:music:quick:
CLOSED: [2026-06-24 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
Done 2026-06-24 (dotfiles a9bfdf3): set =playlist_directory= to =~/.local/share/mpd/playlists= (separate from =music_directory= ~/music). git-moved the 73 radio-stream playlists from =common/music/= into =common/.local/share/mpd/playlists/= (history preserved); dropped the empty =60s Sounds.m3u= (Craig's call); git rm'd the stray =Black Flamingos - Space Bar.m4a= and moved the real track into the music library. Curated playlists left flat in ~/music (Craig's call — avoids rewriting the 7 relative-path ones). The ~/music/radio orphan was already gone. Relinked surgically (a pre-existing =whereami= stow conflict blocked a full =stow common=). mpd restarted clean: 73 radio playlists load from playlist_directory (verified SomaFM stream URLs), 24 curated browsable from the music tree. ratio needs the same restow + mpd restart on its next pull (reminder filed). Decisions answered: 60s dropped, curated flat.
Spec written and approved (option 1), pinned before execution on 2026-06-03. Root issue: mpd.conf has =playlist_directory= == =music_directory= == ~/music, so the whole audio library is the playlist store and radio streams mix with curated playlists. Option 1: radio stream playlists (portable, 73 in the dotfiles repo) move to a dedicated =playlist_directory= (=~/.local/share/mpd/playlists=) via stow; the 22 curated local playlists (machine-specific track refs) live in the music tree. Also removes the broken ~/music/radio/ orphan (73 dead symlinks).

Full step-by-step spec (mpd.conf edit, repo restructure of =common/music/= → =common/.local/share/mpd/playlists/=, curated relocation, restow, verification incl. the 7 relative-path curated playlists, ratio propagation) is in the 2026-06-03 session record under .ai/sessions/. Two open decisions before executing: (1) drop the empty =60s Sounds.m3u= or refill with the SomaFM 60s URL; (2) curated playlists into =~/music/playlists/= subdir vs leave flat in ~/music/. Side cleanup surfaced: a stray audio file =Black Flamingos - Space Bar.m4a= is wrongly committed in the dotfiles repo's =common/music/= — git rm it and move to the synced library.
** DONE [#C] Install adopted modern CLI tools :tooling:solo:
CLOSED: [2026-06-24 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
Done 2026-06-24: added bat/dust/hyperfine/doggo to archsetup General Utilities (tealdeer was already declared), installed all five on velox, set =BAT_THEME=ansi= in =common/.profile.d/tools.sh= (tracks the dupre terminal palette), seeded the tldr cache. ratio still needs the =pacman -S= (additive; lands on its next archsetup run).
Decision (Craig, 2026-06-24): adopt all five recommended tools — =bat=, =dust=, =hyperfine=, =tealdeer=, =doggo= (all in extra). Add them to archsetup's package list and install on both machines. Optional candidates (=xh=/=jless=/=sd=/=ouch=) declined for now. Full evaluation: [[file:docs/2026-06-10-modern-cli-tools-evaluation.org][docs/2026-06-10-modern-cli-tools-evaluation.org]].

- Add the five to the appropriate pacman package section in =archsetup=.
- =pacman -S bat dust hyperfine tealdeer doggo= on velox + ratio.
- =bat=: set =BAT_THEME= to match the dupre palette once installed.
- =tealdeer=: run =tldr --update= to seed the cache after install.
** DONE [#C] Review file manager options for Wayland
CLOSED: [2026-06-24 Wed]
Decision (Craig, 2026-06-24): keep nautilus only; skip yazi. File management lives in Emacs dired plus the Super+F dirvish popup, so a TUI file manager has no daily user here. ranger was already ruled out (frozen upstream). Full evaluation: [[file:docs/2026-06-10-file-manager-evaluation.org][docs/2026-06-10-file-manager-evaluation.org]]. Follow-on surfaced: nautilus needs dark theming (filed as its own task).
** DONE [#B] Theme nautilus to a dark theme :bug:solo:
CLOSED: [2026-06-24 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
nautilus rendered blindingly white (Craig, 2026-06-24). As a GTK4/libadwaita app it follows the appearance portal's =org.freedesktop.appearance color-scheme=, which mirrors =org.gnome.desktop.interface color-scheme=. Two stacked causes:

1. velox had no system-wide dconf db at all — no =/etc/dconf/profile/user=, no =/etc/dconf/db/site.d/00-archsetup-defaults=, no compiled =site= db — so archsetup's declared default (=color-scheme='prefer-dark'=, =archsetup:1109-1119=) never reached the machine (velox predates that block). Created the profile + site defaults as archsetup writes them and ran =dconf update=. =gsettings get= then returned =prefer-dark=.

2. That alone did NOT fix the running session: a system-db default emits no GSettings change signal, so the appearance portal kept reporting =0= (no-preference → light), and libadwaita reads the portal, not =GTK_THEME=. (An early screenshot looked dark only because the shell env carries =GTK_THEME=Adwaita:dark=, which Hyprland-launched apps don't inherit — masking the real state.) Fix: a user-level =gsettings set org.gnome.desktop.interface color-scheme prefer-dark=, which signals the portal live. It now reports =1=, and a portal-driven nautilus (GTK_THEME unset) renders dark — screenshot-verified.

Durable: the user value persists in =~/.config/dconf/user=; archsetup's system-db handles fresh installs (the portal reads the default fresh at login, so no signal is needed there). No archsetup change. ratio may need the same one-two — see the Active Reminder.
** CANCELLED [#D] Test wlogout menu on laptop
CLOSED: [2026-06-24 Wed]
Merged into the "Wlogout exit-menu buttons are rectangular, not square" task ([#C]) — same effort (per-host wlogout button sizing across velox/ratio). The fixed-pixel-margins hint was folded into that task's body.
** DONE [#B] Enlarge org-capture popup to scratchpad size :hyprland:
CLOSED: [2026-06-24 Wed]
From a .emacs.d inbox handoff (2026-06-15, captured via roam): the quick-capture / org-protocol popup is too small to be effective — it should be about the size of a terminal scratchpad.

*** 2026-06-24 Wed @ 17:21:11 -0400 Sized the popup to the scratchpad, per-host in pixels
The 06-15 read was wrong: the real size lever is the Hyprland window rule, not the quick-capture char-cell count. The =size 900 500= rule on the org-capture window pinned it to 900x500 regardless of the frame's requested geometry (demoing 120x24 vs 180x32 looked identical because both clamped to 900x500). Tried a percentage rule (=size 75% 70%=) to auto-adapt per host like the pyprland scratchpad — native window rules do NOT honor percentages (only pyprland does), so the frame fell back to char-cell geometry and overflowed the screen. Fix: absolute pixels matching each host's terminal scratchpad, placed in the host tier (=<host>/conf.d/local.conf=) since pixels don't adapt across monitors. velox = 1078x671 (75%x70% of its 1437x958 logical desktop) — verified on-screen. ratio = 1892x936 (55%x65% of 3440x1440) — set but not yet eyeballed on ratio (tracked as an Active Reminder in notes.org). The shared hyprland.conf keeps float/center/stay_focused and a comment pointing at the per-host size. dotfiles change — needs commit in =~/.dotfiles=.

*** 2026-06-15 Mon @ 19:19:55 -0500 AI Response: popup size is the frame's char-cell count, not the Hyprland rule
Triaged under auto inbox-zero. The popup is the emacsclient frame named "org-capture", created by =~/.dotfiles/hyprland/.local/bin/quick-capture= with =(width . 90) (height . 22)= — 90 columns by 22 lines. Emacs sizes by character cells and overrides the Hyprland rule =windowrule = match:title ^(org-capture)$, size 900 500= (hyprland.conf:182). The live frame measured ~889x860 px; the width tracks the 90-column count, not the window rule. Setting the Hyprland rule to =size 55% 65%= (the scratchpad's pyprland spec) did not change the frame width, so I reverted it — dotfiles left clean.

Real lever: the column/line count in the quick-capture script. Scratchpad reference on ratio (DP-4, 3440x1440) is 55% 65% ~= 1892x936 px ~= 190 cols by 24 lines. Why this isn't a solo auto-fix — it needs a tradeoff decision:
- The script lives in the shared =hyprland/= stow tier, so a fixed ~190 columns overflows velox's 1920-wide laptop, and 24+ lines overflows velox's 1080 height (22 lines ~= 860 px is already near the safe max there).
- Emacs char-cell sizing doesn't adapt to the monitor the way pyprland's percentage does, so "scratchpad-size on both machines" needs one of: a fixed compromise count, a per-host override via the ratio/velox tiers, or a script that computes columns from the active monitor.
Options to weigh: (a) a safe-on-both compromise like width 120-130 / height 24; (b) per-host width through the ratio/velox tiers; (c) dynamic sizing in quick-capture from =hyprctl monitors=. Pick the tradeoff and I'll implement.
** DONE [#C] Highlight current month and year in the calendar hover :feature:waybar:quick:solo:
CLOSED: [2026-06-24 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
From the roam inbox (2026-06-24): the waybar clock's calendar tooltip highlights today's date in goldenrod; the current month and year header should be goldenrod too.

Done 2026-06-24: the date module is the custom =waybar-date= script (not the built-in clock), so the highlight lives in its tooltip markup. Added a sed wrapping line 1 of the current-month =cal= output (the centered "Month Year") in the same =#daa520= goldenrod the day highlight uses. Verified the tooltip JSON carries =<span color='#daa520'><b>June 2026</b></span>= with today's highlight intact and waybar live; the on-hover look is Craig's spot-check.
** DONE [#C] Wallpaper-set from dirvish doesn't work on Wayland :hyprland:
CLOSED: [2026-06-24 Wed]
From the roam inbox (2026-06-24, claimed for archsetup by Craig): typing =bg= in the dirvish popup doesn't change the wallpaper — Craig's read is it may still be wired to feh/X11 instead of a Wayland utility.

Findings (2026-06-24): the Wayland wallpaper utility on this setup is =awww= (waypaper's configured =backend = awww=; =set-theme= sets the default via =awww img <file>=). There was no shared wallpaper script (=bg= on PATH is just the shell builtin), and the dirvish =bg= command lives in the Emacs config, so it was calling the wrong (or no Wayland) setter.

Done 2026-06-24 (dotfiles 8be2484): added =set-wallpaper <image>= to the hyprland tier — sets live via =awww img= and persists the choice into =waypaper/config.ini=, the single Wayland-correct entry point. Resolves relative paths, validates the file, exits non-zero without persisting if awww fails. 8 Normal/Boundary/Error tests green; live-verified (awww set it, config rewrote). Notified =.emacs.d= to point the dirvish =bg= command at =set-wallpaper <file>= — that wiring is its piece (dependency cleared, =:blocker:= dropped).

Follow-up (separate, small): the login restore =exec-once= in =hyprland.conf= is hardcoded to =trondheim-norway.jpg=, so a wallpaper set via =set-wallpaper= shows live but won't survive a relogin until the exec-once becomes =waypaper --restore= (which reads the now-persisted config). Filed below.
** DONE [#B] Add backup before system file modifications :solo:
CLOSED: [2026-06-25 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
Safety net for /etc/X11/xorg.conf.d and other system file edits
Files like ~/etc/sudoers~, ~/etc/pacman.conf~, ~/etc/default/grub~ modified without backup
If modifications fail or are incorrect, difficult to recover - should backup files to ~.backup~ before modifying

Done 2026-06-25: added a =backup_system_file <path>= helper next to =safe_rm_rf= — it snapshots a pre-existing file to =<path>.archsetup.bak= before an in-place edit, idempotent (never clobbers an existing backup, so the pristine original survives repeated edits and re-runs), =cp -p= to preserve mode/ownership, no-op when the file is absent. Took the narrow scope (Craig's call): route only the in-place =sed -i= / append edits to *pre-existing* files through it — locale.gen, makepkg.conf, pacman.conf, sudoers, conf.d/wireless-regdom, geoclue.conf, conf.d/pacman-contrib, fstab, mkinitcpio.conf, vconsole.conf — and skip the brand-new drop-in files archsetup fully owns (nothing to back up; recovery is just deleting them). Tests: =tests/backup-system-file/= (7 Normal/Boundary/Error, incl. mode-preserved, existing-backup-not-overwritten, missing-target no-op, cp-failure). =make test-unit= green across all 5 suites; =bash -n= clean; only shellcheck note is the known SC2329 false positive (indirect STEPS dispatch). Integration verification is the next VM run.
** DONE [#B] Migrate bare-metal test runner to Testinfra, then delete the shell sweep :test:
CLOSED: [2026-06-25 Thu]
Plan + ZFS-coverage expansion: [[file:docs/design/2026-06-25-zfs-vm-test-coverage.org]] (build a ZFS base VM via archangel + a =FS_PROFILE= selector so =make test= covers the ZFS path, then migrate this runner to key auth + Testinfra against it, then delete the dead =validation.sh= functions = phase E here).
=run-test.sh= (VM) now uses the Testinfra/pytest sweep as its authoritative validator, but =run-test-baremetal.sh= (lines ~243-244) still calls the old =run_all_validations= / =validate_all_services= from =scripts/testing/lib/validation.sh=. Migrate the bare-metal runner to =run_testinfra_validation= too (same key + ssh-config approach, adapted for a real host), then delete the now-dead shell-sweep functions from =validation.sh=. Keep the live helpers: =ssh_cmd=, =attribute_issue=, =capture_pre/post_install_state=, =analyze_log_diff=, =categorize_errors=, =generate_issue_report=, and the =VALIDATION_*= counters/arrays. Deferred from the Testinfra cutover because it needs a bare-metal test loop to validate, out of scope for the VM-only autonomous run.
*** 2026-06-25 Thu @ 12:37:02 -0400 P-A/P-B shipped (FS_PROFILE selector); P-C blocked on archangel ZFS-install bug
P-A + P-B landed in =353b179=: =archsetup-test-zfs.conf= (archangel ZFS config) + an =FS_PROFILE= (btrfs default / zfs) selector across =vm-utils.sh= (=init_vm_paths= derives a per-profile image + validates the profile), =create-base-vm.sh= (selects the archangel config), =run-test.sh= (--help + profile display), and the Makefile (=make test FS_PROFILE=zfs=). Design simplification recorded: no =archsetup-vm-zfs.conf= needed — archsetup auto-detects ZFS from the live root via =is_zfs_root()=, so the archsetup run config is shared; only the archangel base config + base image differ. Open Q1 resolved: archangel supports ZFS root natively (it's the default FS).

P-C (build the ZFS base image) is BLOCKED on archangel. =create-base-vm.sh FS_PROFILE=zfs= built the disk + booted the archangel ISO fine, but the archangel install died: =dkms install zfs/2.3.3 -k 6.18.36-1-lts= exited 1, ZFS module not built. Root cause is in archangel, not archsetup: it appends the [archzfs] experimental repo then runs =pacstrap -K= with no =pacman -Sy= refresh, so it uses the archzfs sync db baked into the Feb-2026 ISO (zfs-dkms 2.3.3) while linux-lts is pulled fresh (6.18.36). 2.3.3 doesn't build against 6.18. velox runs zfs-dkms 2.4.2 on the same kernel from the same channel, so the fix exists upstream — archangel just needs to refresh the db before pacstrap (+ a fresh ISO). Bug + dependency handoff sent to archangel inbox (=2026-06-25-1236-from-archsetup-bug-zfs-install-fails-stale-baked.org=). Retry P-C once a fixed archangel ISO is available. P-D (bare-metal migration code) is still workable in the meantime against the btrfs VM / velox.

*** 2026-06-25 Thu @ 16:05:07 -0400 archangel unblocked; ZFS base built; 3 archsetup bugs fixed (local); re-run paused
archangel shipped the fix (archangel =89691a0=: =pacman -Syy= before pacstrap) + rebuilt the ISO. With it, =create-base-vm.sh FS_PROFILE=zfs= built a verified ZFS-root base (=archsetup-base-zfs.qcow2=, clean-install snapshot, kernel 6.18.36). =make test FS_PROFILE=zfs= then surfaced three real archsetup bugs against the current archangel base, each fixed in a LOCAL (unpushed) commit:
- =8ed42b9= informant: the base ships informant; its pacman PreTransaction hook (AbortOnFail) blocked archsetup's first transaction. Fix: =informant read --all= up front (guarded). PROVEN.
- =66caeb5= pacman.conf perms: the base ships =/etc/pacman.conf= 0600 (archangel =strip_repo_stanza= mktemp+mv clobbers perms), breaking user =makepkg=/=yay=. Fix: =chmod 644= after archsetup's edits. PROVEN (run reached 75 min deep).
- =05ec096= reflector: archsetup configured reflector's timer but never ran it, so installs used the base's 425-mirror worldwide list and pacman stalled ~15 min on a slow/unresponsive mirror. Fix: run reflector once before the heavy installs (=timeout=-bounded, non-fatal). NOT yet integration-proven — the next re-run validates it.
Second archangel handoff sent for the pacman.conf-0600 root cause (=2026-06-25-1440-...=); archsetup's chmod is defensive, archangel should ship 0644. Paused before the re-run at Craig's request (he starts =sudo make test FS_PROFILE=zfs= from the laptop). Possible harness-side factor on the stall: slirp IPv6 blackholing (one stalled conn was IPv6) — watch if it recurs despite reflector.

*** 2026-06-25 Thu @ 21:56:12 -0400 P-C GREEN — ZFS VM test path passes end to end
=make test FS_PROFILE=zfs= PASSED: archsetup exit 0 (full ~68-min ZFS install, reflector held — no stall), pytest =95 passed, 0 failed, 11 skipped=. The ZFS-conditional checks now run the ZFS branch instead of skipping: =test_bootloader_installed= (ZFSBootMenu EFI binary at /efi/EFI/ZBM), =test_mkinitcpio_hooks= (zfs udev hook), =test_console_font_configured= (vconsole.conf), =test_zfs_has_sanoid= all PASS; =test_backup_created_for_mkinitcpio= correctly SKIPs (ZFS+virtio edits nothing). The 3 archsetup issues (gamemode, mu, signal-cli AUR) are the known non-critical residuals, same as on btrfs. Four commits pushed to main: =8ed42b9= informant news-hook, =66caeb5= pacman.conf 0644, =05ec096= reflector-during-install, =eb379c3= ZFS-aware boot/backup tests. P-C (ZFS coverage, design phases A-C) is DONE. Remaining on this task: P-D (migrate run-test-baremetal.sh to inject_root_key + run_testinfra_validation) and P-E (delete the dead validation.sh shell sweep).
*** 2026-06-25 Thu @ 23:26:02 -0400 P-D + P-E done — whole epic closed
P-D (=771b92e=): migrated =run-test-baremetal.sh= to key auth + Testinfra. =inject_root_key= generalized to =root@$VM_IP= (vm-utils) so it serves both runners; the bare-metal runner now injects the key after the genesis rollback, threads =SSH_KEY_OPT= + a new =--port= through every ssh/scp, and validates via =run_testinfra_validation= instead of the shell sweep. Follow-up fix =fb495d4=: =set +e= around the validator (it returns pytest's rc, which under =set -e= aborted before the report) — caught by the smoke test. Validated against the ZFS VM (=--validate-only=, localhost:2222): connectivity, ZFS check, key auth, Testinfra connect+run, report all work; a green bare-metal install still needs real ZFS hardware.

P-E (=a4a339b=): deleted the dead shell sweep from =validation.sh= now both runners use Testinfra — run_all_validations, validate_all_services, run_full_validation, the ~35 validate_* checks, validation_pass/fail/warn/skip. Kept the live helpers (ssh_cmd, attribute_issue, capture_pre/post_install_state, analyze_log_diff, categorize_errors, generate_issue_report, VALIDATION_* counters + arrays). 1156 → 314 lines. Verified: no dangling refs, both runners parse + smoke-run clean, unit suite green.

Known follow-ups (not blockers): (1) archangel still owes the pacman.conf-0600 root-cause fix (handoff in its inbox; archsetup's chmod is the defensive layer). (2) The bare-metal runner runs =bash archsetup= with no --config-file — pre-existing, would prompt on real hardware; out of this epic's scope. (3) A true green bare-metal run needs real ZFS hardware (ratio).
** DONE [#B] Implement Testinfra test suite for archsetup
CLOSED: [2026-06-25 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
*** 2026-06-25 Thu @ Final fresh make test GREEN — Testinfra is the validator
=make test= (fresh build, 150-min cap) PASSED: =TEST PASSED=, =Validation: PASSED=, pytest =96 passed, 10 skipped, 0 failed, 0 errors=, pytest as the authoritative gate. ParallelDownloads now =10= on the fixed build. End-state: the VM test runner validates post-install via the Testinfra/pytest sweep (=scripts/testing/tests/=, 88 tests + conftest fixtures) — full parity with the old shell sweep plus expansion coverage (sshd hardening, =backup_system_file= .bak files, applied pacman/makepkg/NM/fail2ban/reflector config). Three real bugs surfaced + fixed by this work: (1) the 2026-06-24 sshd hardening had silently broken =make test= (root password SSH died mid-run → key auth, f50fc1d); (2) =ParallelDownloads= stuck at Arch's default 5 (sed only matched the commented form → fixed, 2d63802); (3) install monitor cap too tight at 90 min (→ 150, fe84b71). Follow-up filed: migrate =run-test-baremetal.sh= off the shell sweep, then delete the dead =validation.sh= functions (P5).
*** 2026-06-25 Thu @ Decision: port to Testinfra + expand coverage, design doc first
Reviewed against the existing harness: =scripts/testing/lib/validation.sh= already runs ~14 post-install checks (=run_all_validations=), so this isn't net-new capability — it's porting that shell validation to Testinfra/pytest for better expressiveness + reporting, then growing coverage. Craig's call (prioritizes test investment over feature speed): do the port and expand. Starting with a design doc in =docs/design/= per the task's own "design doc not yet written" note. Stale slice to drop/rescope: the X11/startx end-to-end tests (fleet is Wayland/Hyprland now).
*** 2026-06-25 Thu @ 00:54:22 -0400 P1 scaffold landed (advisory, alongside shell sweep)
Built the Testinfra harness skeleton: =scripts/testing/tests/= (conftest.py with the attribution marker + report hook + =target_user= fixture; 3 parity checks — user exists/shell, ufw enabled, dotfiles stowed+readable), =scripts/testing/lib/testinfra.sh= (=run_testinfra_validation=: ephemeral-key injection, ssh-config, pytest-over-SSH; advisory + non-fatal, =RUN_TESTINFRA= toggle), wired into run-test.sh after the shell sweep, and added =python-pytest python-pytest-testinfra= to =make deps=. Verified on host: py_compile clean, =pytest --collect-only= green in a throwaway venv (4 tests, fixtures resolve), =bash -n= + shellcheck clean, unit suite still green. Integration (the pytest sweep actually running against a VM) is unverified here — needs a =make test= run. Decisions locked: inject test key; run both through parity; full expansion (P4) in this task after the P3 cutover.
*** 2026-06-25 Thu @ 01:12:09 -0400 P2 full parity port (88 tests)
Ported the whole shell sweep to pytest: test_users (exists/shell/15 groups parametrized), test_packages (yay+functional, pacman, terminus-font, emacs+config readable, git, 5 dev tools), test_services (required enabled/active, enabled-only, timers, optional skip-if-absent, DoT drop-in, fail2ban/nmcli responds, log-cleanup cron, syncthing lingering, DNS/mDNS/docker skips), test_desktop (Hyprland tools+configs+portal+socket gated on install/compositor, DWM suckless, autologin), test_boot (grub, mkinitcpio hooks branched on zfs_root, console-font-in-initramfs, nvme gated, zfs/sanoid), test_keyring (dir 700/owner/default=login), test_archsetup (log no Error:, ≥12 state markers). conftest fixtures: target_user/home/zfs_root/has_nvme/hyprland_installed/dwm_installed/compositor_running/on_slirp. 88 tests collected, py_compile clean. Correctness fix vs the shell sweep: check =awww= not the stale =swww=. Installed python-pytest-testinfra on velox so the harness gate passes. Next: VM run to diff pytest vs shell sweep for parity.
*** 2026-06-25 Thu @ 01:24:11 -0400 Fixed: sshd hardening had silently broken =make test=
VM run #1 aborted ~6 min in (Error 5), before any validation ran. Root cause (pre-existing, not the Testinfra work): the 2026-06-24 sshd hardening sets =PermitRootLogin prohibit-password= + reloads sshd mid-install, and the harness SSHes as root by *password* throughout — so every op after that step got "Permission denied" and run-test.sh fataled before validations. Fix: =inject_root_key= authorizes a throwaway root key right after first SSH (before archsetup runs) and all helpers (=wait_for_ssh=/=vm_exec=/=copy_to_vm=/=copy_from_vm=/=ssh_cmd=) gained =$SSH_KEY_OPT= so they use key auth, which =prohibit-password= still allows. testinfra.sh reuses that key. Additive (password stays as fallback). bash -n + shellcheck clean. Re-running the VM suite to confirm it now reaches the validation + pytest phases.
*** 2026-06-25 Thu @ 03:33:33 -0400 Parity proven + P4 expansion validated on a live VM
VM run #3 (=make test-keep=, kept VM up): pytest parity = 78 passed / 10 skipped / 0 fail / 0 err — matches & exceeds the shell sweep (53/0/0). Then built P4 expansion against the live VM (iterating in ~30s, no rebuild): test_hardening (sshd prohibit-password, sysctl printk, /etc/issue emptied, vconsole font, /efi fmask), test_config_applied (pacman ParallelDownloads/Color/multilib, makepkg MAKEFLAGS/OPTIONS, NM dns+wifi-privacy drop-ins, fail2ban jail, reflector), test_backups (=.archsetup.bak= present for pacman.conf/makepkg.conf/sudoers/mkinitcpio.conf — end-to-end proof of the backup feature). Full suite vs live VM: 95 passed / 10 skipped / 1 fail. The 1 fail = a REAL archsetup bug the tests caught: =ParallelDownloads= stayed at the Arch default 5 because the sed only matched a commented =#ParallelDownloads=, but current Arch ships it uncommented — fixed the sed to match both (=^#\?ParallelDownloads=). Also fixed a test bug (=grep -qx '[multilib]'= → =grep -Fxq=, the brackets were a regex char class). Remaining: P3 cutover (pytest authoritative) + P5 retire shell sweep, then a final fresh =make test=.
*** 2026-06-25 Thu @ 03:38:28 -0400 P3 cutover: Testinfra is now the authoritative validator
run-test.sh dropped the =run_all_validations= + =validate_all_services= shell-sweep calls; =run_testinfra_validation= now drives =TEST_PASSED= (returns pytest's rc; "couldn't run" = fail, not a silent pass). It surfaces pytest's pass/skip/fail counts through the shared =VALIDATION_*= counters and parses =testinfra-attribution.txt= into the issue arrays so =generate_issue_report= still buckets failures archsetup/base/unknown. Validated the failure path against the still-up VM: pytest rc=1, failure correctly bucketed to [archsetup]. P5 (physically delete the dead shell-sweep functions) is NOT done here — =run-test-baremetal.sh= still calls =run_all_validations=/=validate_all_services=, so deletion must wait until the bare-metal runner is migrated too (filed below). Final step: fresh =make test= to confirm the pass path (ParallelDownloads now 10) with pytest as the gate.
*** 2026-06-25 Thu @ 08:35:26 -0400 Final run hit the harness 90-min install cap (not a regression)
The fresh =make test= timed out at 9/12 steps while building =vagrant= from AUR (=ARCHSETUP timed out after 90 minutes=, exit 124), so validation ran against a half-installed system → 10 pytest failures, all late-step (issue/sysctl/vconsole/mkinitcpio/docker/state-markers). The suite worked correctly — it caught an incomplete install. Verified my ParallelDownloads sed is clean (no pacman corruption) and archsetup logged 0 errors. Root cause: =MAX_POLLS=180= (90 min) is too tight for a full install with heavy AUR builds; bumped to 300 (150 min). Re-running.
Create comprehensive integration tests using Testinfra (Python + pytest) to validate archsetup installations

Tests should cover:
- Smoke tests: user created, key packages installed, dotfiles present
- Integration tests: services running, configs valid, X11 starts, apps launch
- End-to-end tests: login as user, startx, open terminal, run emacs, verify workflows

Framework: Testinfra with pytest (SSH-native, built-in modules for files/packages/services/commands)
Location: scripts/testing/tests/ directory
Integration: Run via pytest against test VMs after archsetup completes
Benefits: Expressive Python tests, excellent reporting, can test interactive scenarios

A design doc (not yet written) should cover:
- Complete example test suite (test_integration.py)
- Tiered testing strategy (smoke/integration/end-to-end)
- How to run tests and integrate with run-test.sh
- Comparison with alternatives (Goss)
** DONE [#C] Proton Mail Bridge font size :chore:quick:
CLOSED: [2026-06-24 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
From the roam inbox (2026-06-22): adjust the Proton Mail Bridge UI font to a comfortable size. The bridge is a Qt app, so it likely keys off Qt scaling or the qt5ct/qt6ct config like the other Qt apps (QT_SCALE_FACTOR or a font setting).

Done 2026-06-24 (dotfiles =hyprland.conf:47=): the bridge is a Qt6 *QML* app, so it ignores the qt6ct General font — bumped the UI font via =QT_FONT_DPI= on the autostart instead. Changed the exec-once to =env QT_FONT_DPI=108 protonmail-bridge --no-window= (default DPI is 96; 108 = 1.125x). Iterated live with Craig: 120 too big, 108 comfortable. hyprland.conf is a stow symlink so the change is already live; applies at every login. The =~/.config/autostart/Proton Mail Bridge.desktop= entry is dormant under Hyprland (no XDG-autostart), so it was left as-is.
** DONE [#C] Wallpaper login-restore is hardcoded, not waypaper --restore :hyprland:quick:solo:
CLOSED: [2026-06-24 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
The Hyprland =exec-once= (=hyprland.conf:26=) restores the wallpaper with a hardcoded =awww img ~/pictures/wallpaper/trondheim-norway.jpg=, so any wallpaper set later (via =set-wallpaper=, waypaper, or the dirvish =bg=) reverts on relogin. =set-wallpaper= now persists the choice to =waypaper/config.ini=, so switch the exec-once to =waypaper --restore= (after =awww-daemon= is up) to make set wallpapers survive a relogin. Small, dotfiles-only; verify by setting a different wallpaper, relogging, and confirming it sticks.

Done 2026-06-24 (dotfiles): swapped the line-26 exec-once from the hardcoded =awww img …/trondheim-norway.jpg= to =awww-daemon & sleep 1 && waypaper --restore=. waypaper has a real =awww= backend (in its =--backend= list), the stowed =waypaper/config.ini= carries =backend = awww= plus a default =wallpaper == line, so =--restore= works on a fresh install too. Mechanism verified live: =waypaper --restore= reapplied the persisted wallpaper via awww, exit 0. Relogin confirmation filed under "Manual testing and validation". Follow-up filed: =set-wallpaper='s =mv= detached the live =waypaper/config.ini= from its stow symlink, so set-wallpaper changes no longer flow back to dotfiles.
** DONE [#B] VM test harness shared one NVRAM file across filesystem profiles :bug:test:
CLOSED: [2026-06-27 Sat]
The harness shared one OVMF NVRAM file (=vm-images/OVMF_VARS.fd=) across the btrfs
and zfs profiles (=init_vm_paths= suffixed the disk image per profile but not the
NVRAM). NVRAM lives outside the qcow2, so a disk-snapshot revert can't restore it,
and a zfs run's ZFSBootMenu boot entries clobbered the btrfs GRUB entry. With no
removable =\EFI\BOOT\BOOTX64.EFI= fallback on the base ESP, the next btrfs run
booted into UEFI with no bootable device ("BdsDxe: No bootable option or device
was found", then PXE/HTTP, then SSH timeout before archsetup ran). Found
2026-06-27 trying to VM-validate the installer refactor.

Fixed: =OVMF_VARS= now carries the same per-profile suffix as the disk image
(=OVMF_VARS${img_suffix}.fd=) in =vm-utils.sh init_vm_paths=, so btrfs and zfs keep
separate NVRAM. Validated by a full green zfs run 2026-06-27 (ArchSetup exit 0,
Testinfra 96 passed / 0 failed). Remaining hardening tracked below.
** DONE [#B] Guard against live mesa/hyprland/wayland-runtime updates :hyprland:
CLOSED: [2026-06-28 Sun]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-09
:END:
A live =pacman -Syu= that swaps mesa/hyprland/wayland runtime libs out from under a running Hyprland session can crash the compositor: the next GPU-lib call hits a now-"(deleted)" library and SIGABRTs, taking the Wayland clients down with it. Hit ratio 2026-06-07 (mesa 26.0.6 -> 26.1.2 + hyprland upgraded live; Hyprland SIGABRT took down awww/insync/emacs). Likely the driver behind ratio's high lifetime unsafe-shutdown ratio — a crashed compositor forces a hard reset.

Shipped as a pacman PreTransaction hook rather than a wrapper, so it fires no matter how the upgrade is launched (pacman, yay, topgrade). =scripts/hypr-live-update-guard= aborts the transaction before any package is swapped when the GPU/compositor runtime set is being upgraded AND Hyprland is running, pointing the user to re-run from a TTY with the session stopped; it stays quiet when Hyprland isn't running (the safe from-a-TTY path). Override via =HYPR_ALLOW_LIVE_UPDATE=1= or by touching the sentinel file named in the abort message. archsetup installs the script to =/usr/local/bin= and the hook to =/etc/pacman.d/hooks/= in the hyprland path. Decision logic unit-tested (=tests/hypr-live-update-guard=, 9 cases). Live firing test filed under Manual testing and validation. Commits: archsetup (this session).
** DONE [#B] Collapsible waybar sides :waybar:
CLOSED: [2026-06-27 Sat]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-09
:END:
Let either side of the waybar collapse horizontally to a minimal base set, toggled by a click. Each collapsible side carries a small triangle / arrowhead pointing toward the screen edge it collapses into (away from center). Clicking it collapses that side to its base set and flips the arrow to point back toward center; clicking again restores the full side. Same shape-changes-with-state idea as the auto-dim indicator.

Spec (2026-06-19): [[file:assets/2026-06-19-collapsible-waybar-sides-spec.org]]. Spike that settled the mechanism: [[file:assets/2026-06-18-collapsible-waybar-sides-spike-findings.org]].

Decisions locked: right base set = date + worldclock + tray; left base set = menu + workspaces; per-side independent; host-agnostic (base set constant, full set is each host's existing config). Mechanism = config-swap + SIGUSR2 reload via an active-config copy in =$XDG_RUNTIME_DIR= (the CSS/state-file approach was disproven — GTK3 can't reflow-hide native modules). Lives in =~/.dotfiles/hyprland/=.

Shipped per spec (dotfiles 804bef6): 3 TDD'd scripts (=waybar-active-config=, =waybar-collapse=, =waybar-arrow=; 22 cases), arrow modules wired into the config (left arrow innermost-left, right arrow innermost-right), CSS ×3, =$mod+[= / =$mod+]= keybinds, and =waybar-toggle= relaunch updated to load the active config so a crash preserves collapse state. Verified live: click, keybind, and per-side independence all work; expand round-trips exactly to canonical.
** DONE [#C] Collapse waybar sysmonitor to a single icon + hover :feature:waybar:
CLOSED: [2026-06-27 Sat]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
From the roam inbox (2026-06-22): replace the spread-out sysmonitor readouts (temp, cpu, mem, storage) with one visible icon showing a single chosen metric, the rest in the hover tooltip. Open question: fold it into the battery component instead of a standalone module. Implementation lives in the waybar config under ~/.dotfiles.

Shipped as a standalone =custom/sysmon= module (Craig's call: host-dependent primary — battery on laptop, disk on desktop — rather than fold into battery, which is laptop-only). Backing script =waybar-sysmon= gathers cpu/temp/mem/disk/battery, shows the host-appropriate metric, rest in tooltip; 13-case TDD suite; removed the 5 native modules + their CSS across all 3 themes. Dotfiles be7469b.
** DONE [#C] Rename idle inhibitor to something more intuitive :chore:waybar:
CLOSED: [2026-06-27 Sat]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
From the roam inbox (2026-06-24): the "idle inhibitor" name doesn't work as a mnemonic — something like "sleep" (i.e. "keep awake" / "no-sleep") would land better. Decide the new name, then rename across the touchpoints: the =custom/idle= waybar module, the keybind mnemonic, and the backing script names (=hypridle-toggle= / =waybar-idle= from the 2026-06-24 idle-inhibitor work). Needs Craig's call on the name first, so not solo.

Renamed to "caffeine" (Craig's call, 2026-06-27): =custom/caffeine= module, =waybar-caffeine= + =caffeine-toggle= scripts, tooltip "Caffeine: ON/OFF", CSS + test suites updated. Keybind stays =$mod+I= (=$mod+C= is hyprpicker). Shipped in dotfiles 8b45b51.
** DONE [#B] ZFS pre-pacman snapshot installer step (ZFS-root) :feature:zfs:
CLOSED: [2026-06-30 Tue]
Add a ZFS-root-gated installer step that installs the pre-pacman snapshot pacman hook plus a self-pruning =/usr/local/bin/zfs-pre-snapshot= (KEEP=10). The script is hand-placed on velox, not authored by archsetup, so a reinstall loses it; snapshots accumulated unbounded (53 since April) because nothing prunes them and Sanoid ignores non-autosnap_ names. Gate to ZFS-root (velox; ratio is btrfs). Also correct the stale 2026-01-17 security-doc line claiming it's "already in install-archzfs". Needs the hook file (source from velox) and a ZFS-root VM test.

Shipped: =configure_pre_pacman_snapshots()= in boot_ux (late, ZFS-gated) + =scripts/zfs-pre-snapshot=; unit tests for pruning + a Testinfra assertion. VM-verified ZFS install passed 97/0 (test_zfs_pre_pacman_snapshot_hook PASSED). The "stale doc" turned out accurate (it's an install-archzfs archive) — left as-is. Design notes and the KEEP=10 script: [[file:docs/design/2026-06-29-zfs-pre-snapshot-installer.org]]. Origin: home handoff 2026-06-29.
** DONE [#B] Waybar timer module :waybar:
CLOSED: [2026-06-29 Mon]
:PROPERTIES:
:LAST_REVIEWED: 2026-05-26
:END:
Shipped as =wtimer= in the dotfiles repo (=134d61e=), a single always-visible module right of the battery/resource readout, non-collapsible. Covers all four modes (timer / alarm / stopwatch / pomodoro) with multiple running at once: the bar shows the most urgent item with a per-type glyph + "+N" badge, the tooltip lists them all. Left-click creates (fuzzel), middle-click pauses, right-click cancels, scroll cycles the primary; notify fires on completion and pomodoro phase changes. Pure-functions-over-injected-clock design; CLI serializes state with flock + atomic write so the 1s render and click handlers never lose an update or double-fire. TDD: 86 cases, 95% line coverage. Design spec: [[file:docs/design/2026-06-29-waybar-timer-module-spec.org][docs/design/2026-06-29-waybar-timer-module-spec.org]]. Live-verified on velox (glyph renders, position, countdown); the color states + click interactions filed under Manual testing and validation.

A custom waybar module providing three time-keeping functions, surfaced in the bar with click/scroll controls and dunst notifications on completion.

- *Alarm* — fire a notification at a wall-clock time (e.g. 2:00pm). Builds on the existing =notify= + =at= pattern from protocols.org.
- *Timer* — count down a duration (e.g. 25m) and notify when it elapses.
- *Pomodoro* — alternating work/break cycles (default 25/5, long break after 4) with the bar showing phase + remaining time.

Implementation notes (to flesh out when picked up): waybar =custom= module(s) with =exec= polling or a persistent =exec= script emitting JSON; click actions to start/pause/reset; a small state file under =~/.local/state= or =~/.local/var=. Lives in the hyprland tier (=dotfiles/hyprland/.config/waybar/= + a backing script in =hyprland/.local/bin/=). TDD the backing script per testing.md.

*** 2026-06-24 Wed @ 17:32:37 -0400 Scope expansion from roam capture (folded duplicate)
A roam-inbox capture asked for the same widget and expands the scope, so folding it in here rather than duplicating:
- *One panel, mode-selectable* — a single component where you choose timer / stopwatch / alarm; the icon changes to reflect the selected mode.
- *Stopwatch* — a count-up (the third function alongside the alarm/timer/pomodoro above), hover shows start time ("Stopwatch started: 12:22pm").
- Hover text per mode: timer "Timer: 5 min", alarm "Alarm: 12:15pm", stopwatch "Stopwatch started: 12:22pm".
- *Multiple simultaneous* — several timers/alarms/stopwatches set and displayed at once, in one panel.
- Deliverable includes proposing a few panel designs and recommending one before building.
** DONE [#B] Sysmon module right-click cycles the visible metric :feature:waybar:solo:
CLOSED: [2026-06-28 Sun]
Shipped in the dotfiles repo (=f7b6896=, implemented from this archsetup session per Craig). =waybar-sysmon= reads a selected metric from =$XDG_RUNTIME_DIR/waybar/sysmon-metric= (absent = host default, so the old behavior is preserved); the new =sysmon-cycle= helper advances through a host-appropriate ring (battery only on a laptop), wraps, and refreshes waybar via signal 12 wired to =on-click-right=. Left-click stays the btop popup. Added cpu/temp/mem icons + thresholds. TDD: 13 new =waybar-sysmon= selection cases + a 9-case =sysmon-cycle= suite, full dotfiles suite green (29 suites). =sysmon-cycle= symlinked into =~/.local/bin= on velox. Live visual/relogin check filed under "Manual testing and validation". Handoff sent to the dotfiles inbox.
Builds on the just-shipped =custom/sysmon= collapse (dotfiles be7469b). Right-clicking the module rotates which metric is the visible one, in a fixed order: battery → cpu → temp → mem → disk → back to battery. Each click advances one step and wraps around. The host default (battery on a laptop, disk on a desktop) is the starting/reset metric; the tooltip keeps showing all metrics regardless. Left-click stays =pypr toggle monitor= (the btop popup) — the cycle lives on =on-click-right=.

Implementation notes: =waybar-sysmon= needs a persisted selection (a state file in =$XDG_RUNTIME_DIR/waybar/=, absent = host default) that it reads to pick the visible metric. A new =sysmon-cycle= helper bumps the index and signals the module to refresh (add a =signal= to =custom/sysmon=, like the other custom modules; wire =sysmon-cycle= to =on-click-right=). TDD both — extend =tests/waybar-sysmon= for selection-driven output, add a =tests/sysmon-cycle= for the index advance/wrap and the signal.
** DONE [#B] Network module: enterprise WiFi add/edit deferred to vNext :waybar:network:
CLOSED: [2026-06-29 Mon]
Decided 2026-06-29 (Craig): keep v1 to open + WPA-PSK add/edit; the
WPA-Enterprise / 802.1X add/edit form is vNext, not a v1 phase. v1 still
*activates* any saved enterprise profile and points editing at nmtui/nmcli.
Evidence that settled it: 24 saved profiles on velox, 18 WPA-PSK, 0 enterprise —
no 802.1X network in Craig's history, so the form would be unused UI. If one ever
appears, nmtui adds it once and the module activates it thereafter. Spec:
[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]].
** CANCELLED [#B] Migrate terminal emulator from foot to ghostty :tooling:
CLOSED: [2026-06-28 Sun 13:58]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
Decision (Craig, 2026-06-24): switch from foot to ghostty. Drivers: ligatures (foot won't add them) and kitty-graphics + sixel image support (foot is sixel-only, no kitty-graphics plans). ghostty is pure-Wayland on Hyprland, declarative config that fits the theme system, runtime config reload (keybind / SIGUSR2 since 1.2). Trade-off accepted: slightly higher input latency than foot. Already in use as Emacs's terminal renderer, so the config + rendering are familiar and the 06-18 tmux theme was tuned against that surface. Full evaluation: [[file:docs/2026-06-10-terminal-emulator-evaluation.org][docs/2026-06-10-terminal-emulator-evaluation.org]].

Migration scope:
- archsetup: add =ghostty= to the package list; decide whether to keep =foot= installed as a fallback or drop it.
- dotfiles: port =foot.ini= → ghostty config (flat key=value). The shared foot.ini sets no font (per-host via =host.ini= include) — replicate that per-host font split for ghostty.
- Themes: the dupre/hudson =themes/<name>/= dirs hold foot configs; add ghostty theme files and teach =set-theme= to write + reload the ghostty config. Watch the reload-clobbers-OSC-10/11 bug (ghostty #2795) when wiring runtime theme switch.
- hyprland.conf: default-terminal keybind, pyprland scratchpad terminals, and any other =foot= references → ghostty.
- Verify on velox + ratio: ligatures render, latency acceptable in tmux+vterm use, dupre theme correct, sixel/kitty-graphics previews work.
** DONE [#C] Scratchpad launch turns on focus-follows-mouse :bug:hyprland:
CLOSED: [2026-06-28 Sun]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-28
:END:
Root cause: =float_switch_override_focus = 1= in hyprland.conf. With =follow_mouse = 0=, focus still jumped to the window under the pointer when it crossed a floating-tiled boundary, so launching a floating scratchpad re-enabled focus-follows-mouse onto tiled windows. Fixed by setting it to 0 (dotfiles =5619342=). Not a pyprland side effect.

Imported from roam inbox 2026-06-25. Repro: with two tiled windows, moving the mouse over the other tile does nothing (focus-follows-mouse off, as expected). Then launch a terminal (scratchpad), move the mouse over a tile, and focus now switches to the window under the pointer. Something about the scratchpad/terminal launch flips focus-follows-mouse on. Find what re-enables it (likely a Hyprland focus/input setting or a pyprland scratchpad side effect) and keep it off.
** DONE [#B] mod+J/K focus navigation: raise to front, reach floating, monocle fix :feature:bug:hyprland:
CLOSED: [2026-06-29 Mon]
Three improvements to =layout-navigate= (mod+J/K), validated live on velox:
- Raise the focused window to the front on focus navigation, so focusing a window behind an overlapping floating one brings it forward (dotfiles =5619342=, bundled with the =float_switch_override_focus = 0= scratchpad fix tracked above).
- Cycle into floating windows, so you can navigate back to a scratchpad like any window instead of it being a one-way trip (dotfiles =f2107f7=).
- Fixed a monocle regression from that change: the =cyclenext= dispatcher no-ops between monocle-stacked tiles, so focus navigation now computes the workspace window list and focuses the next/prev by address — layout-independent and floating-inclusive (dotfiles =09815f3=).
** CANCELLED [#C] archsetup Waybar Wi-Fi module should show no-internet state :feature:waybar:
CLOSED: [2026-06-29 Mon]
Consolidated, not dropped: the no-internet/captive indicator + the diagnostics/
bounce/speed-test scope are now Phase 1 + Phase 3 of the unified
[[*Waybar network module — custom/net][Waybar network module — custom/net]] parent. The work continues there;
this separate entry is retired so it's tracked in one place. Spec:
[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]].
** CANCELLED [#B] Audit dotfiles/common directory
CLOSED: [2026-06-28 Sun]
Refiled to the standalone =~/.dotfiles= repo, which owns this content since the 2026-06-16 split. Handoff sent 2026-06-28: =~/.dotfiles/inbox/2026-06-28-1335-from-archsetup-refiled-from-archsetup-task-audit-2026.org=. The three sub-tasks (review ~/.local/bin scripts, remove orphaned configs, verify stowed files are used) travel with it. Cancelled here, not abandoned.
** CANCELLED [#C] Zoom launches in a tiny window :bug:hyprland:
CLOSED: [2026-06-28 Sun 13:56]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
From the roam inbox: Zoom opens at a tiny size. Needs diagnosis (HiDPI scaling vs a window rule vs XWayland) and live verification with Zoom actually running — held for a Craig-driven debug pass, not a blind fix.
** DONE [#B] btrfs base VM unbuildable — archangel ISO bakes zfs-auto-snapshot :bug:test:
CLOSED: [2026-06-28 Sun]
Resolved: archangel shipped a fixed ISO (2026-06-27) that conditions the baked AUR list on the filesystem, so a btrfs install no longer drags in =zfs-auto-snapshot=. The btrfs base rebuilt and went green in the 2026-06-28 VM run (97/0, zero attributed issues). The EFI removable-fallback hardening is archangel-side and optional.
=make test-vm-base= (btrfs) fails in archangel's installer: the ISO bakes a fixed
AUR list ("downgrade yay informant zrepl pacman-cleanup-hook zfs-auto-snapshot
topgrade ventoy-bin") into every install regardless of =FILESYSTEM=. On a btrfs
install =zfs= isn't present, so =zfs-auto-snapshot='s =zfs= dependency can't
resolve and the unattended pacstrap aborts ("unable to satisfy dependency 'zfs'
required by zfs-auto-snapshot"). This is an archangel ISO bug (the baked list isn't
controllable from =archsetup-test.conf=), so it blocks btrfs-profile VM testing
until archangel ships an ISO that conditions the AUR list on the filesystem (or
drops zfs tooling from non-zfs installs). The 2026-06-27 btrfs base regen attempt
also wiped the prior (unbootable) btrfs base, so there's no btrfs base image until
this is fixed. zfs-profile testing works (=make test FS_PROFILE=zfs=).

Companion hardening (defense-in-depth, archangel-side): install the bootloader
with a removable =\EFI\BOOT\BOOTX64.EFI= fallback so a base boots even from
fresh/empty NVRAM, and real installs survive firmware that drops boot entries.