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
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
|
#+TITLE: Task Archive
#+FILETAGS: :archive:
* Resolved (archived)
** DONE [#B] Full install logs should contain timestamps
CLOSED: [2026-02-23 Sun]
Log filename includes timestamp via =date +'%Y-%m-%d-%H-%M-%S'=.
Functions =error_warn()=, =error_fatal()=, and =display()= all output timestamps via =date +'%T'=.
** DONE [#B] Validate DESKTOP_ENV default behavior
CLOSED: [2026-02-23 Sun]
Defaults to =hyprland= silently via =desktop_env="${desktop_env:-hyprland}"=.
Overridable via config file or =DESKTOP_ENV= environment variable.
** DONE [#B] Test archsetup username/password prompts
CLOSED: [2026-02-23 Sun]
Username prompt with regex validation (lines 320-332) and password prompt
with confirmation (lines 339-353) implemented and functional.
** DONE [#B] Verify SSH to remote server works
CLOSED: [2026-02-02 Mon]
Tested 2026-02-02: ssh cjennings.net returns "connected" successfully.
SSH key authentication working, no password required.
** DONE [#B] Verify Proton Mail Bridge retrieves email
CLOSED: [2026-02-02 Mon]
Verified 2026-02-02: Proton Mail Bridge running, ports 1143 (IMAP) and 1025 (SMTP)
listening on 127.0.0.1. mu4e email retrieval functional.
** DONE [#B] Fix unsafe sed patterns with user input
CLOSED: [2026-02-23 Sun]
Quoted =$username= in sed replacement, switched locale and wireless-regdom sed
patterns to pipe delimiter to avoid conflicts with path/encoding characters.
** DONE [#B] Fix unsafe heredoc variable expansion
CLOSED: [2026-02-23 Sun]
Quoted =UDEVEOF= heredoc and used placeholder + sed replacement pattern (same as hyprpm hook).
** DONE [#C] Add mountpoint check before ramdisk mount
CLOSED: [2026-02-23 Sun]
Added =mountpoint -q= guard before mount; skips with info message if already mounted.
** DONE [#C] Improve error handling in chained commands :chore:
CLOSED: [2026-05-07 Thu]
Line 820: three operations chained with =&&= reported as single failure.
Broken into separate error-handled steps.
** DONE [#C] Add comments on complex logic
CLOSED: [2026-02-23 Sun]
Added comments explaining wireless region locale-to-ISO3166 mapping and
archsetup clone strategy (why symlinks need user-owned repo).
** DONE [#D] Validate reserved usernames
CLOSED: [2026-02-23 Sun]
Added check against list of reserved system usernames (root, bin, daemon, sys, etc.).
** DONE Review: Hyprland conf.d source ordering :chore:
CLOSED: [2026-05-07 Thu]
~source = $HOME/.config/hypr/conf.d/*.conf~ was at top of hyprland.conf (line 9).
Machine-local overrides (gaps, monitor scale) were overwritten by defaults later in the file.
Fixed by moving source line to end of file. Update stowed hyprland.conf.
** DONE Review: natural_scroll not set for mouse (only touchpad) :chore:
CLOSED: [2026-05-07 Thu]
~input:natural_scroll~ was missing; only ~touchpad:natural_scroll~ was set.
Added ~natural_scroll = true~ to input block.
** DONE [#B] Extend layout-navigate to escape special workspaces
CLOSED: [2026-04-19 Sun]
With the =special:stash= overlay visible and focus on a window inside it,
=$mod+J= was trapped because =layoutmsg cyclenext= only operates within the
current workspace. The 2026-04-09 fix handled floating→tiled but not
special-workspace→regular.
Fix in =dotfiles/hyprland/.local/bin/layout-navigate=: when the active
window's =workspace.name= begins with =special:= and the user is navigating
focus (not moving), dispatch =togglespecialworkspace <name>= first, re-read
activewindow state, then fall through to the existing floating/layout
branches. Move variant (=$mod SHIFT J=) is intentionally left untouched so
moving a window out of a scratchpad remains a deliberate separate action.
Unit tests live in =tests/layout-navigate/= (stdlib =unittest=, fakes
=hyprctl= via PATH). Run with:
=python3 -m unittest tests.layout-navigate.test_layout_navigate=
** DONE Check linux-lts version until 6.18+
CLOSED: [2026-03-07 Sat]
Run =topgrade= and check =pacman -Q linux-lts=. Once 6.18+, remove =/etc/modprobe.d/amdgpu.conf= and mark this DONE.
Background: AMD Strix Halo VPE power gating bug causes system freeze. Workaround disables power gating. Fix is in kernel 6.15+.
Running linux-lts 6.18.16-1. amdgpu.conf workaround already removed.
** DONE [#D] Find or create a monocle layout for Hyprland
CLOSED: [2026-03-07 Sat]
Both existing monocle plugins (zakk4223/hyprlandMonocle, pianocomposer321/hyprland-monocle) are
abandoned and broken against current Hyprland. Options: fork and fix hyprlandMonocle (more features),
script a pseudo-monocle using fullscreen 1, or wait for a maintained plugin. Lower priority since
stash-window ($mod+O / $mod+Shift+O) covers the main use case. More important for laptop installs.
Resolved: Hyprland 0.54 added native monocle layout. Bound to $mod SHIFT M.
** DONE [#B] Investigate rlwrap not installed after archsetup run
CLOSED: [2026-05-11 Mon]
rlwrap was declared in archsetup (Emacs Dependencies) but missing after a run on ratio (2026-02-06).
The 2026-05-11 VM test run shows it installs cleanly in a fresh install (=...installing rlwrap via pacman @ 15:36:55=; =rlwrap 0.48-1= in the captured package list), so it doesn't reproduce — likely a one-off / machine-specific glitch on ratio, not a systemic skip. Closing; reopen if it recurs.
** DONE [#C] Remove stale hyprpm/plugins validations; make run-test.sh tolerant of validation failures
CLOSED: [2026-05-11 Mon]
The 2026-05-11 VM test aborted because =validate_hyprland_plugins= in =scripts/testing/lib/validation.sh= checked for =~/.local/bin/hyprland-plugins-setup=, which was deliberately removed in dd543e3 (=feat(hyprland): remove plugins, add layout cycling=; Hyprland 0.54 brings the layouts into core). The function's =return 1= under run-test.sh's =set -e= killed the run before the test report was written or the VM cleaned up.
Fix: deleted =validate_hyprland_plugins= and =validate_hyprpm_hook= (the hyprpm pacman hook was removed in the same commit) plus their calls in =validate_window_manager=; disabled errexit in =run-test.sh= from the validation phase onward so a failed check is counted (=VALIDATION_FAILED=) instead of fatal — the script signals pass/fail via its exit code at the end. Verified with =bash -n=; the next =make test= run confirms the count-not-abort behavior.
** DONE [#B] toggle key for touchpad on/off
CLOSED: [2026-05-20 Wed]
*** 2026-05-20 Wed @ 18:18:30 -0400 Spec: touchpad toggle + waybar indicator
**** Current state
A toggle mechanism already exists in the live home dir but is only partly committed.
- =~/.local/bin/toggle-touchpad= (live, NOT in repo): reads/writes a state file at =${XDG_RUNTIME_DIR:-/tmp}/touchpad-state= (values "enabled"/"disabled"), flips =hyprctl keyword "device[$TOUCHPAD]:enabled" true|false=, and fires a =notify info "Touchpad" ...= toast. Hardcodes =TOUCHPAD="pixa3854:00-093a:0274-touchpad"=.
- =~/.local/bin/touchpad-auto= (live, NOT in repo): daemon watching Hyprland's =.socket2.sock= for mouseadded/mouseremoved/configreloaded, auto-disables the touchpad when an external mouse is present, writes the same state file. Same hardcoded device name.
- Keybinding already committed: =bind = $mod, F9, exec, toggle-touchpad= (=hyprland.conf:315=).
- State file confirmed live at =/run/user/1000/touchpad-state= (reads "enabled").
**** Gap
1. No waybar indicator — nothing in modules-right shows touchpad state; no =custom/touchpad= module exists.
2. Neither =toggle-touchpad= nor =touchpad-auto= is committed into the repo. They live only in =~/.local/bin=, so a fresh stow won't install them. They belong in =dotfiles/hyprland/.local/bin/= (the =dotfiles/dwm/.local/bin/toggle-touchpad= is the old X11/xinput version, unrelated).
3. =touchpad-auto= is never started — no =exec-once= launches it.
4. The toggle doesn't refresh waybar, so an indicator would lag until its poll interval.
**** Proposed implementation
1. New status script =dotfiles/hyprland/.local/bin/waybar-touchpad= mirroring =waybar-layout= / =waybar-netspeed= (emit one JSON line: text + tooltip + class). Reads the state file the toggle already writes — single source of truth, no extra hyprctl call. Emits a "disabled" class + off-icon when the state file reads "disabled", else "enabled" + on-icon.
2. Waybar module in =dotfiles/hyprland/.config/waybar/config=, using "signal" so the toggle pushes an instant refresh (no polling — state only changes on toggle or mouse hotplug):
=, "custom/touchpad": { "exec": "waybar-touchpad", "return-type": "json", "signal": 9, "on-click": "toggle-touchpad" }=
Add =custom/touchpad= to modules-right, near =idle_inhibitor=.
3. Refresh-on-toggle: have =toggle-touchpad= (and =touchpad-auto='s set function) run =pkill -RTMIN+9 waybar= after each write to the state file (RTMIN+N ⇄ waybar "signal": N). Alternative: drop "signal", use "interval": 2 (simpler, ~2s lag, constant poll). Signal is the cleaner fit.
4. =style.css= (=dotfiles/hyprland/.config/waybar/style.css=): add =#custom-touchpad= to the shared padding/hover selector lists; add =#custom-touchpad.disabled { color: #d47c59; }= (the dupre orange already used for warnings). Enabled state inherits the default color.
5. Keybinding: keep =$mod+F9= (=hyprland.conf:315=). The waybar on-click gives a mouse path to the same action.
6. Commit the live scripts so stow installs them: =toggle-touchpad= and =touchpad-auto= into =dotfiles/hyprland/.local/bin/= (plus the =pkill= line), and =waybar-touchpad= (new). If the auto-disable-on-external-mouse behavior is wanted at boot, add =exec-once = touchpad-auto= near the other daemon exec-once lines.
**** Decisions (Craig, 2026-05-20)
1. Icons: enabled / disabled (the mouse / mouse-off pair).
2. Waybar on-click toggles the touchpad.
3. Commit =touchpad-auto= and add its =exec-once= so it runs at login.
4. Signal-driven refresh (=pkill -RTMIN+9 waybar=).
Note: the hardcoded device name =pixa3854:00-093a:0274-touchpad= is Framework-laptop-specific — a portability concern for other machines, not a blocker for this task.
*** 2026-05-20 Wed @ 18:29:06 -0400 Implemented the toggle + waybar indicator (in repo)
Built per spec + decisions above. Committed the two formerly-live-only scripts into the repo and added the indicator:
- =dotfiles/hyprland/.local/bin/waybar-touchpad= (new) — reads =$XDG_RUNTIME_DIR/touchpad-state=, emits JSON (text/tooltip/class), fail-safe to "enabled". Unit-tested in =tests/waybar-touchpad/= (6 Normal/Boundary cases).
- =dotfiles/hyprland/.local/bin/toggle-touchpad= — copied from =~/.local/bin=, added =pkill -RTMIN+9 waybar= so the indicator refreshes on toggle.
- =dotfiles/hyprland/.local/bin/touchpad-auto= — copied in, =pkill -RTMIN+9 waybar= inside =set_touchpad= so auto on/off events refresh too. Added =exec-once = touchpad-auto= to =hyprland.conf=.
- =waybar/config= — =custom/touchpad= module (signal:9, on-click toggle-touchpad), placed in modules-right before idle_inhibitor.
- =waybar/style.css= — =#custom-touchpad= in padding + hover lists; =.disabled { color: #d47c59 }= (dupre orange).
- =$mod+F9= bind already present (=hyprland.conf=), left as-is.
*** 2026-05-20 Wed @ 18:36:26 -0400 Deployed + verified on velox
Discovered =.local/bin= is stow-symlinked (waybar-layout/netspeed point into the repo); the two touchpad scripts were real files only because they weren't committed. Replaced both real files with repo symlinks and symlinked the new =waybar-touchpad= (matching the existing relative-symlink form). velox needed no hyprland.conf change — =exec-once = touchpad-auto= and the =$mod+F9= bind were already present. waybar =config= / =style.css= are real local files on velox (config diverges: standalone battery, no sysmonitor group), so applied targeted edits there rather than a copy.
Verified end-to-end after a waybar restart: config loads with no parse errors; toggle round-trips state enabled → disabled (, class disabled) → enabled (), and the =pkill -RTMIN+9 waybar= refresh fires into the running bar. Touchpad left enabled. Visual confirmation (icon in bar, orange when off) is Craig's to eyeball. Other machines (ratio) pick this up on =git pull && make restow hyprland= — their =.local/bin= and waybar configs are symlinks, so no real-file conflict there.
** DONE [#B] Airplane-mode toggle + waybar indicator
CLOSED: [2026-05-21 Thu]
Laptop-only low-power toggle, modeled on the touchpad indicator. Wifi off (bluetooth left alone for earbuds), CPU EPP → power, brightness → 35%, and stops network-only services. Disengage restores only what it recorded, so anything already off stays off.
*** 2026-05-21 Thu @ 17:43:07 -0400 Built the toggle, indicator, and tests
- =dotfiles/hyprland/.local/bin/airplane-mode= (new) — toggle. Engage records prior state (wifi enabled/disabled, EPP value, brightness, which services were active) to =$XDG_RUNTIME_DIR/airplane-state=, then applies low-power: =nmcli radio wifi off=, EPP → power on all CPUs (sudo sysfs write), =brightnessctl set 35%=, and stops Tier 1+2 services (tailscaled, proton.VPN, avahi-daemon, cups, wsdd, geoclue, sshd, fail2ban + user syncthing). Disengage replays the recorded state — only re-enables wifi if it was on, only restarts services it stopped. Refreshes the bar via =pkill -RTMIN+10 waybar=.
- =dotfiles/hyprland/.local/bin/waybar-airplane= (new) — indicator. Reads =mode= from the state file; fail-safe to inactive. Laptop-gated: exits silently (module hidden) when no battery is present (=/sys/class/power_supply/BAT*=). One clear plane glyph (FA U+F072) for both states; color carries state (gold active / gray inactive).
- =waybar/config= — =custom/airplane= module (signal 10, on-click airplane-mode), placed after custom/touchpad. =waybar/style.css= — =#custom-airplane= in padding + hover lists; =.active { color: #d7af5f }= (dupre gold).
- Tests: =tests/airplane-mode/= (20 — engage/disengage/preserve-existing-state/dispatch, via command stubs + fake EPP sysfs) and =tests/waybar-airplane/= (10 — states/boundary/laptop-gating). All green; shellcheck clean.
- Deployed + live-verified on velox (engage → disengage round-trip works). Other machines pick it up via git pull && make restow hyprland.
** DONE [#C] super+e emacs launch doesn't grab focus from tiled browser :quick:
CLOSED: [2026-05-22 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-05-22
:END:
Launching emacs with super+e while a browser window is open in tiled mode leaves focus on the browser instead of moving it to the newly opened emacs window in the main (left) portion of the screen. Expected: the new emacs window takes focus. Noticed 2026-05-22.
Resolved 2026-05-22: not a focus *failure* but a focus *fight*. Live socket2 capture showed the new (XWayland, non-pgtk Emacs 30.2) frame does get focus on open, then Firefox reclaims it via an activation request because =misc:focus_on_activate=true=. Set it =false= in the dotfiles repo (=3bfba5a=) — new-window focus is a separate path so emacs still focuses on open, but the browser can no longer steal it back. Verified by Craig.
** DONE [#C] Dim inactive windows in Hyprland :hyprland:
CLOSED: [2026-05-27 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-05-26
:END:
Shipped in the =~/.dotfiles= repo (=66124e8=): =dim_inactive = true=, =dim_strength = 0.4= (tuned by eye), =dim_special = 0.2= for pyprland scratchpads, and a =no_dim true= window rule for Zoom. The opt-out rule is =no_dim= (underscore), not =nodim= — the latter throws a config-error banner. Config uses Hyprland 0.55's =windowrule = match:class ...= grammar.
** CANCELLED [#A] Prevent X termination and VT switching (security risk)
CLOSED: [2026-05-21 Thu]
If someone grabs laptop at cafe and hits ctrl+alt+backspace, they kill screensaver/X and get console access
Need to disable: ctrl+alt+backspace (zap X) and ctrl+alt+F# (VT switching)
Previous attempts to configure in xorg.conf.d failed - need to investigate what's overriding the settings
Tried: /etc/X11/xorg.conf.d/00-no-vt-or-zap.conf with DontVTSwitch and DontZap options
Removed conflicting setxkbmap statements, gdm, and keyd configs - still didn't work
** DONE [#B] Add Rust installation via rustup instead of pacman package :quick:
CLOSED: [2026-05-26 Tue]
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Already implemented — =archsetup= lines 1976-1979 (Programming Languages and Utilities) =pacman_install rustup= then =rustup default stable= as the user. Closing on verification; the task predated that work.
The =rust= package has been removed from archsetup. Need to add Rust installation using =rustup= (the official Rust toolchain manager) instead of the Arch package.
Steps:
- Install rustup: =pacman -S rustup=
- Initialize default toolchain: =rustup default stable=
- Consider adding to archsetup or post-install script
Reference: Removed from archsetup on 2025-11-15
** CANCELLED [#D] Add cpupower installation and enabling to archsetup :quick:
CLOSED: [2026-05-26 Tue]
Implemented, VM-verified, then removed — wrong tool for this fleet. Both machines run active-mode pstate drivers (ratio amd-pstate-epp, velox intel_pstate) where only performance/powersave exist and the driver self-manages frequency via EPP; both correctly sit on powersave. cpupower's governor-forcing only helps older acpi-cpufreq systems, which we don't run. Forcing performance would pin max clocks (worse on the laptop, pointless on the desktop). Dropped from archsetup rather than ship a backwards default.
cpupower service configures the default CPU scheduler (powersave or performance)
Install cpupower, configure /etc/default/cpupower, enable service: ~systemctl enable --now cpupower.service~
** DONE [#C] Airplane-mode toggle robustness follow-ups :quick:solo:
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Shipped 2026-06-10 as dotfiles commit =16fbe4e=, TDD'd (23 tests green). Both gaps closed: the toggle now no-ops without a BAT* (same check as waybar-airplane, AIRPLANE_POWER_SUPPLY_DIR override for tests), and an empty recorded brightness at disengage falls back to 100% (AIRPLANE_BRIGHTNESS_DEFAULT) instead of stranding the screen at 35%.
** DONE [#B] protonmail-bridge package service conflicts with Hyprland autostart :cmail:
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Craig confirmed resolved 2026-06-10 — the per-machine fix (disable the packaged user service, Hyprland exec-once as sole launcher) has held since 2026-05-22 with no recurrence.
The =protonmail-bridge= package ships an enabled systemd user service (=/usr/lib/systemd/user/protonmail-bridge.service=, =--noninteractive=, =Restart=always=) that double-launches with the Hyprland =exec-once = protonmail-bridge --no-window= GUI autostart. Two symptoms: (1) no tray icon — the headless service grabs ports 127.0.0.1:1143/:1025 before the GUI =--no-window= instance can bind; (2) TLS cert mismatch — the headless service can't reach gnome-keyring (starts outside the graphical session), falls back to its own self-signed cert, so =mbsync=/mu4e and cmail-action.py fail STARTTLS against =~/.config/protonbridge.pem= with SSL CERTIFICATE_VERIFY_FAILED.
Fix applied per-machine 2026-05-22: =systemctl --user disable --now protonmail-bridge.service=, leaving the Hyprland exec-once GUI as the sole bridge (tray icon returns, served cert matches, =mbsync -a= clean). A fresh install re-enables the package service, so make it durable: mask/disable =protonmail-bridge.service= during install (likely in =scripts/cmail-setup-finish.sh=) and document that the Hyprland exec-once is the intended launcher — never run both. Source: handoff from .emacs.d 2026-05-22.
** DONE [#B] Add signal-cli to the standard install :tooling:signal:solo:
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Shipped 2026-06-10 as archsetup commit =1229fb2= — =aur_install signal-cli= beside signal-desktop, with the JRE/update-cadence/manual-linking caveats as comments.
Add =signal-cli= (AUR) to the regular package set so every provisioned machine has it. It's the headless JSON-RPC engine for an in-Emacs Signal client (a =signel= fork) that's the same across all machines. Source: handoff from .emacs.d 2026-05-26.
- =aur_install signal-cli= in the appropriate section (comms/messaging or AUR utilities).
- Runtime needs a JRE (OpenJDK 17+) — already satisfied by =jdk-openjdk=; note it as a dependency if the install set is ever trimmed.
- Keep-current caveat: signal-cli must update roughly every 3 months or Signal-Server rejects it (client-version floor moves). It belongs in the regularly-updated AUR set, not pinned.
- Linking is per-machine and interactive (QR scan from phone's Linked Devices), so that stays manual. archsetup only guarantees the binary is present.
** DONE [#B] Mic-mute keybind + waybar indicator :waybar:hyprland:solo:
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Shipped 2026-06-10 as dotfiles commit =07d056c= (script + 5 unit tests + bind + waybar module + CSS in all three theme files; old CTRL+ALT+SPACE bind removed). Verified live on ratio: state flips in wpctl, indicator renders both states with correct glyphs and colors, notifications fire. velox picks it up via pull + restow.
A single mute state in PipeWire, reachable from a keybind and a waybar indicator, each reflecting the other. Agreed design (2026-06-10):
- *Keybind*: Super+Shift+A (=bindl= so it works on the lock screen), running a =mic-toggle= script in =hyprland/.local/bin/=: =wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle=, then read the new state and fire =notify= (alert "Mic muted" / success "Mic live"). wpctl targets PipeWire's default source, so the bind keeps working if the default mic changes (ratio has three capture devices).
- *Waybar indicator*: a second pulseaudio module instance (=pulseaudio#mic=) using =format-source= / =format-source-muted= — waybar subscribes to PipeWire events natively, so the keybind and the click both update the icon with no signal plumbing (unlike =custom/dim=). =on-click= runs the same wpctl toggle.
- *Icons*: Nerd Font MD glyphs — mic (U+F036C) live, mic-off (U+F036D) muted — matching the MD volume glyphs already in the pulseaudio block. Verify by rendering, not by name (BerkeleyMono remaps codepoints; see the 2026-06-10 glyph lesson).
- *Coloring* (dupre): default =#969385= when live; =#d47c59= when muted — same semantic as =#custom-touchpad.disabled= (an input device turned off). The gold =#d7af5f= stays reserved for active/attention states (airplane, dim). Mirror the rule in the hudson theme's waybar css with its palette equivalent.
- *Remove the old mechanism entirely*: the =CTRL ALT, SPACE= amixer Capture-toggle bind in =hyprland.conf= (~line 325) — ALSA-level, fragile with multiple capture devices, brittle notify grep chain.
Lives in the dotfiles repo (=hyprland/.config/hypr/hyprland.conf=, =hyprland/.config/waybar/=, =hyprland/.local/bin/=). TDD the =mic-toggle= script per the dotfiles suite. velox picks it up via pull + restow.
** DONE [#B] Waybar theme-CSS drift — live style.css ahead of theme copies :waybar:hyprland:solo:
CLOSED: [2026-06-11 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-11
:END:
Shipped 2026-06-10/11 across two dotfiles commits: =1589734= reconciled dupre to a byte-copy of the live style.css, rebuilt hudson with the full live selector set in its palette, and added the guard suite (dupre must equal live; hudson must cover every live selector). The same guards were extended to the foot.ini family in =c5e699b= when the per-host work touched it (set-theme overwrites foot.ini the same way). The symlink-instead-of-cp alternative wasn't needed — the test guard catches drift at =make test= time.
** DONE [#B] Add =uv= to the install playbook :tooling:python:solo:
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Shipped 2026-06-10 as archsetup commit =3e22b06= — =pacman_install uv= in the Python tooling block (uv 0.11.19 in extra). Exercised by the same-day hyprland VM run.
Add =uv= (Astral's Python package + script runner) to archsetup so fresh machines pick it up automatically. Currently installed by hand on ratio + velox (=/usr/bin/uv= 0.11.15), not in the standard set — a fresh install would skip it, and project scripts using PEP 723 inline-script metadata (=#!/usr/bin/env -S uv run --script= shebangs) would fail with =env: uv: No such file or directory=. Source: handoff from health 2026-05-29 ([[file:assets/outbox/2026-05-29-1127-from-health-todo-a-add-uv-to-the-install-playbook.org][outbox copy]]).
Health requested [#A] (load-bearing for the PEP 723 pattern they're promoting + the rulesets template-script proposal). Demoted to [#B] for archsetup: no current install is broken (uv is pre-installed everywhere it's needed), and the shape matches the existing [#B] tooling-codification tasks (eask, signal-cli) — load-bearing for other projects, manually installed today, codify so fresh installs pick it up.
- *Install via pacman* — =uv= is in extra (=pacman -S uv=). Cleanest path; auto-updates with the rest of the system. AUR =uv-bin= and Astral's official installer are alternatives but add a non-pacman path to maintain.
- *Placement* — alongside the existing language-tooling block in =archsetup= (near =rustup=, =nvm=, or the Python set). Decide the exact section at implementation time.
- *Verification* — post-install =which uv && uv --version=; PEP 723 end-to-end check per the health handoff (=/tmp/uv-test.py= shebang script with inline =requests= dep).
Related: the new [#B] LLM task above may grow scripts that benefit from PEP 723 (e.g. =scripts/llm-smoke-test.sh= if Python-based). =uv= landing here removes that friction.
** DONE [#A] Separate dotfiles from archsetup
CLOSED: [2026-06-09 Tue]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-09
:END:
*** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Dotfile separation plan
Approach: keep =dotfiles/= committed in this repo as the working default (Craig's machines and CI keep functioning untouched), but make the *source location* a config variable. The install script learns one new conf key — =DOTFILES_REPO= / =DOTFILES_BRANCH= — and when set, clones that repo into =~/.dotfiles= and stows from there instead of from =dotfiles/= inside archsetup. The Makefile gets a =DOTFILES= override env var so the same stow targets work whether dotfiles live in-repo or elsewhere. No submodule (adds fragility for a curl|bash installer); a separate published =archsetup-dotfiles= repo is optional follow-up, not a blocker.
1. Add conf keys to =archsetup.conf.example= under the "Git Repositories" block (after line 57): =DOTFILES_REPO= (commented, with note "leave unset to use the dotfiles bundled with archsetup"), =DOTFILES_BRANCH= (default =main=), and =DOTFILES_DIR= (target clone path, default =~/.dotfiles=). Document that a user's repo must have =common/= plus optionally =dwm/= and =hyprland/= subdirs that stow cleanly to =~=.
2. In =archsetup= lines 114-122, map =DOTFILES_REPO=/=DOTFILES_BRANCH=/=DOTFILES_DIR= to lowercase vars. At lines 136-146, leave =dotfiles_dir="$archsetup_dir/dotfiles"= as the fallback default and add =dotfiles_repo="${dotfiles_repo:-}"=.
3. In =user_customizations()= (lines 828-854): after the archsetup clone (line 838-841), branch — if =dotfiles_repo= is non-empty, =git clone --depth 1 --branch "$dotfiles_branch" "$dotfiles_repo" "$dotfiles_clone_dir"= (chown to user) and set =dotfiles_dir="$dotfiles_clone_dir"=; else keep =dotfiles_dir="$user_archsetup_dir/dotfiles"= (line 844). The stow calls at lines 847-854 stay as-is since they just =cd "$dotfiles_dir"=. Guard the hyprland stow (851) so it no-ops if the user repo has no =hyprland/= dir.
4. The waybar-battery sed block (lines 856-865) and the =git restore= step (lines 896-902) both assume Craig's exact files — wrap each in an existence check (=[[ -f "$waybar_config" ]]=, and only =git -C "$dotfiles_dir" restore .= when =dotfiles_dir= is a git repo). Right now they'd error on a foreign dotfiles tree.
5. =Makefile= line 5: change =DOTFILES := $(shell pwd)/dotfiles= to =DOTFILES ?= $(shell pwd)/dotfiles= so a user with external dotfiles runs =make stow hyprland DOTFILES=~/.dotfiles=. =reset= (line 123, =git checkout -- dotfiles/=) and =import= (writes to =$(DOTFILES)/$(DEST)=) already key off =$(DOTFILES)= except that one hardcoded path — fix line 123 to =git -C $(DOTFILES) checkout -- .=. Update the =help= text (lines 16-45) to mention the =DOTFILES== override.
6. Migration: this is purely additive. Default behavior = today's behavior, so Craig's existing machines and =make test= VMs are unaffected. Craig can later extract =dotfiles/= to =git.cjennings.net/archsetup-dotfiles= and set =DOTFILES_REPO= in his own =archsetup.conf= — but that's his choice and a separate commit; the in-repo copy stays as the canonical default. Update =CLAUDE.md= "Project Structure" + "Makefile Targets" sections to document the override.
7. Simple-UX summary to put in =archsetup.conf.example= and README: "Accept the defaults → you get the bundled dotfiles (DWM or Hyprland). Bring your own → set =DOTFILES_REPO= to your git URL; it gets cloned to =~/.dotfiles= and stowed automatically." Optional stretch: ship a tiny =dotfiles/minimal/= (bash/zsh/git/tmux only) and a conf option =DESKTOP_ENV=none= path that stows just that.
Open questions for Craig:
- Extract =dotfiles/= to a standalone repo now, or defer? (Plan above defers — keeps it as the in-repo default.)
- Clone target: =~/.dotfiles= vs =~/code/dotfiles= vs let the user pick via =DOTFILES_DIR=?
- Should =DOTFILES_REPO= unset mean "bundled dotfiles" (proposed) or "no dotfiles at all"?
- Do you want the minimal/headless starter set (=dotfiles/minimal/=), or out of scope?
- Keep =git restore=/=git checkout= conflict-recovery behavior only when dotfiles are a git checkout, and silently skip otherwise — acceptable?
*** 2026-05-13 Wed @ 13:07:54 -0500 AI Response: Open questions answered; full spec written
All 5 questions resolved: extract now (Q1), clone to =~/.dotfiles= (Q2), no opt-out — =DOTFILES_REPO= always has a default (Q3), include =minimal/= as Tier B with TUI apps (Q4), error out if dotfiles dir isn't a git checkout (Q5). Full spec at [[file:docs/PLAN-dotfiles-separation.org][docs/PLAN-dotfiles-separation.org]] — covers the =minimal/= tree, SSH/GPG availability, three-phase implementation plan (Phase 1: extract + populate new repo at =cjennings.net/archsetup-dotfiles.git=; Phase 2: wire archsetup + VM test; Phase 3: migrate machines + remove =dotfiles/=), commit map, and open observations. Implementation gated on spec review.
*** 2026-05-14 Thu Review docs/PLAN-dotfiles-separation.org
CLOSED: [2026-05-14 Thu]
Review the spec for accuracy, edge cases, and scope. Flag changes before implementation starts. See [[file:docs/PLAN-dotfiles-separation.org][docs/PLAN-dotfiles-separation.org]].
*** 2026-05-14 Thu @ 21:43:41 -0500 AI Response: Review resolved; spec locked for Phase 1
Walked the spec's 5 open questions plus my 5 review concerns. Locked: URL =https://git.cjennings.net/dotfiles.git= (anonymous HTTPS read confirmed against existing repos at the same host), bare repo path =/var/git/dotfiles.git=, scope = Phase 1 only (~30 min). Added =environment.d/envvars.conf= (with rofi path stripped) and =systemd/user/emacs.service= to the =minimal/= tree; skipped =ncmpcpp= and =systemd/user/geoclue-agent.service=. Phase 2/3 constraints folded into the spec body for the executor: =DESKTOP_ENV=none= VM test required (was optional), clone uses =sudo -u "$username"= to avoid chown-after races, Phase 3 unstow/restow runs without an intermediate Hyprland reload, dotfiles repo can't go on GitHub until secrets cleanup ships, and Step 3.3 documents the post-install update flow. Latest spec at =docs/PLAN-dotfiles-separation.org= (=817d939=). End-of-day Phase 1 session reads from there and executes.
*** 2026-05-22 Fri @ 13:41:08 -0500 Phase 1 executed — dotfiles repo live on cjennings.net
Created the bare repo at =/var/git/dotfiles.git=, extracted =dotfiles/= from archsetup with =git filter-repo --subdirectory-filter= (229 commits, per-file history preserved), built the =minimal/= stow target per the spec, and pushed to =git@cjennings.net:dotfiles.git= (HEAD =68daeab=). Anonymous read at =https://git.cjennings.net/dotfiles.git= confirmed. Two spec corrections committed in archsetup (=7c26495=): push URL switched to SSH (HTTPS is read-only), and =minimal/.profile.d/= now ships 5 files including =claude.sh= (added on Craig's call, post-dated the spec lock). Phase 2 (wire archsetup config + VM test, ~2-3 hrs) and Phase 3 (migrate machines, remove =dotfiles/= from archsetup) remain.
*** 2026-05-22 Fri @ 17:05 -0500 Phase 2 shipped — archsetup clones the dotfiles repo
Wired archsetup to the external dotfiles repo: clones =DOTFILES_REPO= to =~/.dotfiles= and stows per =DESKTOP_ENV= (dwm/hyprland → common + that DE; none → minimal). Added =DOTFILES_REPO=/=BRANCH=/=DIR= config keys + validation; test harness serves the repo to the VM as =/tmp/dotfiles-test=. Commits =bab6901= (feat) + =68172c8= (test infra), pushed to origin/main. Spec-directed =sudo -u= clone hit a real bug — =useradd -m= skips the home-dir chown when =/home/$username= pre-exists (root-owned), so the user-clone failed with Permission denied; fixed by cloning as root + =chown -R= (mirrors the archsetup clone). git restore now runs for all DE paths (minimal ships skel-colliding .bashrc etc.).
*** 2026-05-22 Fri @ 18:10 -0500 Phase 3.1 + 3.3 done — this machine on ~/.dotfiles
Migrated this workstation: cloned the dotfiles repo to =~/.dotfiles=, committed the gpg-agent SSH routing (=.zshenv= + =envvars.conf=) that was uncommitted in the live tree as =888a599= in the dotfiles repo, then =make unstow hyprland= + =make stow hyprland DOTFILES=~/.dotfiles=. Snag: unstowing while Hyprland ran made it write a stub hyprland.conf that blocked the restow — quit Hyprland, removed the stub, restowed clean. All symlinks now resolve into =~/.dotfiles=. CLAUDE.md updated with the external-repo docs + migration steps + the quit-Hyprland gotcha (=e1810ce=). Remaining: 3.2 (=git rm dotfiles/=) blocked until ratio + velox migrate the same way.
*** 2026-05-22 Fri @ 21:20 -0500 velox migrated to ~/.dotfiles (laptop overrides preserved)
ratio is THIS machine (was "fractal" pre-reinstall) — migrated in 3.1. velox migrated over SSH (Craig quit its Hyprland): cloned ~/.dotfiles, stowed common+hyprland from it. velox carries deliberate laptop-local real-file overrides (foot.ini font 12, pypr config.toml laptop scratchpad sizing, waybar config battery module) that shadow stow — preserved them as local real files (backed up, restowed the rest, restored the overrides). All machines now on ~/.dotfiles.
*** 2026-06-02 Tue @ 12:16:54 -0500 Phase 3.2 done — removed in-repo dotfiles/ from archsetup
git rm'd the in-repo =dotfiles/= tree (831 files) now that ratio + velox both stow from =~/.dotfiles=; the installer already clones DOTFILES_REPO so nothing read it at install time. Stripped the stow targets from archsetup's Makefile (kept VM-integration + the safe-rm-rf installer-helper suite). Updated CLAUDE.md (Project Structure, Makefile Targets, Dotfiles Repository, Script Counts, Theme/Key-Config path refs) and README.md (dotfile-management, theme, DE, unit-test sections) to point at =~/.dotfiles=; the README had been describing the pre-Phase-2 in-repo model. Commit b10cba5 on archsetup origin/main. velox + ratio local clones drop dotfiles/ on their next archsetup pull (ratio: see the "Pull Phase 3.2 changes onto ratio" task). 4 untracked calibre cache/annotation files that were never committed got moved aside to /tmp/archsetup-dotfiles-orphan-untracked-20260602 (disposable reading-position markers).
*** 2026-06-02 Tue @ 12:16:54 -0500 Migrated script unit-test suites + a Makefile into ~/.dotfiles
Gave =~/.dotfiles= its own Makefile rather than repointing archsetup's =DOTFILES= default — the dotfiles repo now owns its stow tooling and tests, so it manages and validates standalone (relevant to the open-source release too). Authored =~/.dotfiles/Makefile= with the stow family (=stow/restow/reset/unstow/import= + check-de/check-dest + DE/DEST machinery) plus a =make test= target (mirrors archsetup's hyphenated-dir test-unit loop). Moved-Makefile fixups: =DOTFILES := $(shell pwd)= (trees at repo root), =reset='s revert scoped to =git checkout -- common $(DE)= (not the whole repo — caught in review), import header/path "dotfiles/$(DEST)" → "$(DEST)", =minimal= added to the import DEST filter only.
Moved 6 suites (=airplane-mode=, =layout-navigate=, =notify=, =tmux-util=, =waybar-airplane=, =waybar-touchpad=) into =~/.dotfiles/tests/=, dropping the =dotfiles/= =SCRIPT=-path prefix (=REPO_ROOT= is now the dotfiles root), and copied their fixtures (=layout-navigate/fake-hyprctl=, =tmux-util/fake-{fzf,kill,sleep,tmux}=). =waybar-netspeed='s suite was already there. =safe-rm-rf= stayed in archsetup (it tests the installer, not a dotfile). =make test= green: 7 suites, 124 tests. Committed 59b10c4 + pushed to the dotfiles repo. =minimal= is a standalone tree (stowed alone, not =common + minimal=), so a =make stow minimal= target needs its own branch — deferred as a small follow-up; the move kept stow/restow/reset/unstow behavior-identical to archsetup (dwm/hyprland).
*** 2026-06-09 Tue @ 19:21:36 -0500 Pulled Phase 3.2 onto ratio + cleaned dangling links
ratio's archsetup clone was already current with origin/main (Phase 3.2 pulled), but the migration had left stale symlinks pointing into the now-deleted =~/code/archsetup/dotfiles=: =~/.config/calibre= plus a manual =~/music/radio/= playlist farm (73 broken =.m3u= links) and one dead reference under =~/projects/home/reconciliation=. Re-pointed calibre into =~/.dotfiles/common/.config/calibre=. Deleted the 73 radio links — dead and redundant, since the same playlists already stow correctly to =~/music/*.m3u=, which is what mpd reads (=music_directory=/=playlist_directory= both =~/music=) — and removed the reconciliation link. ratio now has zero archsetup-dangling symlinks. (The ~3400 other dangling links in =~= are unrelated system/flatpak noise: ca-certificates, =/run/host=, =/bin=.)
** DONE [#B] Cleaner per-machine override mechanism for the dotfiles repo
CLOSED: [2026-06-11 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-11
:END:
Shipped 2026-06-11 as dotfiles =c5e699b= after spec review (all five questions decided — see the spec's Status table). Host tiers =ratio/= + =velox/= auto-included by every stow target; first tenants: hypr local.conf (velox HiDPI scale + XWayland toolkit env, replacing the Zoom per-app hack), pypr whole-file split, foot font via per-host host.ini include. waybar stays shared (velox's was stale, not divergent). velox restows cleanly for the first time and caught up on all pending dotfiles work. Drift guards extended to foot.ini; Makefile host logic unit-tested (15 suites green). Phase 5 (installer) filed as a follow-on below. velox needs a Hyprland restart for the env vars — see Manual testing.
velox keeps laptop-specific configs (foot font, pypr scratchpad sizing for 2256x1504, waybar battery) as local REAL files shadowing the stow symlinks. That's fragile: any =make restow= on velox re-conflicts (hit exactly this during the 2026-05-22 migration — stow aborts on the real files). The =~/.dotfiles= model needs a real per-machine override story (a =minimal/=-style per-host package, a documented local-override convention with =.stow-local-ignore=, or host-conditional includes) so overrides survive restows without manual backup/restore.
Spec: [[file:docs/PLAN-per-host-overrides.org][docs/PLAN-per-host-overrides.org]] — gated on review before implementation.
*** 2026-06-11 Wed @ 04:40:00 -0500 AI Response: Second restow-conflict instance on velox
The roam-units rollout hit this again: =make restow hyprland= on velox aborted on three real files (foot.ini font size 12, the velox-tuned pypr config.toml, an older waybar config). The roam systemd units were linked manually to unblock; everything else velox should have picked up from recent dotfiles work (mic-toggle, quick-capture, airplane-mode hardening, ranger plugin) is still NOT stowed there — velox's tree stays partially stale until this mechanism ships. That raises this task's practical urgency: velox can no longer cleanly receive dotfiles changes at all.
*** 2026-05-26 Tue @ 10:21:08 -0500 AI Response: Spec written, gated on review
Surfaced by a HiDPI scaling failure: a per-app =QT_SCALE_FACTOR=1.5= in the shared =Zoom.desktop= (meant for velox) made Zoom open enormous on ratio. Reverted that patch to plain =/usr/bin/zoom %U=; the durable fix is this mechanism. Proposed approach: a per-host stow tier (=ratio/=, =velox/=) stowed as =common + hyprland + $(uname -n)=, with the existing =conf.d/*.conf= glob as the first clean tenant — move =local.conf= out of the shared =hyprland/= tier into per-host tiers so each machine gets its own (HiDPI monitor scale + =env = QT_SCALE_FACTOR/GDK_SCALE= on velox, minimal on ratio). XWayland apps don't scale via the compositor (=force_zero_scaling=true=), so toolkit env vars set in =conf.d= are the right layer — kills per-app =.desktop= hacks. Open question in the spec: whole-file configs with no include directive (waybar JSON, pypr toml) need a separate strategy. Full design + 5 open questions for Craig in the spec.
** DONE [#B] Verify Phase 2 in the VM (hyprland + none) — pending clean run :solo:
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Both runs clean on 2026-06-10. Hyprland (=make test=, results =20260610-151228=): 52 passed / 0 failed, and the same-day uv + signal-cli install additions were exercised in-run. None (results =20260610-165438=-ish, second attempt): 50 passed / 0 failed — the minimal/ tree stowed correctly. The first none attempt failed on a test-harness bug, not the installer: validation.sh hardcoded the common/ symlink target, fixed in =1754a94= (expected path now follows DESKTOP_ENV). The only attributed issue in both runs is the Proton-VPN-daemon-fails-in-VM known noise. The Phase 2 none/minimal path is now verified end-to-end.
** DONE [#C] Investigate the 2026-05-11 VM-test warnings
CLOSED: [2026-06-11 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-11
:END:
All five resolved. Four were environment-impossible checks converted to uncounted skips (=ced91c4= + the portal refinement =19015c7=) — socket, portal, mDNS-on-slirp, docker-pre-reboot — and all four skips verified firing in the 2026-06-11 12:56 run (52/0, 1 warning). The fifth (lingering) turned out to be a harness quoting bug, not a logind issue — fixed in =5b51900=, dated entry below. The next clean run should report zero warnings. The 18:36 =make test= run that filed this passed 52/0/5; the sub-entries below carry each investigation.
*** 2026-06-10 Wed @ 19:07:54 -0500 Hyprland-socket warning converted to a skip
Shipped in =ced91c4=: the check now passes when the socket exists, skips (uncounted) when no Hyprland process is running — the headless-VM state — and warns only in the genuinely odd case of a running compositor with no socket. Verified live: the skip fired in the 2026-06-10 19:06 run.
*** 2026-06-10 Wed @ 19:07:54 -0500 Portal-query warning converted to a skip
Shipped in =ced91c4= + a follow-up refinement: the first condition (portal process absent) didn't fire because a socket-activated =xdg-desktop-portal= exists even headless; the precondition is really a running compositor, so the skip now keys on =pgrep -x Hyprland= like the socket check. The conf-file checks (the part install controls) still pass/fail normally. The dconf-write angle stays tracked under =[#B] Fix install errors=.
*** 2026-06-10 Wed @ 19:07:54 -0500 mDNS-ping warning converted to a slirp-aware skip
Shipped in =ced91c4=: when the VM is on QEMU slirp (a =10.0.2.x= address), the =.local= ping is skipped — multicast genuinely can't pass there — and the =is-enabled= check stands alone. On real networking the full ping test still runs and still warns on failure. Verified live: the skip fired in the 2026-06-10 19:06 run.
*** 2026-06-11 Thu @ 12:58:19 -0500 Lingering warning was a harness quoting bug — fixed, hypothesis disproven
make test-keep forensics on the kept VM: the linger file existed (created mid-install), =loginctl show-user cjennings -p Linger= said yes, logind active with zero errors — lingering was correctly enabled all along, so the logind-degraded hypothesis was wrong and archsetup's =enable-linger= calls were always fine. The actual bug was in the check itself (=validation.sh=): it captured =ls path && echo yes=, so a present file produced "path\nyes", which never string-equals "yes" — the check warned on every run regardless of state. Fixed in =5b51900= with =test -e=; the corrected expression verified returning "yes" against the live VM. With this, all five 2026-05-11 warnings are resolved and a clean run should report zero.
*** 2026-06-10 Wed @ 19:07:54 -0500 Docker warning converted to a pre-reboot skip
Shipped in =ced91c4=: =docker info= success still passes; enabled-but-inactive (the deliberate enable-not-now install state, validated pre-reboot) now skips; active-but-unresponsive still warns — that's the real failure case. Verified live: the skip fired in the 2026-06-10 19:06 run. The enable vs enable-now question for archsetup itself was left as-is (the daemon's weight makes enable-on-boot defensible).
Note: the run also logged two log-diff meta-warnings — "Found 4 new error lines after archsetup" and "New failed services detected (before: 1, after: 2)". Those correspond to the post-install systemd noise (pam_systemd / logind / Proton VPN) already captured under =[#B] Fix install errors= above; not duplicated here.
** DONE [#B] Enable TLP power management for laptops :quick:
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Done live on velox 2026-06-10: tlp 1.10.1 installed, =/etc/tlp.d/01-custom.conf= written (EPP balance_performance/power + platform-profile per power source; 80% charge cap present but commented off), service enabled and active, systemd-rfkill masked per TLP docs. Verified: tlp-stat runs, EPP reads balance_performance on AC. Codified in archsetup commit =adb39f2= as a battery-gated block.
** DONE [#B] Remove unnecessary linux-firmware packages (velox only) :quick:
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Done live on velox 2026-06-10. Hardware re-verified first (i915 graphics, ath9k wifi), then removed the meta + 12 subpackages (the task's 9 plus liquidio/mellanox/nfp/qlogic from the finer 2026 split), keeping intel + atheros + whence. The meta needed =-Rdd= — mkinitcpio-firmware declares a dep on it; the dangling dep is cosmetic. Initramfs rebuilt clean (warnings only for absent hardware), wifi stayed connected. Codified in archsetup commit =adb39f2= as a DMI-gated Framework-Intel block. Full confidence needs the next reboot — see Manual testing below.
** DONE [#B] Identify and replace packages no longer in repos
CLOSED: [2026-06-11 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-11
:END:
Shipped 2026-06-11 as =1f89523=: =scripts/audit-packages.sh= (unit-tested) makes the check repeatable, and its first run over 420 packages found four casualties, all fixed in the same commit — libva-mesa-driver (folded into mesa), nvidia-dkms → nvidia-open-dkms, swww → awww (set-theme's stale swww call fixed in dotfiles =4ea35a1=), libappindicator-gtk3 → libayatana-appindicator. Re-run anytime: =scripts/audit-packages.sh=.
** DONE [#B] Verify package origin for all packages
CLOSED: [2026-06-11 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-11
:END:
Covered by the same auditor (=1f89523=): it flags movers in both directions. Current state: zero official packages wrongly routed through aur_install-only territory; 15 aur_install entries have graduated to official repos (duf, flameshot, gist, inxi, nsxiv, nvm, papirus-icon-theme, ptyxis, qt5ct, qt6ct, ttf-lato, ueberzug, warpinator, xcolor, xdg-desktop-portal-hyprland). Left as-is deliberately — yay resolves repo packages fine — but switching them to pacman_install is a clean :quick: cleanup whenever wanted; the auditor lists them on every run.
** DONE [#B] Automate script usage tracking :solo:
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Shipped 2026-06-10 as dotfiles commit =e5044b8=: =script-usage= in =common/.local/bin/= (10 unit tests). Reads zsh extended + bash history, reports last-used date per ~/.local/bin script, =--unused= lists the never-seen set. First run on ratio: 109 scripts, 98 unseen by the current (short) history window.
** DONE [#B] Automate dotfile validation :solo:
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Shipped 2026-06-10 as dotfiles commit =2054da4=: =dotfiles-validate= in =common/.local/bin/= (11 unit tests). Extracts commands from hypr exec/bind-exec lines, waybar exec/on-click/on-scroll values, and systemd user-unit Exec* lines, then verifies each resolves. First run found 4 real orphans — see the follow-up task below.
*** 2026-06-11 Thu @ 00:44:41 -0500 All 4 orphaned references fixed; validator fully clean
Both emacs.service units repointed to /usr/bin/emacs (dotfiles =cd15d9b=), and per Craig's call the tor-browser and virtualbox keybinds were dropped rather than backed by installs (dotfiles =e4cb4c2= — Ctrl+Alt+W and Super+V now free). dotfiles-validate: 102 references checked, all resolve.
** DONE [#B] Document evaluation criteria and trade-offs
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Written 2026-06-10: [[file:docs/2026-06-10-tool-evaluation-criteria.org][docs/2026-06-10-tool-evaluation-criteria.org]] — four gating criteria (Wayland-native, actively maintained with live verification, automation-compatible, stowable config), five weighting criteria, the process, and the trade-offs accepted in the 2026-06-10 evaluation round.
** DONE [#B] Add org-capture popup frame on keyboard shortcut
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Shipped 2026-06-10, all five spec steps: =quick-capture= script (dotfiles =08ae188=, 3 unit tests, notify-on-failure when the daemon's down), Hyprland window rules in current 0.53+ syntax (float, 900x500, center, stay_focused on title org-capture) + Super+Shift+N bind (same commit), and the auto-close hook in =org-capture-config.el= (.emacs.d =1a25fada=, .elc recompiled, loaded live). Verified end-to-end on ratio: popup opens floating/centered with the template menu (screenshot), frame auto-deletes on org-capture-kill — finalize uses the same hook. Existing capture templates untouched.
** DONE [#C] Create Chrome theme with dupre colors :quick:solo:
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Shipped 2026-06-10 as archsetup commit =4736058=: unpacked-extension theme at =assets/color-themes/dupre/chrome-theme/= (manifest.json + README with the color mapping and load-unpacked install steps). Visual check is yours — see Manual testing below.
** DONE [#C] Install Zoxide integration into Ranger :quick:
CLOSED: [2026-06-10 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-10
:END:
Shipped 2026-06-10 as dotfiles commit =220dde6=: jchook/ranger-zoxide vendored (with MIT license) into both =common/= and =minimal/= ranger plugin dirs — :z and :zi commands wherever ranger runs. Python syntax verified; live verification is yours (see Manual testing) and needs a machine with ranger installed — note neither Wayland box has it, and the same-day file-manager evaluation recommends yazi over porting ranger forward.
** DONE [#D] Add retry logic to git_install function :quick:
CLOSED: [2026-06-10 Wed]
Already shipped before this review — commit =798b86f= gave git_install the same MAX_INSTALL_RETRIES loop as pacman/aur, with a clean-slate build dir per attempt. The task predates the fix; closing as done.
** DONE [#B] Org-capture popup frame split (quick-task Super+Shift+N)
CLOSED: [2026-06-13 Sat] SCHEDULED: <2026-06-12 Fri>
:PROPERTIES:
:LAST_REVIEWED: 2026-06-12
:END:
Resolved: .emacs.d fixed it config-side (single-window display + cj/quick-capture command); archsetup pointed the popup script at cj/quick-capture (8cc1be7). Verified end-to-end on ratio.
The quick-capture popup opens split in two windows — a top sliver of the daemon's last-visited buffer plus the =*Org Select*= menu below — so the two stacked modelines read like tmux status bars. Root cause: =org-mks= displays the template menu via =org-switch-to-buffer-other-window=, splitting the fresh popup frame instead of taking it over.
Coordinating with the .emacs.d project: handoff sent 2026-06-12 18:59 requesting a config-side fix scoped to frames named =org-capture= (handoff note + screenshot evidence delivered to .emacs.d's inbox, since processed and removed). Waiting on its reply in this project's inbox; then verify the popup end-to-end on ratio (Super+Shift+N → single-window menu → single-window capture buffer). Fallback if .emacs.d declines: carry the fix in the dotfiles =quick-capture= script's =-e= elisp.
Related finding, no change needed: whole-desktop screenshot already exists at CTRL+Super+S (=screenshot fullscreen=, grim fires before the fuzzel menu so popups survive). Possible follow-up decision: rebind Super+Shift+S (currently layout-switch to scrolling) if Craig wants fullscreen capture there.
*** 2026-06-12 Fri @ 20:21:00 -0500 Incorporated .emacs.d's fix and verified end-to-end
.emacs.d replied same evening with two notes (now in [[file:assets/outbox/2026-06-12-1947-from-.emacs.d-org-capture-popup-singlewindow-reply.org][outbox]] and [[file:assets/outbox/2026-06-12-2006-from-.emacs.d-quick-capture-script-change.org][outbox]]): the single-window fix landed config-side (frame-scoped =display-buffer-alist=, 7 ERT tests, live in the daemon), plus a new =cj/quick-capture= command (Task/Bug/Event only, global-inbox targets, frame closes on every exit path, 12 ERT tests). Our side: test-first one-line change in the dotfiles =quick-capture= script — =(org-capture)= → =(cj/quick-capture)= — suite 15/15 green, live immediately via stow. Verified on ratio with sendshortcut-driven popups + grim: menu single-window with the 3-template subset, capture buffer single-window targeting =CAPTURE-inbox.org=, no orphan frames, nothing leaked into the inbox file. Verification reply + screenshot evidence sent back to .emacs.d. Remaining: commit the dotfiles change (Craig's gate) and the Super+Shift+S rebind decision.
** DONE [#C] Silent notifications for the mic-mute toggle :quick:solo:
CLOSED: [2026-06-11 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-11
:END:
Shipped 2026-06-11 as dotfiles =a4ae4a4=, minutes after filing: =--silent= on all four of mic-toggle's notify calls (Muted/Live/unknown/fail), tests assert the flag on every path (5/5, full suite 15 suites green), and a live round-trip on ratio confirmed the toggle works with the toast and without the chime. velox picks it up on next pull.
** DONE [#B] Create package inventory system
CLOSED: [2026-06-14 Sun]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-13
:END:
Satisfied by =scripts/package-inventory= (the same script that closes "Automate the inventory comparison" above). It lists archsetup's declared packages, lists the live system's packages, and prints the diff in both directions. Design note: it compares explicit-vs-explicit (=pacman -Qqe= against declared =pacman_install=/=aur_install=), which is the meaningful comparison — the original "including dependencies" framing was superseded, since transitive deps are pulled automatically and listing full closures would only add noise.
*** 2026-06-14 Sun @ 22:13:48 -0500 Listed archsetup's declared packages — package-inventory extraction (pacman_install/aur_install + for-loop lists)
*** 2026-06-14 Sun @ 22:13:48 -0500 Listed live-system packages — package-inventory via pacman -Qqe / -Qq / -Qqen / -Qqem
*** 2026-06-14 Sun @ 22:13:48 -0500 Generated archsetup-vs-system diff — package-inventory, both directions, AUR/official split
** DONE [#B] Automate the inventory comparison :test:solo:
CLOSED: [2026-06-14 Sun]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-13
:END:
Make package diff a runnable script instead of manual process
Resolved 2026-06-14: the runnable script already existed — =scripts/package-inventory= (built 2026-02-06) extracts archsetup's declared packages and diffs them against the live system (=--summary= / =--archsetup-only= / =--system-only= / full report). This pass added the missing coverage: 7 characterization tests in =tests/package-inventory/= pinning the extraction and both diff directions behind injectable =PKGINV_ARCHSETUP= / =PKGINV_PACMAN= seams, plus a =make package-diff= target for discoverability. Full unit suite green (26 tests, 3 suites).
** DONE [#C] paru vs yay — evaluated, staying with yay
CLOSED: [2026-06-10 Wed]
Research done 2026-06-10: [[file:docs/2026-06-10-paru-vs-yay-evaluation.org][docs/2026-06-10-paru-vs-yay-evaluation.org]]. The maintenance picture inverted since the task was filed: yay released v12.6.0 on 2026-06-07 with active triage, while paru has had no release in 11 months, no commit in 5, and a stable that fails to build against current libalpm (issue #1468 open 6 months). For an installer that bootstraps the AUR helper unattended, paru is the riskier choice on every axis that matters. No decision needed — the evidence closes this one; revisit only if paru's maintenance resumes.
** 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 [#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] 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 [#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] 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] 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] 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.
** DONE [#B] Network panel UI — review findings :feature:waybar:network:
CLOSED: [2026-07-01 Wed]
Full UI review 2026-07-01 (visual walk of every state + code pass over the view logic), 30 findings: 21 from the agent review + color audit, 9 from Craig. All fixed the same night in a no-approvals speedrun, five commits, each test-gated (33 suites) and the whole panel re-verified via AT-SPI smoke + screenshots.
*** 2026-07-02 Wed @ 00:00 -0400 Theme pass: contrast, hierarchy, focus, hover, destructive, dialogs (dotfiles 82aad0b, 998829b)
Selected-row captions turned cream (the dim gray measured 2.2:1 on the slate fill, a WCAG fail on the auto-selected active row). Names gained weight and captions dropped a size (type hierarchy beyond color). Focus rings gold, scrollbars slim slate, rows got a hover wash. Disconnect and Forget became terracotta destructive-action (red = off). The Join/Add dialogs carry the dupre contract now (ground, mono, styled entries with a gold focus border, no stock blue anywhere), the Add dialog disables its action button until the SSID is non-empty, its password field gained the peek icon, and long SSIDs ellipsize.
*** 2026-07-02 Wed @ 00:00 -0400 Connections logic: lies, gaps, races (dotfiles 693f820)
Saved profiles stopped claiming "open" (security shows only when the scan knows it; subtitles are view-aware — Available adds signal %, Saved says just "out of range"). An active wired connection pins above the wifi scan in Available. Add connects immediately (Craig's decision), both add dialogs' action reads Connect. Rescan went through the op state machine (the dead guard let double-clicks double-scan). A failed initial load shows in the boxes with a retry instead of stranding "Loading…". Available's first message is "Scanning networks…". Live-info ages humanize (7m, not 445s). A held portal replaces the internet line (stable row height under the poll). The poll pauses on other tabs. VPN profiles got a VPN glyph. Forgetting the active network warns it will disconnect you.
*** 2026-07-02 Wed @ 00:00 -0400 Diagnostics restructure: selector, live speed test, streamed verdicts, leaner chrome (dotfiles 787b475)
The six-button wall became a dropdown tool selector (full tool names, a description of the selection, one Run button) revealed under Advanced. Speed Test became Network Performance: a reveal with Run Speedtest + Stop (no button-flips-to-Cancel), a once-a-second elapsed ticker while running, and Download / Upload / Ping (high-latency warn) / Server / Tip rows in the diagnose aesthetic. Get Me Online streams the diagnose rows first, announces each attempt ("Trying Reset Connection…"), then its result, and closes with a bold verdict row — as do diagnose, the single tools, and the portal login; verdicts left the status line. Get Me Online at a held captive portal opens the login flow (doctor runs the safe, reversible portal-login when fix is requested). Connecting to a network that turns out captive sends a desktop heads-up, waits a beat, then opens the login page. Diagnose evidence humanized ("open internet (HTTP 204)", "names resolve (captive.apple.com)"). The title row and Close button are gone (Esc + focus-loss auto-hide cover a transient popup) and the status line became a self-clearing toast. The AT-SPI driver anchors on the Diagnostics tab now.
** DONE [#B] Advanced repair buttons: half width, two per row :feature:waybar:network:quick:
CLOSED: [2026-07-01 Wed]
The wide Advanced buttons shrink the panel and leave the diagnostics output impossible to read. Make each half width, two to a row, and rename where needed to fit. Origin: roam inbox capture.
Done 2026-07-01 (dotfiles aca6827): the Advanced reveal became a 3-column, 2-row grid (Diagnose, Unblock WiFi, Reset / Restart, Test DNS, Force Portal) per Craig's follow-up; labels shortened, tooltips carry the full descriptions. Verified live + AT-SPI smoke.
** DONE [#B] Panel action-button rows fill the panel width :feature:waybar:network:quick:
CLOSED: [2026-07-01 Wed]
Disconnect / Rescan / Add / Add Hidden, and the Saved row, should be as wide as the panel and the buttons above them. Apply the same homogeneous full-width treatment used on the Available / Saved sub-tabs. Origin: roam inbox capture.
Done 2026-07-01 (dotfiles aca6827): both Connections action rows are homogeneous full-width, which also fixed the panel resizing when Connect flips to Disconnect. Verified live.
** DONE [#B] Live connection info in the row subtitle :feature:waybar:network:
CLOSED: [2026-07-01 Wed]
The live connection information shown in the row hover should also appear in the small print under the connection name, updated in realtime like the hover. Origin: roam inbox capture.
Done 2026-07-01 (dotfiles aca6827): a 1.5s poll fills the active row's subtitle with the bar-tooltip fields minus the SSID (signal, interface, internet + age, portal note, throughput), sharing the bar's per-field formatters. Verified live.
** DONE [#B] Bake captive-portal login into the net panel :feature:network:
CLOSED: [2026-07-01 Wed]
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-07-01 Wed @ 22:41:51 -0400 Live-validated end to end against a local captive simulator (dotfiles c1401db)
The last remainder. tests/net/captive_sim.py is a local redirect portal (302s to a login page until "logged in", then a clean 204). NET_PROBE_URL and NET_PORTAL_TRIGGERS point the whole flow at it (an overridden probe skips the interface binding, which can't reach loopback). Ran live on velox, both restore paths verified: online-detect (login click, watcher saw the 204, DoT drop-in restored within ~2s, clean exit) and the timeout fallback (a watcher that never saw online restored DoT at its 300s deadline). Real sudo mv, real resolved restarts, real redirect URL recovery, real clean-profile Chrome — against a temp drop-in dir, so live DNS was untouched. All three remainders are done; the task is closed. The remaining what-if is a real venue's walled-garden quirks, which only an actual portal exercises.
*** 2026-07-01 Wed @ 21:44:05 -0400 Diagnose names the DoT block; panel gained Log in to This Network (dotfiles 51e0e2d)
Remainders 1 and 2 landed. The dns-resolve step names the DoT pin when resolution is dead and the drop-in exists (sysio.dot_forced), and routes next_action to the portal login. The panel's hidden Open Portal button became a first-class suggested-action "Log in to This Network", shown whenever the report holds a portal signal (portal step with or without a URL, or the DoT-blocked resolution) via the unit-tested viewmodel.wants_portal_login. TDD, 33 suites green. Remainder 3 (live validation against a real portal) still open.
*** 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.
** DONE [#B] Consistent red=off across waybar toggle modules :waybar:
CLOSED: [2026-07-01 Wed]
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.
Already implemented (verified 2026-07-01): =style.css= gives =#pulseaudio.muted=, =#pulseaudio.mic.source-muted=, and =#custom-caffeine.inhibited= the off-state color =#d47c59=, matching =#custom-touchpad.disabled=. Note: caffeine's red fires on =.inhibited= (caffeine ON / staying awake), which is arguably the inverse of "off" — leave as-is unless you want strict off=red semantics there.
** DONE [#B] Microphone-mute keybind :feature:waybar:quick:
CLOSED: [2026-07-01 Wed]
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.
Already implemented (verified 2026-07-01): hyprland.conf binds both =XF86AudioMicMute= and =Super+Shift+A= to =mic-toggle= (no conflict — airplane is Super+Shift+X).
** DONE [#C] Alarm tooltip shows time remaining, not alarm time :bug:waybar:quick:
CLOSED: [2026-07-01 Wed]
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.
Fixed 2026-07-01 (dotfiles): =_describe= now renders an alarm's wall-clock target via a new =format_clock= helper instead of =format_time(remaining)=. TDD test added; full wtimer suite (87) green.
** DONE [#C] Waybar right-cluster module order :waybar:quick:
CLOSED: [2026-07-01 Wed]
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.
Done 2026-07-01 (dotfiles waybar config): =custom/timer= now sits just left of =tray= with =custom/sysmon= second-to-rightmost. waybar regenerated + reloaded live on velox; visual confirmation pending Craig.
** DONE [#B] Right-click date/time: ntp sync + timezone update :feature:waybar:
CLOSED: [2026-07-02 Thu]
Right-click on the date updates the clock from ntpd (or whatever keeps the clock in sync); right-click on the time runs the update-timezone script. Neither opens a terminal — both run transparently in the background. Errors surface as notifications. The module should detect whether we're online and, if not, show a message instead of running the script. Origin: roam inbox capture 2026-07-02.
Shipped 2026-07-02 (dotfiles 2f7993d). Date right-click = clock-sync (chronyc makestep behind a connectivity gate; offline/captive get explanatory notifications). Time right-click = timezone-set, rewritten to prefer WiFi geolocation (whereami → timeapi.io) over IP lookup — the hotel IP geolocated two timezones off, and ipapi.co is paywalled now (ipinfo.io is the fallback). Both promptless (sudo -n), all outcomes notify, worldclock gained signal 16 for an instant refresh. 13 new tests across clock-sync + timezone-set; live-verified on velox (WiFi path resolved Rhode Island → America/New_York correctly).
** DONE [#B] Screenshot "view image" option :feature:hyprland:
CLOSED: [2026-07-02 Thu]
The screenshot flow should also offer a "view image" selection: saves the shot, opens it in a viewer, and puts the path on the clipboard. Origin: roam inbox capture 2026-07-02.
Shipped 2026-07-02 (dotfiles 10e5961). View Image entry in the post-capture fuzzel menu: opens the shot via xdg-open (default viewer, currently feh) and puts the path on the clipboard. Script gained env seams + a nine-test suite (menu dispatch, both capture modes, cancel, failure). Live check pending: take a shot and pick View Image once.
** DONE [#C] Collapse-triangle buttons: dimmer, inlaid styling :waybar:
CLOSED: [2026-07-02 Thu]
The triangle collapse buttons should look embedded (inlaid) and be slightly dimmed so they don't compete for eye attention with the other components. Origin: roam inbox capture 2026-07-02.
Shipped 2026-07-02 (dotfiles 15cb93c): muted color + dark inset well + smaller glyph; hover still brightens. Hudson theme carries the same shape. Screenshot-verified.
** DONE [#C] Contrast button ignores the white=on / red=off paradigm :bug:waybar:
CLOSED: [2026-07-02 Thu]
The contrast button doesn't respect the white=on, red=off color paradigm the other waybar modules follow. Cosmetic × every time = P3. Origin: roam inbox capture 2026-07-02.
Shipped 2026-07-02 (dotfiles 15cb93c). The "contrast button" is the auto-dim module — its ON icon (nf-fa-adjust) is the classic contrast glyph. It showed gold when on and nothing when off; now on = default silver, off = terracotta, matching every other toggle. Screenshot-verified both states.
** DONE [#C] Off-state red inconsistent across waybar modules :bug:waybar:
CLOSED: [2026-07-02 Thu]
Terracotta red isn't applied uniformly for "off": the sleep icon is a different shade than the mouse/trackpad when off, and the dim indicator doesn't show red when off at all. Cosmetic × every time = P3. Origin: roam inbox capture 2026-07-02.
Shipped 2026-07-02 (dotfiles 15cb93c + 7f1f334). The touchpad script's Pango-markup red predated the terracotta theme pass (#d47c59 vs the CSS's #cb6b4d) — unified on #cb6b4d. Dim-off now red (see the contrast task). Bonus find: waybar hands every pulseaudio instance the sink's .muted class, so a muted speaker also painted the mic red — scoped with :not(.mic) so each glyph keys on its own device.
** DONE [#B] Waybar volume/mic toggle like the touchpad module :feature:waybar:
CLOSED: [2026-07-02 Thu]
Make the volume/mic waybar component look and behave like the touchpad/mouse toggle.
- Move the mic to the other side of the volume so the percentage isn't in the way. The mic and speaker icons sit the same distance apart as the hand and mouse.
- One keybinding cycles the four states: volume on / mic on, volume on / mic off (red), volume off (red) / mic on, volume off (red) / mic off (red).
- Move the trackpad/mouse toggle to another keybinding (discuss an open mnemonic, e.g. =d= for disable) and assign Super+M to this module (for mute).
Origin: roam inbox capture.
Shipped 2026-07-02 (dotfiles 7f1f334). New audio-cycle script walks the four-state ring in the exact order above (wpctl-backed, explicit set-mute so the pair can't desync, 6 tests) on Super+M; live-verified the full ring on velox. Mic moved left of the speaker and hugs it via paired margins, percentage on the outside. Touchpad toggle moved to Super+Shift+I ("input devices" — d-for-disable was taken at both levels by removemaster and dim-toggle); its tooltip and tests follow.
** DONE [#C] Timer end sends no notification :bug:waybar:
CLOSED: [2026-07-02 Thu]
The end of a wtimer timer didn't fire a desktop notification. Needs reproduction to confirm frequency; priority follows the severity-by-frequency matrix once known (a reliably-missing timer-end alert would rate higher). Origin: roam inbox capture.
Root-caused 2026-07-02 (dotfiles ca35642). The pipeline works (a live 3s timer fired and persisted), but notify sent everything --urgency=normal and dunstrc delays normal-urgency popups while a fullscreen window has focus — a timer ending mid-video sat invisible until fullscreen exit. Alarms now go critical urgency, which rides the fullscreen_show_critical rule; verified CRITICAL in dunst history. The alarm sound (paplay, separate from dunst) was never affected.
** DONE [#C] Keybind hints in waybar module tooltips :waybar:
CLOSED: [2026-07-02 Thu]
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.
Shipped 2026-07-02 (dotfiles 4c32aec). Audited every module: arrows (Super+[ / Super+]), sysmon (Super+R), net (Super+Shift+N), layout (Super+Shift+←/→), menu (Super+Space / Super+Shift+Q, tooltip enabled), plus the already-hinted dim/caffeine/touchpad/mic/volume. Date/time tooltips document their new right-click actions. Workspaces/window/tray don't take custom tooltips; timer has no keybind.
** CANCELLED [#C] Smooth waybar expansion animation :waybar:
CLOSED: [2026-07-02 Thu]
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.
Assessed infeasible 2026-07-02: collapse works by config rewrite + SIGUSR2 reload, which rebuilds every widget — nothing survives to transition, GTK3 can't animate add/remove without Revealer (an upstream waybar change), and the tray pop-in is async StatusNotifier re-registration. Full findings + revisit conditions: [[file:docs/design/2026-07-02-waybar-expansion-animation-feasibility.org]].
** DONE [#C] Optional label on timer/alarm/stopwatch items :feature:waybar:
CLOSED: [2026-07-02 Thu]
Let each wtimer item carry an optional short text label. The data model already supports it (=add_timer/add_alarm/add_stopwatch/add_pomodoro= all take =label=""=, and =_describe= shows =label or type=); the gap is the fuzzel-driven creation flow, which doesn't prompt for a label. Add the optional label prompt on create. Origin: roam inbox capture.
Shipped 2026-07-02 (dotfiles ca35642): =wtimer new= gained a "label (optional)" fuzzel prompt after the type/value prompts; empty keeps the unlabeled default. 2 new tests (89 total in the suite).
** DONE [#B] Pocketbook finish-or-cancel decision :pocketbook:
CLOSED: [2026-07-02 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
Decided by Craig 2026-07-02, ahead of the scheduled checkpoint: remove pocketbook altogether. Executed same day — pip package uninstalled (user site clean), running instance killed, launcher gone, =pocketbook/= tree removed from the repo, Super+P rebound to toggle-touchpad (P for Pointers; Super+Shift+I unbound, waybar tooltip hint updated — dotfiles a750cb4). The org-capture popup remains the quick-notes surface.
** DONE [#B] Provision Eask in archsetup :tooling:eask:
CLOSED: [2026-07-02 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-05-26
:END:
Shipped 2026-07-02 (speedrun): npm global install block added after the nvm line — runs as $username with --prefix $HOME/.local, display/error_warn wrapped, output to $logfile, matching the claude-code block's shape. The npmrc decision went yes: dotfiles common/.npmrc pins prefix=${HOME}/.local (stowed; hand-linked live, npm config get prefix confirms ~/.local — dotfiles 01627cc). VM assertion added: ~/.local/bin/eask present + ~/.npmrc stowed. Live smoke: eask 0.12.9 on PATH. Full acceptance (fresh-install chime make setup/test) rides the next VM pass.
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.
** DONE [#C] Waybar timer dialog styling :waybar:
CLOSED: [2026-07-02 Thu]
From Craig's roam capture 2026-07-02: style the timer module dialogs like the screenshot dialog — tighter window, icons on the selections, colon+space after the prompt.
Shipped 2026-07-02 (dotfiles 9ffcba7): dialogs size to content, type menu carries the kind glyphs, prompts end ": ". Three new tests; screenshot-verified live.
** DONE [#B] Waybar collapse jumps client windows :bug:waybar:hyprland:
CLOSED: [2026-07-02 Thu]
From Craig's roam capture 2026-07-02: collapsing/expanding (and any waybar teardown) snapped every tiled window up and back down; hold the clients still and let only the bar change.
Shipped 2026-07-02 (dotfiles 4b1a4ec): waybar now runs exclusive:false and the new waybar-reserve script statically reserves the bar strip per monitor (wired as exec so config reloads re-apply it). Verified live: client geometry held constant through bar kill, relaunch, and a collapse round-trip. Eight new tests (script + pairing).
** DONE [#B] Bluetooth panel + bar module :feature:waybar:bluetooth:
CLOSED: [2026-07-02 Thu]
:PROPERTIES:
:SPEC_ID: 1271a845-4463-4831-9902-990eda6b2265
:END:
Spec: [[file:docs/specs/2026-07-02-bluetooth-panel-spec.org]] (IMPLEMENTED 2026-07-02 — all five phases shipped same day: engine eb2230f, panel 76b2c05, bar module e372de3, bt-priv + blueman retirement 2a026b1/d8d8c53, install wiring proven by VM assertions). Residual: the phase 4-5 VM assertions run on the next VM pass; ratio picks up the package removal + hand-links on its trip list.
A bluetooth panel driving a CLI underneath (bluetoothctl one-shot verbs), consistent in look and feel with the net panel (GTK4 + layer-shell + Blueprint, humble-object presenter, verify-everything). Minimalistic interface, full functionality, plus a diagnostics/troubleshooting section mirroring the net panel's Diagnostics tab. Bar module glyph opens it. Craig's ask (2026-07-02): follow UX/UI best practices; where the net panel's patterns conflict with best practices, file a net-panel bug task rather than clone the flaw.
*** 2026-07-02 Thu @ 13:30:42 -0400 Shipped phase 1 — the bt engine package (dotfiles eb2230f)
=bluetooth/src/bt/= mirrors the net engine's layout: btctl parsing boundary (show/devices/info, connect-error classifier), sysio rfkill/airplane, audio module over pw-dump/wpctl (HSP probe + A2DP switch repair with verify-after), redacted eventlog, six repair tiers, and the doctor chain (adapter → rfkill → service → powered → devices → audio profile) with safe auto-repairs behind =--fix= (never auto-connects; airplane blocks are named, not fought). 101 tests over fake binaries; 42 suites green (=make test= glob auto-discovered =tests/bt/= — gate check verified). Live read-only on velox: =bt status= + =bt doctor= read the real adapter/devices/audio graph; =~/.local/bin/bt= hand-linked (no restow under running Hyprland). Ground truth vs spec: profile inventory needs =pw-dump= (wpctl can't enumerate), and the card's =bluez5.profile= prop is unreliable — sink node's =api.bluez5.profile= is authoritative. Deferred INTO phase 2: the shared dupre css factoring (net's css is an inline string in =gui.py=, not an asset — factoring it without the bt-panel consumer just risks the working net panel).
*** 2026-07-02 Thu @ 14:15:27 -0400 Shipped phase 2 — the GTK panel (dotfiles 76b2c05)
Cloned the net panel's shape over the phase 1 engine: GTK-free PanelModel + viewmodel (69 new tests, display-free), Blueprint pages (Devices with the adapter power row + Paired/Nearby sub-views; Diagnostics with the doctor cascade, inline fix buttons mapped from step =repair= keys, Advanced tool selector), gui.py controller (layer-shell OVERLAY 380x520 TOP+RIGHT, Esc closes, single-instance toggle, bg worker, passkey + confirm dialogs), pairing pty state machine (=bt/pairing.py=: confirm-passkey yes/no with default-deny, display-passkey, bounded deadline, tested over the fake's interactive mode), manage.py op envelopes shared by CLI and panel (cli refactored onto it; power + discoverable verbs added), =bt panel= subcommand + =bt-panel= toggle wrapper (hand-linked into =~/.local/bin=, no restow under live Hyprland). Shared dupre css factored: net's inline =_CSS= → =hyprland/.config/themes/dupre/panel.css= with =dupre-*= classes, both panels load it (stowed path first, repo-relative fallback; hand-linked =~/.config/themes/dupre/panel.css=). Super+Shift+B rebound blueman-manager → bt-panel (hyprctl reloaded, live). 43 suites green (=make test= exit 0). DEFERRED pending Zoom ending: the AT-SPI smoke (=make test-panel-bt=, written and wired) and any visual check of either panel with the factored css — both need a visible window. Gotcha for posterity: the old =test_panel_stub_exits_two= CLI test ran =bt panel= for real once cmd_panel was wired and launched the panel on the live compositor mid-meeting for ~30s before the test timeout killed it; it now asserts parser wiring only — never run =bt panel= inside =make test=.
*** 2026-07-02 Thu @ 15:06:00 -0400 Shipped phase 3 — the bar module + blueman retirement (dotfiles e372de3)
=custom/bluetooth= over the engine: waybar-bt shim + =bt/indicator.py= (state-following glyph — slashed off/blocked/absent, plain dim idle, connected mark white; low-battery <15% adds a red pango percentage to the glyph; tooltip = connected devices with battery + Super+Shift+B hint). Signal 10; the panel pokes =pkill -RTMIN+10 -x waybar= after each status reload. Blueman retired from the Hyprland session: exec-once + both windowrules removed, applet killed live; waybar relaunched on the runtime config and the module verified on the bar (connected glyph, blueman tray icon gone). Theme drift guard caught that themes/*/waybar.css edits must mirror into the live =waybar/style.css= — all three updated. 43 suites green. ALSO closed this pass: the deferred phase 2 visual batch (bt AT-SPI smoke green after fixing its Connect/Disconnect state-following assertion — c1a8219; net smoke green on the factored css; both panels eyeballed correct in dupre). Left for phase 4: package removal (blueman out of archsetup), sxhkdrc's dwm blueman-manager binding decision rides that pass.
*** 2026-07-02 Thu @ 15:16:51 -0400 Shipped phase 4 — bt-priv shim, blueman out, VM assertions
Dotfiles =2a026b1=: the stowed =bt-priv= shim over the phase-2 =bt.priv= module (one verb, =restart-bluetooth=; verified end-to-end against the symlinked fake-systemctl — rc 0 with =BT_SUDO= empty, rc 2 on bad verb/usage; hand-linked into =~/.local/bin=), and the sxhkd =Super+Shift+B= bind repointed from the retired blueman-manager to =st -e bluetoothctl= (the decided terminal fallback — the GTK panel is Wayland-only, and the bt CLI is hyprland-tier so dwm never gets it). 43 dotfiles suites green. archsetup: blueman dropped from the =desktop_environment= bluetooth loop (bluez + bluez-utils stay, solaar untouched); VM assertions added to =test_packages.py= (bluez/bluez-utils installed, blueman NOT installed as the retirement regression guard — collected 15, exercised on the next VM run since VM tests run committed code); =bash -n= + =py_compile= + =make test-unit= green. SUDOERS: no new rule needed, same conclusion as net-priv (2026-07-01 entry) — archsetup:1089 grants the primary user blanket =NOPASSWD: ALL=, which covers =systemctl restart bluetooth=; a narrow bt-priv rule would be dead config under the blanket grant, so phase 5's "sudoers placed" item is satisfied by the existing grant. LIVE: blueman package removed from velox (=pacman -Rns=, decision "drop it outright, both machines"); ratio needs the same + the bt-priv hand-link on its trip list.
*** 2026-07-02 Thu @ 15:19:58 -0400 Shipped phase 5 — install-default wiring proven by VM assertions
No new install code was needed: the waybar =custom/bluetooth= module, the =Super+Shift+B= → =bt-panel= bind (hyprland.conf), and the shared =themes/dupre/panel.css= all live in the dotfiles hyprland tier, so the existing clone + =make stow hyprland= step lands them on a fresh install; sudoers is covered by the blanket grant (phase 4 conclusion). The phase's substance is the proof: =test_desktop.py= gained hyprland-gated assertions that the four bt bins (=bt=, =bt-panel=, =bt-priv=, =waybar-bt=) are stowed executable in =~/.local/bin= (either stow shape — per-file symlink or folded dir), the waybar config carries =custom/bluetooth=, hyprland.conf carries the =bt-panel= bind, and the stowed theme has =panel.css=. Collected 30 in =test_desktop.py=; exercised on the next VM run (VM tests run committed code).
*** 2026-07-02 Thu @ 15:19:58 -0400 Test surface complete across all phases
Everything the surface named exists and is green: engine suites over fake binaries (phase 1, 101 tests — btctl parse, doctor chain, A2DP repair verify), PanelModel presenter suite (phase 2, 69 tests), pairing state-machine suite (passkey confirm / NoInputNoOutput / timeout, over the fake's interactive mode), bar-module suite (phase 3), gated AT-SPI smoke (=make test-panel-bt=, run green live), and the phase 4-5 VM assertions (=test_packages.py= bluetooth stack + blueman-absent guard; =test_desktop.py= panel wiring). 43 dotfiles suites green; VM assertions await the next VM run.
** DONE [#B] All error messages should be actionable with recovery steps
CLOSED: [2026-07-02 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
Shipped 2026-07-02 (speedrun). Structural fix at the helper: =error_fatal= now takes an optional third recovery-hint arg and every fatal prints the last five log lines inline, the full log path, the per-site "Fix:" when given, and the resume pointer (step markers mean a re-run continues where it stopped) — so even a hint-less fatal is actionable. All 17 fatal call sites got specific hints (keyring reinit, mirrorlist switch, userdel/USERNAME conflict, base-devel for makepkg, DESKTOP_ENV values, dotfiles-dir cleanup, tmpfs sizing, aur.archlinux.org reachability). The end-of-run Error Summary now closes with the grep-the-log line and the fix-and-re-run pointer. =error_warn= already carried what-failed + exit code into the summary; unchanged.
** DONE [#B] Improve logging consistency
CLOSED: [2026-07-02 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
Shipped 2026-07-02 (speedrun), paired with the actionable-errors task. Audit result: the install helpers (pacman_install/aur_install/retry_install/run_task/git_install/pipx) and error helpers already tee/append everything to $logfile — the gaps were direct mutations whose stderr went to the console and vanished. Swept every =sed -i= and file-write mutation lacking capture (locale.gen uncomment, pacman.conf ParallelDownloads/Color + multilib, waybar battery removal x3, wireless-regdom, geoclue BeaconDB, paccache, BRIO udev rule, fstab fmask, mkinitcpio HOOKS, sudoers append, ufw status read): each now sends stderr to $logfile, and the previously-silent ones (locale.gen, pacman.conf, multilib, waybar, regdom, geoclue, paccache, udev) gained =error_warn= handlers so failures land in the summary instead of passing silently. Verified: bash -n clean, 10 unit suites green, shellcheck warning-diff vs HEAD empty (no new findings).
** DONE [#B] Add NVIDIA preflight check for Hyprland
CLOSED: [2026-07-02 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Shipped 2026-07-02 (speedrun), TDD. =nvidia_preflight_report= is a pure sed-extractable core (same harness pattern as zig-pin): modalias scan for vendor 10DE — DRM first, PCI display-class (bc03) fallback so an NVIDIA audio function can't false-trigger — then the repo's =nvidia-utils= candidate major checked against 535. Prints the Wayland guidance + env vars (LIBVA_DRIVER_NAME, GBM_BACKEND, __GLX_VENDOR_LIBRARY_NAME, ELECTRON_OZONE_PLATFORM_HINT) and the pre-Turing/AUR-legacy note. preflight_checks aborts on <535/unknown (rc 11), prompts continue/abort on a healthy NVIDIA box (rc 10), silent on non-NVIDIA (rc 0). 9 Normal/Boundary/Error tests over fake modalias trees + a fake pacman (=tests/nvidia-preflight/=, glob-discovered by test-unit — 10 suites green).
** DONE [#C] Wlogout exit-menu buttons are rectangular, not square
CLOSED: [2026-07-02 Thu]
: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.
Shipped 2026-07-02 (dotfiles 775771b). Keybind now calls a =wlogout-menu= wrapper computing centered margins from the focused monitor (the old fixed L/R 1200 exceeded velox's 1436 logical width). Also fixed two styling defects the geometry hid: invisible unfocused borders (now muted, so the square edge is visible) and hover/focus sharing one gold rule (lock button glowed at launch; focus is now a muted ring). Tests: unit margin-math suite across both hosts' resolutions + portrait + small + bad-geometry, CSS regression suite, and a compositor-gated =make test-wlogout= smoke that launches a no-op probe, screenshots, and measures squareness (velox: 361x361 px, PASS). Ratio's visual eyeball rides the pending ratio sync.
** DONE [#C] Net panel: error toasts auto-dismiss unread :bug:network:waybar:
CLOSED: [2026-07-02 Thu]
Fixed in dotfiles 0f017d4: viewmodel.toast_plan owns the toast policy — errors show sticky and ignore the post-op refresh's empty clear (worst case: a forget failure's error was wiped within ~2s by its own refresh), and the next real status replaces them. Successes keep the 4s fade. 7 policy tests added; 41 suites green.
** DONE [#C] Net panel: verify claimed keyboard navigation :test:network:waybar:
CLOSED: [2026-07-02 Thu]
Found during the bluetooth-panel UX pass (2026-07-02). The V2 spec claims tab-between-sections, arrow-key row navigation, and type-to-filter, but no custom keyboard code exists in the panel — arrows and type-ahead may ride GTK ListBox defaults, tab-between-sections likely doesn't. Verify each claim against the live panel (AT-SPI smoke can assert focus order); implement or strike the claims from the spec so spec and panel agree.
*** 2026-07-02 Thu @ 13:05:00 -0400 Code-level pass done; live probe deferred (Craig in a Zoom meeting)
Code reality (dotfiles net/src/net): Esc close is wired (gui EventControllerKey); row-activated -> primary is wired for both connection lists (pages.py:122,126), so Enter-on-row rides the GTK ListBox activate binding; arrows ride ListBox defaults; NOTHING implements type-to-filter (no search/filter code exists — that claim is false as written); Tab is the plain GTK focus chain, widget by widget, not section jumps. Live AT-SPI probe plan: launch panel in test mode, drive keys via hyprctl dispatch sendshortcut targeted AT THE PANEL WINDOW (never the focused surface — wtype/ydotool absent anyway), gate every key on the panel holding focus, never send Enter on the available list (real connect risk). BLOCKED at 13:05: active window is zoom (meeting) — no test windows, no synthetic input until clear. Then: verify focus order + arrows + no-filter live, strike/reword the spec's keyboard bullet to match.
*** 2026-07-02 Thu @ 14:57:18 -0400 Ran the live probe; spec bullet reworded to match reality
Zoom ended ~14:45; probe ran per plan (panel in test mode, hyprctl dispatch sendshortcut targeted at the panel address, every key gated on panel focus, Enter never sent). Verdicts: arrows move row focus and Enter rides the ListBox activate binding (TRUE — kept); Esc closes (TRUE — kept); Tab is the plain GTK widget-by-widget chain and inside a list crawls row by row, no section jumps (claim FALSE — struck); type-to-filter does not exist (claim FALSE — struck; typing into the 24-row Saved list filtered nothing). Spec's Keyboard bullet reworded with the live evidence and a note that section-jump Tab or filtering would be new work. Probe gotchas for reuse: AT-SPI list items have empty accessible names, so row identity needs get_index_in_parent(); a killed test panel can leave a windowless single-instance process that eats the next launch via D-Bus activation — pkill -9 -f 'net panel$' and wait before relaunching.
** CANCELLED [#C] Pocketbook development backlog :pocketbook:
CLOSED: [2026-07-02 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-05-26
:END:
Cancelled with the 2026-07-02 remove-pocketbook decision — the app and its in-tree package are gone.
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=.
** CANCELLED [#C] Fn+F9 toggles pocketbook — source unlocated :hyprland:pocketbook:
CLOSED: [2026-07-02 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-23
:END:
Retired with pocketbook itself (2026-07-02 removal) per this task's own exit condition — with the app uninstalled and unbound, whatever Fn+F9 emitted has nothing to toggle.
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.
** CANCELLED [#C] Waybar emacs-service status + control :feature:waybar:
CLOSED: [2026-07-02 Thu]
: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.
Cancelled 2026-07-02 per Craig during the task-batch pick: no current need. Re-add or pull back from Resolved if a need surfaces.
** DONE [#C] set-wallpaper detaches waypaper config from its stow symlink :bug:hyprland:quick:solo:
CLOSED: [2026-07-02 Thu]
: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".
Shipped 2026-07-02 (dotfiles d826be4): write-back now redirects through the symlink instead of mv-ing over it; two boundary tests pin the invariant (replace + append paths). velox's live config was still a healthy symlink, so no repair needed.
** DONE [#B] Instrument-console rebuild: net + bluetooth panels :feature:waybar:network:bluetooth:solo:
CLOSED: [2026-07-03 Fri]
:PROPERTIES:
:SPEC_ID: e73877f5-4f5b-4f81-b946-dbaa6145e0d5
:END:
The no-approvals speedrun build of the console design Craig approved through five prototype iterations (2026-07-02/03). Spec: [[file:docs/specs/2026-07-03-instrument-console-panels-spec.org]] — the interactive prototype [[file:docs/prototypes/2026-07-03-instrument-console-panels-prototype.html][docs/prototypes/2026-07-03-instrument-console-panels-prototype.html]] is the normative design reference. Folds three open tasks: network panel redesign, bt switch placement + title, bt rename devices. Code in ~/.dotfiles (net/, bluetooth/, themes/dupre/panel.css). Final step: flip the spec to IMPLEMENTED, write the findings summary to file, finalize session context.
*** 2026-07-03 Fri @ 03:20:00 -0400 Phase 2 shipped: net GTK-free console layer + engine verbs
Dotfiles =81ec9c3= (TDD, 52 new tests, 581 net green). Pure presenter logic for the single-screen console, no view code touched: =viewmodel.net_faceplate= (state word + lamp + TUNNEL/AIRPLANE badges, wired-link-wins precedence), =network_console_rows= (ethernet pinned, radio-off note, active-then-signal sort, per-row lamp/caption/ladder/forget), =channel_headline= (wired device+speed / SSID+ladder+dBm / not-connected placeholder), =tunnel_console_rows=, dial-meter geometry (=meter_needle_deg= + =meter_scale= 100→1000 auto-relabel), =signal_bars=/=mbps_label=, and =panel.ArmState= (two-click arm-to-fire for forget/disconnect). Engine verbs: =manage.wifi_radio= (nmcli radio wifi on|off), =manage.device_up= (ethernet take-the-route), =sysio.link_speed_mbps= (/sys wired speed), =connections.ethernet_devices=, hidden flag on =manage.add=.
*** 2026-07-03 Fri @ 06:02:32 -0400 Phases 3+4 shipped: net view rebuilt as the instrument console
Dotfiles =800ef60= (1197+/250-). =gui.py= rewritten as the single-screen console — no tabs, no Blueprint template (the dial meters and arm-to-fire rows are too dynamic, so the tree is built in Python; =pages.py= + the =*.blp/*.ui= are now orphaned, Phase 6 dead-code). Faceplate (lamp/word/TUNNEL+AIRPLANE badges/wifi-radio switch/close), engraved CHANNEL headline, scrolled NETWORKS + TUNNELS lamp rows, CONSOLE keys, two cairo dial meters, output well + dismiss ✕, toast. Interactions all wired: open network joins / secured opens the password dialog, active row arm-disconnects (gold), ✕ arm-forgets (terracotta), tunnel toggles, ethernet row takes/yields the route, radio switch flips the wifi radio (refuses under airplane with the way out), + hidden joins a non-broadcast SSID, DOCTOR streams diagnose+repair into the well, SPEED TEST sweeps both dials with the live rate then pins the final with HOLD (location/ping/final/tips in the well). =panel.css= grew the console classes (lamps+glow, b-face, engrave, chan, lamp-row + arm tints, c-btn, meter/mode/hold, output steps, toast). AT-SPI smoke + driver rewritten (anchor on the DOCTOR key). Phases 3 and 4 landed together because a view-only intermediate is a non-functional panel. Verified live on velox: full render screenshotted, console smoke green (faceplate/keys/sections/tunnels/DOCTOR/dismiss/close), DOCTOR streams real diagnose steps, SPEED TEST drove RX 36.6↓ / TX 90.7↑ then HELD. 581 net tests + full make test green.
*** 2026-07-03 Fri @ 06:55:00 -0400 Phase 5 shipped: bt panel rebuilt as the instrument console
Bluetooth's turn, two commits mirroring net. Phase-5a (dotfiles =5318b34=, 47 new console tests): the GTK-free layer — =viewmodel.bt_faceplate= (POWERED/OFF/AIRPLANE word + lamp + LOW BATT/AIRPLANE badges), =paired_console_rows= / =nearby_console_rows= (lamp rows with connect/forget/rename affordances), =discoverable_chip=, count labels, =battery_gauges= (two dial slots, one per connected device, red under 15%, dim NO DEVICE / ADAPTER OFF empties), =STEP_NARRATION=, and =panel.ArmState= (the forget latch). Engine gaps: =btctl.set_alias= renames through the bluez D-Bus Alias via busctl (set-alias has no MAC-addressed one-shot; =device_path= discovers the controller node from the object tree), =manage.rename= wraps it with a verify-after read, =parse_info= reads the Alias as the display name (a rename lands there, not on Name; the MAC-shaped placeholder stays "unnamed"), and =doctor= grew =on_report=/=on_begin= callbacks. Phase-5b (dotfiles =66f03d9=): =gui.py= rewritten as the single-screen console — faceplate (lamp/word/LOW BATT+AIRPLANE badges/adapter-power switch/close), engraved ADAPTER line with the clickable discoverable chip, scrolled PAIRED + NEARBY lamp rows, CONSOLE keys DOCTOR / SCAN, two cairo battery dials, output well + toast. Interactions: paired rows toggle connect/disconnect, ✎ renames via a dialog, ✕ arm-forgets, nearby rows run the pair flow into a passkey-confirm dialog, the chip toggles discoverability, the switch powers the adapter, SCAN refreshes nearby, DOCTOR streams checks + repairs. =panel.css= gained =.chip= / =.pen= / =.o-passkey= (the rest already shared with net). AT-SPI smoke rewritten (anchor on the bt-only SCAN key). Verified live on velox: smoke green end to end, screenshot matches the prototype (POWERED faceplate, four paired audio devices, two NO DEVICE battery dials). 46 suites + full make test green. Phase 6 next: live both-panel verify, folded tasks closed, dead code removed, spec → IMPLEMENTED.
*** 2026-07-03 Fri @ 06:49:45 -0400 Phase 6 shipped: build closed out, dead code removed, spec IMPLEMENTED
Live both-panel verify on velox: 46 suites + full make test green, and both AT-SPI smokes green end to end (net: faceplate NET·01/ONLINE, DOCTOR streams real diagnose steps, tunnels rows, close; bt: BT·01/POWERED, SCAN/DOCTOR keys, battery dials, close). The two =gui.py= files are byte-identical to their screenshot-verified commits (net =800ef60=, bt =66f03d9=), so the render carries over from the phase-3/4/5 screenshots — this pass touched no view code. Dead code removed (dotfiles =f4e688e=): both panels' orphaned =pages.py= + =ui/= (=*.blp/*.ui=) gone now that =gui.py= builds the tree in Python, the now-dead =make ui= Blueprint-compile target and its =.PHONY= entry dropped, and the stale =gui.py / pages.py= mention in bt =viewmodel.py= fixed; nothing imported the removed modules. Three folded tasks close with this build (network panel redesign, bt switch placement + title, bt rename devices). Build summary written to [[file:assets/2026-07-03-instrument-console-panels-build-summary.org][assets/2026-07-03-instrument-console-panels-build-summary.org]]. Spec =e73877f5= flipped DOING → IMPLEMENTED. Manual-test checklist for the real-device bt interactions filed under Manual testing and validation.
** DONE [#B] Net diagnostics: narrate every step :feature:network:solo:
CLOSED: [2026-07-02 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-02
:END:
Follow-on 2 (dotfiles =ebf24fe=, Craig's decision 2026-07-02, option 1 of the discussed policies): mutating tiers pre-check whether we're online before acting. dns-test/dns-override short-circuit with an "already online" step and touch nothing (live-verified on hotel wifi: 100 ms skip, DNS untouched); reset/bounce/nm-restart/resolved-restart proceed (reset has a legit online use — fresh MAC) but carry "(was online before ...)" in their evidence; the panel's repair confirm warns via the cached probe verdict (=probe.cached_online=, file read only); rfkill/dns-revert/tunnel-down already verify their own state, unchanged. 492 net tests / 45 suites / panel smoke green.
Follow-on (dotfiles =50a7239=, Craig's ask 2026-07-02): the same requirements now cover every Advanced-dropdown action — all repair ids + portal narrate in the panel's step rows, =net repair= / =net portal= human-by-default (=--json= kept, added to portal), doctor's attempts render like checks, and mutating steps keep their next_action on pass (the fail/warn-only rule was dropping dns-override's revert pointer, portal-login's login click, and cleanup-unverified's manual revert). 479 net tests / 45 suites green; panel smoke shows narration in live rows.
Shipped (dotfiles =7772427=): every diagnose step id carries a one-line narration (what it tests and why) in a viewmodel table, and both human renderers print every step — status, title, narration, evidence with timing, and the fix pointer on fail/warn — in =net doctor= and =net diagnose= alike. Bare =net diagnose= now prints the narrated report (was raw JSON; =--json= keeps the machine envelope; =net-fix= already used =--json= explicitly, the panel calls the engine in-process). A completeness test walks diag.py's step ids against the narration table so a new step can't land unnarrated. 470 net tests / 45 suites green; verified live on velox hotel wifi — both commands narrate the full probe sequence. Ratio picks it up with its queued dotfiles pull (source-imported, no restow-only step).
Original ask (roam inbox, 2026-07-02, from a real net doctor run on hotel wifi): the output isn't enough to know what's being tested, why, and whether it passed — a failing run printed only the verdict, the fix pointer, and the failing rows:
#+begin_example
% net doctor
net doctor: fixable
DNS not resolving
-> net repair dns-test
diagnose:
fail: DNS resolution — no resolution (portal may be stalling DNS)
fail: Internet — link up but no clean internet (DNS or egress issue)
#+end_example
** DONE [#B] Network panel: identify tunnel backends + richer connection info :feature:waybar:network:
CLOSED: [2026-07-02 Thu]
Shipped (dotfiles =405235f=). Identification: every Tunnels row's caption now leads with its backend — "tailscale", "WireGuard (NetworkManager)", "openvpn (NetworkManager)" (from the profile's =vpn.service-type=, resolved on the panel path only), "Proton VPN CLI" — via =viewmodel.tunnel_kind_label=. Found and fixed a real gap: NM vpn-type profiles (openvpn etc.) weren't listed at all, only wireguard type. Active tunnels now carry their device's IP4 address. Info page: the active connection's live subtitle gains IP, gateway, and DNS via =build_status(full=True)= (panel poll only — the bar's one-nmcli hot path is untouched). Live-verified on velox: all 9 tunnel rows correctly labeled (tailscale w/ tailnet + peers, 7 WireGuard NM profiles, Proton CLI), live subtitle shows IP/gw/DNS on hotel wifi. 523 net tests / 45 suites / panel smoke green.
Craig's ask (roam inbox, 2026-07-02): the Tunnels rows all look alike — no way to tell which is tailscale, which is an NM wireguard/openvpn profile, and which is proton CLI without prior knowledge (e.g. when you want to bounce tailscale specifically). Second half: improve the stats under each connection — the panel is effectively a connection's info page.
** DONE [#B] Timer: alarm am/pm input silently fails :bug:waybar:solo:
CLOSED: [2026-07-02 Thu]
Fixed (dotfiles =8dd36c4=). Two root causes: =parse_alarm= only accepted 24h =HH:MM=, and =cmd_new= suppressed the ValueError, so any 12h input silently created nothing. Now accepts 24h (=14:30=, bare =14=) and all common 12h shapes (=2:30pm=, =2:30 PM=, =7:15p=, =7p=; any case, optional space, bare a/p; 12am = midnight), and input that still doesn't parse fires a fail notification instead of vanishing. 107 wtimer tests green (10 new parse cases + notify-on-error CLI tests). Manual test filed (live dialog run).
Craig's report (roam inbox, 2026-07-02): when setting an alarm, entering am or pm in any fashion makes the timer silently fail. It should accept 24h and 12h variants — capitalization, spaces, bare "a"/"p" — all common forms.
** DONE [#B] Timer: escape doesn't cancel the dialog flow :bug:waybar:solo:
CLOSED: [2026-07-02 Thu]
Fixed (dotfiles =8dd36c4=). Root cause: =_fuzzel= ignored fuzzel's exit code, so Escape (fuzzel exits 2 on a dmenu abort — confirmed in its changelog) returned "" and the flow fed it onward to the next prompt. =_fuzzel= now returns None on any non-zero exit and =cmd_new= aborts the whole flow on None at any step (type, duration/alarm, label). Escape-at-each-step covered by CLI tests against a fake fuzzel exiting 2. Manual test filed (real keyboard Escape).
Craig's report (roam inbox, 2026-07-02): hitting cancel via escape at the step after choosing "timer" does nothing but proceed to the next step — likely the same for the other dialog steps.
** DONE [#B] Network panel: stream speedtest results live :feature:waybar:network:solo:
CLOSED: [2026-07-02 Thu]
FIX-UP (dotfiles =60707be=, 2026-07-03, caught by Craig): the first shipped version didn't actually stream — speedtest-go buffers all phase lines to process exit when piped (per-line arrival timestamps proved it: 25s of silence, then everything at once; the original "live" verification never checked arrival timing). The stream now runs the binary under a pty, where terminal mode redraws continuously: in-flight rates tick (download climbing like a speedometer), ANSI/spinner noise is stripped, and on_update fires per changed value. CLI closes with a "final:" settled-numbers line. Re-verified with timestamps (server +1s, ping +2s, download first tick +4s, upload +19s, final +29s) AND an AT-SPI probe of the live panel that sampled the results box mid-run: ping filled at 4s, download ticking at 12s, upload at 24s, final rows + conditioned tips at the end. 529 net tests / 45 suites green.
Shipped (dotfiles =38171e8=). =run_speedtest_stream= runs speedtest-go's plain mode, whose lines land one per completed phase (parser written against a real captured hotel-wifi run). Panel: a checklist fills in as ping → download → upload arrive, final rows at the end. =net speedtest= streams the same lines at the terminal (=--json= keeps the one-shot envelope). Bonus from the text mode: jitter (rides the Ping row) and packet loss (own row, warns >1%) — the JSON mode never reported either. The static Tip is gone; =speedtest_tips= derives guidance from the numbers (high ping >100ms, download < half of upload, <10 Mbps both ways, loss >1%), each tip naming its trigger values — that's the answer to Craig's "what criteria" question: the old tip had none, the new ones are stated rules. 509 net tests / 45 suites green; live CLI run streamed correctly and fired the asymmetric-download tip on real numbers (33 down / 76 up). Manual test filed for the in-panel run.
Craig's ask (roam inbox, 2026-07-02): the speedtest only shows results at the end; typical speedtest UIs report the numbers as they come in. Stream the CLI's progress into the results box as it arrives, then the final numbers at the end. Screenshot: ~/pictures/screenshots/2026-07-02_225441.png.
** DONE [#C] Bluetooth bar icon: gray instead of the bar's white :bug:waybar:bluetooth:solo:
CLOSED: [2026-07-03 Fri]
Fixed (dotfiles =27d8eda=). Root cause: the =on= state sat in the css dim group with off/absent/degraded (a deliberate "idle dims" choice that read as broken). Removed =.on= from the dim rule in all three css copies (dupre, hudson, live style.css — the theme-drift guard suite pins them together); indicator docstring updated. Live-verified: SIGUSR2 css reload, bar screenshot shows the bt glyph in the bar's resting white alongside battery/text.
Craig's report (roam inbox, 2026-07-03): the bluetooth waybar icon renders gray, not the same white as the other bar module icons.
** DONE [#B] Bluetooth panel: close button like the net panel :feature:waybar:bluetooth:solo:
CLOSED: [2026-07-03 Fri]
Shipped (dotfiles =42c93d6=): a flat circular Close button right of the tab switcher (accessible "Close" label, "Close (Esc)" tooltip), wired to window.close. The bt smoke asserts it exists AND that clicking it exits the panel (run green live). Plot twist answered in-session: the net panel had no close button either — Craig's leaner-chrome pass removed it 2026-07-01 (787b475) on the Esc-suffices theory; he asked where it went, so it was restored with the same tab-row button (=6a0aff7=, net smoke extended the same way). Both panels match again.
Craig's ask (roam inbox, 2026-07-03): the bt panel needs a close button matching the network panel's.
** DONE [#B] Bluetooth panel: switch placement + panel title :feature:waybar:bluetooth:solo:
CLOSED: [2026-07-03 Fri]
Delivered by the instrument-console rebuild (spec e73877f5, phase 5). The adapter-power switch now sits on the faceplate above every console key, and the engraved ADAPTER line is the panel's title row with the clickable discoverable chip right-justified on it.
Craig's ask (roam inbox, 2026-07-02): move the bluetooth on/off switch above all the buttons. "Bluetooth" becomes the panel's title, with the on/off switch right-justified on that title row. Panel code in ~/.dotfiles =bluetooth/= (GTK4 + Blueprint, phase-2 PanelModel/presenter — see the shipped panel task in Resolved). Presenter tests + the AT-SPI smoke likely need their layout assertions updated.
** DONE [#B] Bluetooth panel: rename devices :feature:waybar:bluetooth:solo:
CLOSED: [2026-07-03 Fri]
Delivered by the instrument-console rebuild (spec e73877f5, phase 5). =btctl.set_alias= renames through the bluez D-Bus Alias via busctl (no MAC-addressed one-shot exists; =device_path= finds the controller node from the object tree), =manage.rename= wraps it with a verify-after read, and =parse_info= reads the Alias as the display name. Each paired row carries a ✎ affordance opening a rename dialog. Live-probed the mechanism on velox before wiring it (rename + restore verified on the M650).
Craig's ask (roam inbox, 2026-07-02): the panel should be able to rename a device. bluez supports per-device aliases (=bluetoothctl= device menu =set-alias=; the one-shot invocation shape needs verifying at the btctl boundary). Wire it through the engine (=bluetooth/src/bt/=) with a verify-after read, and surface a rename affordance on the device row consistent with the panel's existing patterns.
** DONE [#B] Network panel: other network interfaces (tailscale, VPNs, wireguard) :feature:waybar:network:
CLOSED: [2026-07-02 Thu]
:PROPERTIES:
:SPEC_ID: 79a1075a-4b56-4f25-a861-b69f120a636a
:END:
Spec: [[file:docs/specs/2026-07-02-net-panel-other-interfaces-spec.org]] (DOING — reviewed READY and decomposed 2026-07-02 evening; all four decisions were resolved same morning, claims re-verified live at review: protonvpn binary, tailscale JSON shape, seven importable wireguard configs).
Tunnels visible and controllable in the net panel: tailscale + NM wireguard + proton-vpn-cli probes, a Tunnels group in Connections, diagnose/doctor route-ownership awareness, a bar badge when a tunnel owns the default route, archsetup operator flag + package swap, and the one-time NM import of the seven Proton configs. Origin: roam inbox capture 2026-07-02.
*** 2026-07-02 Thu @ 18:47:05 -0400 Shipped phase 1 — overlay probes (dotfiles 2d9d060)
=net/src/net/overlays.py=: one probe per backend, shared row shape ={kind, name, state, addr, detail, can_toggle}=. tailscale parses =status --json= (up/down/needs-login/stopped, tailnet + N/M peers online + exit node detail, first TailscaleIP); wireguard rows filter =nmcli connection show= by type with uuids for the existing up/down wrappers; proton drives the official CLI — ground truth sampled live before writing the parser: the GUI-running refusal prints to stdout and EXITS 0 (text-detected, =can_toggle false=), disconnected = "Status: Disconnected", and the CLI's account store is separate from the GTK app's (=protonvpn info= → Account 'None' — sign-in is a phase 6 migration step for Craig). =net status= gained a fast-path overlays section (tailscale + wireguard only; the python CLI's ~300ms startup stays out of the indicator poll, and an active proton tunnel surfaces as its NM wireguard row anyway), guarded so a probe crash yields =[]= not a dead indicator. 19 new tests over fake-tailscale/fake-protonvpn/fake-nmcli (45 suites green); live check on velox: tailscale row up, 5/6 peers, hot path 149ms. proton-vpn-cli 1.0.1 installed on velox (GTK app stays until phase 5).
*** 2026-07-02 Thu @ 19:02:45 -0400 Shipped phase 2 — panel Tunnels sub-view (dotfiles 21db05a)
Connections gained a third sub-view (Available | Saved | Tunnels — a StackSwitcher page, the natural landing for the spec's "fourth group" in this UI): rows from =overlays.collect(fast=False)= with the vpn glyph, name, and a =tunnel_caption= (state · addr · backend detail); one primary button follows the selected row via =PanelModel.tunnel_primary()= — Bring Up/Bring Down when toggleable, disabled explainers for needs-login ("Sign in first: tailscale up") and the Proton GUI-running case. =manage.tunnel_up/down= dispatch by kind (wireguard rides the existing nmcli up envelope + =connection down=; tailscale/protonvpn shell their tools into a =_tool_result= envelope carrying stderr on failure); ops run on the worker thread, rows + bar reload on land. gui grew =refresh_tunnels()= (bg, full probe set) kicked from the list load. AT-SPI smoke extended (Tunnels tab, action button, rows — POLLING for the bg load; a fixed sleep raced it and false-failed). 22 new tests (45 suites green). LIVE on velox: smoke fully green, rows eyeballed in dupre (tailscale up caption with peers count; proton app-running row), =tailscale set --operator=cjennings= applied and the user-mode =tailscale down/up= round-trip verified (Self.Online back true). Gotcha reconfirmed: stray test panels leave a windowless single-instance process — =pkill -9 -f '[n]et panel'= + wait before relaunch.
*** 2026-07-02 Thu @ 19:11:47 -0400 Shipped phase 3 — diagnose/doctor tunnel awareness (dotfiles 31ba056)
=overlays.default_route_owner()= classifies the default route's owner (tailscale prefix, wg/pvpn/proton/tun/tap prefixes, else the active NM connection's type — imports can name a wireguard device anything). diag's route step went three-way: overlay owner = informational pass row ("internet flows through the tailscale tunnel tailscale0"), other physical link = the old multi-homing warn. When the HTTP probe fails while a tunnel owns the route, a new "tunnel" edge row LEADS the evidence and the classifier returns fixable/action tunnel-down (the deferred-vpn verdict is retired — it was look-don't-touch, and it never caught tailscale at all since NM lists it unmanaged; an NM VPN that doesn't own the route now falls through to normal classification instead of being blamed). =repair_tunnel_down= dispatches by owner (tailscale CLI / protonvpn CLI for pvpn-named devs / nmcli connection down via active-connection lookup), verifies route ownership actually moved, and registered in ACTIONS so Get Me Online drives it. fake-ip gained FAKE_IP_DEFAULT_DEV_SEQ (head-first line consume, the UP_RC_SEQ idiom) so tests watch the owner change across the verify. 11 new tests, 2 old deferred-vpn pins rewritten to the new contract; 45 suites green; live read-only diagnose on velox clean (wlan owns the route — no tunnel rows, as designed).
*** 2026-07-02 Thu @ 19:14:58 -0400 Shipped phase 4 — bar tunnel badge (dotfiles b4010bf)
=net status= carries =tunnel_route= ({dev, kind} via =overlays.default_route_owner=, exception-guarded like the overlays list, present on the no-device path too). The indicator appends a small nf-md-vpn badge after the state glyph, emits =["<state>", "tunnel"]= as a waybar class list (string class unchanged when no tunnel), and the tooltip names the owner ("Tunnel: default route via tailscale0 (tailscale)"). No css edit — presence is the signal, themes can hook the class later, and the waybar/style.css drift test stays untouched. 4 new tests; StatusHarness gained fake-ip so the machine's real route can't leak into assertions (462 net tests, 45 suites green). Live payload on velox verified badge-free (wlp170s0 owns the route — correct); a badge render awaits the first real tunnel-owned route (phase 6's wg import or a tailscale exit node).
*** 2026-07-02 Thu @ 21:56:00 -0400 Shipped phase 5 — installer proton CLI swap + tailscale operator (archsetup 0389790); GTK app retired live on velox
The feat commit landed at 19:16 (the session died before this close-out): installer enables tailscaled with =--now= and grants =tailscale set --operator= to the primary user (brief retry while the daemon's socket comes up), proton-vpn-cli replaces proton-vpn-gtk-app, VM asserts the vpn stack + the retirement + the OperatorUser pref (format verified against a live daemon). Live velox application finished 21:55: the =protonvpn-app --start-minimized= exec-once removed (dotfiles b5c8442 — nothing replaces it, the CLI is on-demand from the panel), the running app killed, =pacman -Rns proton-vpn-gtk-app= (proton-vpn-daemon stays — separate package the CLI uses). CLI verified unblocked: =protonvpn status= → "Status: Disconnected", =protonvpn info= → Account 'None' (sign-in is Craig's step, filed under Manual testing and validation).
*** 2026-07-02 Thu @ 21:57:00 -0400 Shipped phase 6 — wireguard import script + velox migration (scripts/import-wireguard-configs.sh)
The script stages each config through a =wgpvpn.conf= temp copy (NM's import name must be a valid <=15-char interface name; several config names are longer), renames by the UUID parsed from the import output (never by the transient name, so a stray same-named connection can't be hit), forces =autoconnect no= (full-tunnel AllowedIPs 0.0.0.0/0 must not arm itself at boot), skips already-imported names, and refuses to run past a stale =wgpvpn= connection (an earlier run that died between import and rename — it still has autoconnect on). =tests/import-wireguard-configs/=: 10 cases over a fake nmcli; writing them caught a real bug (under =set -e= the grep-for-UUID pipeline aborted before the error message printed). shellcheck clean; 11 unit suites green. Velox migration verified: the crashed session had already run the import, so tonight's run exercised the skip path live — all 7 connections confirmed wireguard type, autoconnect no, iface wgpvpn, no stale leftovers; =net status= overlays show tailscale + all 7 rows. Ratio runs the script on its trip (rides the archsetup pull).
*** 2026-07-02 Thu @ 21:58:00 -0400 Test surface complete across the phases
Probe suites over fake tailscale/nmcli/protonvpn (19, phase 1), panel-model Tunnels coverage (22, phase 2), diag overlay-ownership cases (11, phase 3), badge suite (4, phase 4) — all in dotfiles; VM assertions for phase 5 in archsetup 0389790; the import-script suite (10, phase 6) closes the set.
** CANCELLED [#B] File-manager swallow pattern :feature:hyprland:
CLOSED: [2026-07-02 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-02
:END:
Reassigned to .emacs.d 2026-07-02 (handoff: =~/.emacs.d/inbox/2026-07-02-2231-from-archsetup-dirvish-popup-swallow-handoff.org=). The "file manager" is the dirvish popup (Super+F, an Emacs frame), not nautilus — so the fix is elisp in dirvish's external-open path (=cj/xdg-open=): spawn the handler directly with =start-process=, hide the popup frame, restore it from the process sentinel, notify on non-zero exit. The spec drafted here first ([[file:docs/specs/2026-07-02-file-manager-swallow-spec.org]], now CANCELLED) records the feasibility finding that stays useful: gio/xdg-open launches double-fork, so no PID-ancestry approach (Hyprland native swallow included) can ever connect viewer to launcher.
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.
*** 2026-07-02 Thu @ 22:20:00 -0400 Feasibility ground truth: Hyprland native swallow ruled out
=misc:enable_swallow= would be the whole feature in two config lines, but it matches by PID ancestry, and nautilus's launch path (GLib =g_app_info_launch_default_for_uri=) orphans the handler — reproduced live on velox with a python-gi launcher: feh came up with PPID 1 while the launcher was still running. The spec's design is therefore an event-listener daemon (socket2 =openwindow=/=closewindow= while nautilus is active), the touchpad-auto shape. Handlers sampled: pdf → zathura, image → feh (X11 — flagged as a side task), video → mpv, text → emacsclient (exempt candidate, decision 2).
** DONE [#C] Open meeting links in the browser instead of the Zoom app :feature:
CLOSED: [2026-07-02 Thu]
Shipped 2026-07-02, mechanism per Craig ("the Linux zoom app is really terrible — one less dependency"): a =zoommtg://= URL handler, and the native app retired outright. =zoom-web= (dotfiles 187414a, 10 tests) registers as the xdg default for x-scheme-handler/zoommtg via =zoom-web.desktop=; Zoom's launch-page bounce rewrites deterministically to =https://<host>/wc/join/<confno>?pwd=…= in the default browser (subdomain hosts preserved, tracking params dropped, start action mapped, malformed URIs notify + exit 2). The registration landed in the stowed mimeapps.list, so it ships with dotfiles. Zoom uninstalled from velox (=pacman -Rns=), its windowrules removed from hyprland.conf, =aur_install zoom= dropped from archsetup, and the VM retired-package assertion now covers blueman + zoom. Known limit, accepted: a host who disabled join-from-browser blocks the web client — that meeting needs the native app installed ad hoc. Ratio trip: =pacman -Rns zoom= + the pull brings the handler; run =xdg-mime default zoom-web.desktop x-scheme-handler/zoommtg= if the stowed mimeapps.list doesn't take effect.
** DONE [#B] Network panel redesign — no terminals, verify-everything, full failure coverage :feature:waybar:network:
CLOSED: [2026-07-03 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-02
:END:
Delivered by the instrument-console rebuild (spec e73877f5). The three locked decisions all landed: no terminals (the single-screen console renders every action and result in the output well — net-popup is gone), the passwordless privileged path (the net-priv helper + narrow NOPASSWD sudoers, shipped earlier and carried forward), and verify-every-action (arm-to-fire mutations plus doctor's re-probe). The failure-mode catalog below is the diagnose/repair contract, built out across the net-diagnostics tasks and this rebuild's DOCTOR path; the catalog stays here as the standing completeness reference for that path.
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>."
*** 2026-07-01 Wed @ 13:02 -0400 net-priv helper landed (V2.1)
Craig's call: stowed (not root-owned), low security on locked-down single-user machines.
Shipped =net.priv= module + stowed =net-priv= bin (dotfiles =00aac1e=): a fixed 12-verb set
(rfkill/radio/mac-random/conn-up/net-off/net-on/restart-nm/dns-set/dns-revert/restart-resolved/
dot-disable/dot-enable) with per-arg validation (uuid/iface/ipv4/resolved.conf.d-path, injection
rejected). =repair.py= now routes every privileged op through =priv.run(verb)= in-process instead
of scattered inline sudo — which also fixes the detached DoT-restore watcher (runs privileged ops
with no tty) and closes the gap where rfkill repair ran unprivileged. 244 net + 33 dotfiles suites
green. NO new sudoers needed: archsetup already grants =%<user> ALL=(ALL) NOPASSWD: ALL=
(archsetup:1089), so every build's primary user already runs net-priv's commands passwordless;
"replicate in archsetup" is already satisfied. net-priv rides =make stow hyprland=; hand-linked on
velox. The velox DoT-path reconcile (whether velox should run DoT at all) stays open — folded into
the deeper reconcile, low priority since the guard makes it a no-op.
*** 2026-07-01 Wed @ 14:05:47 -0400 Shipped V2.2 — merged Diagnostics panel + nav restructure, no terminals
Built the V2 panel (dotfiles =75ed825=, pushed): three top tabs Connections |
Diagnostics | Performance; Diagnostics merges the old Diagnose + Repair pages into a
sub-row (Diagnose | Get me online | Advanced) over a shared area that shows diagnose
rows AND streams repair progress in-panel. net-popup deleted entirely; repairs run on
a worker thread through net-priv (no tty). doctor grew an =on_step= callback so Get me
online streams each escalation step live. Connections groups Saved / Available now /
Wired with a golden group header and joins from a row (=join_plan= auth matrix +
=manage.join= one-step connect, secret to NM only); the Add modal became the hidden-network
affordance. Every diagnose/repair/speed run offers a Copy/Open redacted report
(=report.py=, MAC/IP scrubbed). Waybar visual contract applied (dark capsule, golden
border, monospace) via a CssProvider. =net-fix= opens the panel on Diagnostics instead of
a terminal; middle-click runs =net portal= directly. TDD: 34 new GTK-free tests (grouping,
join_plan, join, report, on_step, eventlog.tail); 278 net + 33 dotfiles suites green.
Live-verified: AT-SPI panel_smoke passes end-to-end + screenshots confirm both pages and
the visual contract. DAILY-DRIVER: waybar config + net-fix are stow symlinks (live on
disk); ratio needs =git pull= + waybar restart; velox waybar picks up on next restart.
**** 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]].
*** 2026-07-01 Wed @ 22:01:38 -0400 Made diagnose IPv6-aware and multi-homing-aware (dotfiles c0d48e2)
IPv6-only networks pass the DHCP step ("IPv6 only: <addr>") with the v6 gateway standing in for the ping; a bare fe80:: doesn't count. A new route step fires only under multi-homing and names the interface that owns the default route (tether/ethernet/VPN). Also landed the adjacent IP-layer detects: APIPA 169.254 fails DHCP with a link-local explanation, address-without-gateway fails the gateway step as a bad DHCP answer, and a weak wifi signal (below fair) warns on the link step with the dBm. fake-nmcli grew IP6.* and a fake ip(8) serves the JSON route reads. TDD, 33 suites green.
*** TODO Close every detect/correct gap in the catalog, with post-action verification
**** 2026-07-01 Wed @ 22:41:51 -0400 Closed the feasible edge rows (dotfiles d096b30, 241744b, fafefb6)
Three grouped commits, all TDD. Services/radio: dead NetworkManager and dead systemd-resolved get their own diagnose steps and verified restart repairs (resolved only when resolv.conf is resolved-managed; hand-managed DNS gets a heads-up row), airplane mode fails the link by name and classifies needs-user-action ahead of rfkill, and a missing WiFi adapter is named with the dmesg pointer. Association/auth: reset retries once with the permanent MAC when the randomized one is rejected (new mac-permanent net-priv verb), SAE/WPA3 activation failures classify sae-incompat, and stuck-connecting classifies fixable/reset. Egress edges (run only on an existing failure): DNSSEC validation failure named via resolvectl, clock skew off the probe's Date header, MTU/PMTUD blackhole via df-bit pings, and proxy detection (env vars or an advertised WPAD name). Deferred as infeasible without state the engine doesn't keep: AP roaming flap (needs BSSID history), duplicate-IP/ARP conflict (needs the kernel log), and the USB-unplug transition (its end state is the no-adapter row). Still open here: generalized post-action verification for connect/forget/add.
**** 2026-07-01 Wed @ 22:01:38 -0400 Closed the two named correct gaps (dotfiles 7819f58)
Gateway unreachable now earns one bounce before the upstream verdict (classifier returns fixable/bounce on gateway warn/fail + probe fail; reachable-gateway keeps the honest upstream call, DNS failure still outranks it). Venue-DNS-broken-but-public-works now ends online: the dns-test chain escalates to a persistent dns-override (1.1.1.1 on the link, dies on reconnect, offered dns-revert undo; a useless override reverts itself) instead of auto-reverting into a misreported upstream outage. Override-aware getent/curl fakes model the venue end to end. Remaining: the edge rows (DNSSEC, proxy, MTU blackhole, clock skew, ARP conflict, roaming flap, stuck-connecting budget, USB-adapter unplug, driver/firmware, WPA3/SAE, randomized-MAC retry, NM-not-running, resolved-down, unmanaged resolv.conf) and the generalized post-action verification for connect/forget/add.
*** 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.
*** VERIFY Dead-GUI console recovery vs "no terminals" — keep =make online= or replace it? :network:
The cj comment (2026-07-01) said scrub every terminal the module uses to report to or get input
from the user, and I folded that into decision 15 (all module UX is in-panel). The one place it
collides: the deliberate console-recovery path — =make online= / =net doctor --fix= run from a
bare TTY when waybar and the GUI are *down* — is the whole point of the CLI being usable with no
GUI. That's a terminal reporting to the user, but only because there's no panel to use. Keep it
as an explicit carve-out (recovery-only, not terminal-as-UI), or replace it with something else
(a TTY text UI still counts as a terminal)? Your call settles whether the Makefile/CLI recovery
targets stay in the spec.
** DONE [#B] Audio panel spec :feature:waybar:audio:solo:
CLOSED: [2026-07-03 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-02
:END:
Went past the spec to a full build in a no-approvals speedrun. Spec is now IMPLEMENTED ([[file:docs/specs/2026-07-03-audio-panel-spec.org]], all 5 Decisions resolved). The panel shipped in the dotfiles repo (branch panel-bugfixing, commits 65e5bb0..9601420): pactl engine, GTK-free presenter, GTK instrument-console panel (OUTPUTS/INPUTS device rows with faders + per-device mute, LIVE/MUTED/PUSH·TALK mic keys, twin VU gauges, master quick-mute), Hyprland-bind push-to-talk, bar indicator, and the bar/keybind wiring (Super+A → panel, XF86AudioMute → master quick-mute). 102 unit tests + a passing AT-SPI smoke on velox. Live-eyeball validation filed under Manual testing and validation. Apply steps + follow-ups handed to the dotfiles project inbox.
Original ask (roam inbox, 2026-07-02): net/bt-panel kin — change default output/input, volume for both, push-to-talk mic mode for meetings, master quick-mute, bar sound-glyph state. Related bindings: Super+M audio-cycle ring, Super+Shift+A mic-toggle. Prototype: =docs/prototypes/2026-07-03-sound-panel-prototype.html=.
** DONE [#B] Panels moveable + resizable by drag :feature:waybar:network:bluetooth:
CLOSED: [2026-07-04 Sat]
Resolved by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): both net + bt panels switched from anchored gtk4-layer-shell overlays to normal floating windows (set_decorated(False), positioned by the net.cjennings.netpanel window rule), so Hyprland moves them on drag and resizes on corner-drag natively. That was exactly the "switch to a normal floating window" approach the design note flagged as the required decision.
Both the net and bluetooth instrument-console panels should be repositionable and resizable at runtime: click-drag to move the panel anywhere on screen, drag the corners to resize. Raised from roam capture 2026-07-03.
Design note: the panels are gtk4-layer-shell overlays anchored TOP+RIGHT with fixed margins — layer-shell surfaces are compositor-positioned, so free drag-move/resize needs either dynamic margin updates on pointer motion or a switch to a normal floating window (Hyprland moves/resizes those natively). Approach decision required before build.
** CANCELLED [#B] Net panel wider initial width :waybar:network:quick:
CLOSED: [2026-07-04 Sat]
Superseded by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): the panel is now a floating, user-resizable window (set_default_size(420, 560)), no longer a right-anchored layer-shell surface. The task's mechanic ("keep the right edge fixed, extend the left border leftward") assumed the old anchored surface, which no longer exists — the width is now drag-adjustable. Cancelled per the 2026-07-04 audit (Craig's call to close rather than re-file a "bump the 420px default" task).
Start the network panel a bit wider — keep the right edge fixed (it's right-anchored), extend the left border leftward. Raised from roam capture 2026-07-03.
** DONE [#B] Net panel doctor results can't display :bug:waybar:network:
CLOSED: [2026-07-04 Sat]
Resolved by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): the panel gained a streaming output well (gui.py) with a "Copy results" button (via wl-copy) and a dismiss control that collapses the well back to the panel's pre-open height (_shrink_to_compact asks Hyprland to resize back). Doctor/speed-test output streams into it as appended lines — matching the task's ask for a tall results box, copy button, and collapse-back. This capture (filed 2026-07-03 morning) predates the same-day 22:06 redesign that addressed it.
The doctor diagnostic output is unreadable — the results well is too constrained to show the multi-line result. It should open a results box tall enough for several lines with a copy-results button; closing it via an X in the box's upper-right collapses the space back to what it occupied before. Raised from roam capture 2026-07-03.
** DONE [#B] Timer GTK panel :feature:waybar:
CLOSED: [2026-07-05 Sun]
Built and shipped to dotfiles 2026-07-05 in a no-approvals speedrun (4 commits =1f4f270=..=78d3cbb=): wtimer gained watch/lap/save; a new =timer/= package holds a GTK-free PanelModel (62 tests) and the GTK instrument-console panel; the bar's =custom/timer= now opens the panel and the fuzzel creation flow retired. Spec: [[file:docs/specs/2026-07-02-timer-panel-spec.org]] (IMPLEMENTED). Code-complete; live GTK verification filed under Manual testing and validation below.
From Craig's roam capture 2026-07-02: give the timer a GTK UI/UX like the network panel. Scope expanded via a later cj comment (queue/output-wall auto-sorted by fire time, stopwatch lap/stop + saveable runs, 5/25 configurable defaults, up to 10 timers, widget-gallery elements) — folded into the spec's Build scope and shipped.
*** 2026-07-05 Sun @ 07:20:20 -0500 Redesign shipped — hero-on-top rebuild
The UI/UX redesign (decided through the prototype process, final = [[file:docs/prototypes/2026-07-02-timer-panel-prototype-3.html]]) built and shipped to dotfiles in a no-approvals speedrun, 5 commits =c7ac193=..=5a863b5=: Phase 1 wtimer engine (timer repeat; recurring alarms with snooze/ringing/dismiss; =@half=/=@hour=/=+dur= alarm parse; the rebuilt configurable pomodoro cycle — work/rest short+long, long-every-N, auto vs awaiting); Phase 2 PanelModel view-data (=row_view=, ringing-first sort, per-type create options as wtimer flags, locked presets + half-past + named pomodoro cycles); Phase 3 GTK hero-on-top panel (Cairo progress ring + stopwatch analog sweep dial, per-type create strips, one transport row, close ✕/Esc); Phase 4 bar-tooltip parity. wtimer + timer suites 231 green, full =make test= green. Spec re-flipped DOING → IMPLEMENTED. Stopwatch run-save deferred to vNext. Live GTK render is the refreshed manual checklist below.
** CANCELLED [#B] Test each modernization thoroughly before replacing
CLOSED: [2026-07-04 Sat]
Retired in the 2026-07-04 audit (Craig's call): a standing-judgment umbrella with no completion criterion. The fleet is Hyprland-only now, and per-change test discipline is already carried by the actual work (TDD + the VM harness), so this adds nothing to track. Original intent: ensure new tools integrate with the Hyprland environment and don't break workflow (archsetup still supports DWM/X11 but no current machine uses it).
** DONE [#C] Window focus lost when unhiding stashed windows :bug:hyprland:
CLOSED: [2026-07-04 Sat]
Verified fixed live on ratio 2026-07-04 (Craig at the machine). Stash (Super+O) → restore (Super+Shift+O) left the restored window focused, and Super+J/K (layout-navigate) cycled focus normally afterward — both original symptoms gone. Resolved by two fixes that postdated the filing: dotfiles 5619342 (raise window on focus nav, stop float focus-follow, 2026-06-28) and 09815f3 (cycle focus by address so j/k works in monocle, 2026-06-29). Both confirmed present in ratio's HEAD and in the live layout-navigate script at test time.
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. (Note: the actual bind is Super+J/K, not ctrl+j/k as the capture said.)
** DONE [#C] Instrument-console panel bugs (net/bt/audio) :bug:dotfiles:
CLOSED: [2026-07-05 Sun]
Batch from the roam inbox (2026-07-05). Panel code lives in =~/.dotfiles= (net/, bluetooth/, audio/). All eight shipped 2026-07-05, each verified live and covered by the panel test suites.
*** 2026-07-05 Sun @ 17:49:51 -0400 Titled the panel windows Network/Bluetooth/Audio (was python3)
The GTK app set no window title, so it fell back to the process name. dotfiles 2d03451.
*** 2026-07-05 Sun @ 17:49:51 -0400 Faceplate word is the subsystem name, not live state (net/bt/audio)
NETWORKING / BLUETOOTH / AUDIO; state now reads off the lamp colour + badges. Bluetooth's DOCTOR feedback (CHECKING/FIXING) moved to the status line and output well. dotfiles 5c58833.
*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel flags a signed-out Proton CLI as needs-login
The proton probe reads =protonvpn info= first, since =protonvpn status= says Disconnected either way. The panel already blocks a needs-login row with a sign-in hint. dotfiles 2671472.
*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel sorts live tunnels to the top
Stable within each backend group. dotfiles 2671472.
*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel refuses a second full-tunnel VPN while one is active
tailscale is a mesh overlay, not a full tunnel, so it never conflicts. dotfiles 307a0fe.
*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel dedupes Proton's own wireguard row
The proton CLI's =ProtonVPN <server>= NM profile no longer shows alongside the proton backend row. dotfiles dbc9ee8.
** DONE [#B] Maintenance console build :feature:
CLOSED: [2026-07-08 Wed]
:PROPERTIES:
:SPEC_ID: 9d9df833-c592-4aec-a7df-50d588e943ce
:END:
Build the maintenance console per [[file:docs/specs/2026-07-07-maintenance-console-spec.org][maintenance-console-spec.org]] (DOING; review incorporated 2026-07-07 — two rounds, all 13 decisions DONE, all 10 findings DONE, verdict Ready; Craig approved config paths + sysmon right-click re-homing). Design source of truth: [[file:docs/design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]]; pixel reference: [[file:docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html][E5 prototype]]. CLI-first =maint= package in dotfiles (archsetup-owns-dotfiles applies: edit, test, commit, push, note dotfiles inbox per phase); thresholds TOML + VM harness in archsetup. Each phase gates on green tests before its commit.
*** 2026-07-07 Tue @ 20:11:23 -0500 Built Phase 1 — package skeleton, thresholds, contract (dotfiles 43a39ac)
maint/ package + shim + tests/maint/ (48 tests, fake-tool harness); thresholds two-layer merge with disable flags + env overrides; capability probe (battery ⇔ type==Battery, verified against ratio's Mains+USB tree); =maint status --json= with disk-usage/pacman-cache/failed-units pilots. Seeded configs/maintenance-thresholds.toml in archsetup from the workflow's values and installed a copy to ~/.config/archsetup/ on ratio (installer wiring stays Phase 13). Full make test green (50 suites). Live on ratio: worst=warn from the real 10.75 GB cache; du grades on its printed total, not its exit code (root-owned partial-download dirs make it exit 1) — pinned in a test.
*** 2026-07-07 Tue @ 20:49:05 -0500 Built Phase 2 — storage & snapshot collectors (dotfiles 94e8371)
All Phase 2 collectors TDD'd against the fake-tool harness (argv-matched cases added): btrfs unallocated/scrub-age/device-errors (SMART cross-check), per-disk SMART health + last self-test, fstrim posture (discard mount option = continuous trim, ratio's real setup), disk top-consumers on a new slow-local cache (=maint scan --slow=, ~/.local/state/maint), snapper counts split timeline·single·pre-post (split keys on cleanup, not type), full ZFS family. =priv.py= pulled forward from Phase 6 (read-only verbs =smart_json= + =btrfs_scrub_status=, validated args, MAINT_SUDO) so probes stay elevation-free. maint suites 48→118; make test green (51). Live read-only verified on BOTH hosts: ratio worst=warn (10.75 GB cache; scrub 0d, SMART ok), velox over tailscale shows the ZFS family live (ONLINE/autotrim-on/scrub 1d/231 snaps) with a genuine fstrim warn (timer off — Phase 6 Confirm remedy). TOML gained storage keys (btrfs_unalloc_warn_gb, smart_*, fstrim_stale_days, hog_*); installed copies refreshed on ratio + velox.
*** 2026-07-07 Tue @ 21:30:59 -0500 Built Phase 3 — packages, security, systemd collectors (dotfiles 3eaab6e)
All Phase 3 collectors TDD'd: orphans name+size with [curation.kept_orphans], pacnew safe-delete vs needs-merge, keyring freshness, reboot-required (uname -r vs modules dir), -Qkk on =maint scan --slow= with a noise split (unverifiable-as-user reads, mtime-only, [curation.qkk_known] paths tallied but never counted — an unfiltered live run sat permanently red at 68 "altered" files; classified, ratio shows 10 real findings); =maint scan --net= writes checkupdates/yay/arch-audit/fwupd caches (yay rc-1-silent = AUR-clean zero; failures never clobber good cache; malformed payloads degrade to unprobed), readers grade with age + stale bump; is-system-running with state-aware cause, failed-unit roster since/exit/journalctl hint, maintenance-timers meta-metric (capability-derived expected set, @-prefix instance match), taint letters decoded. Prereq done: arch-audit added to archsetup deps + installed both hosts. Review subagent (live-verified) found 2 Important (yay AUR-clean rc quirk, cache-shape envelope kill) + 2 minor — all fixed red-first; archsetup now enables btrfs-scrub@-.timer on btrfs installs (was ratio-only out-of-band, the meta-metric expects it). maint suites 118→169; full make test green. Live on ratio: 23-metric envelope, worst=warn from real findings (10.75GB cache, 8 orphans, mirrorlist.pacnew, 713 repo updates, 32 CVEs, 1 firmware).
*** 2026-07-07 Tue @ 21:59:27 -0500 Built Phase 4 — logs + mem·pwr collectors (dotfiles 40c448b)
All Phase 4 collectors TDD'd: journal error digest (identifier groups w/ first/last seen + journalctl hint, known-noise curation tallied but never counted, byte-array MESSAGE decoded), coredumps by binary over a TOML window, kernel/hw event classes (MCE/EDAC/IO/thermal/GPU — generic driver noise excluded), journald disk usage, app-log staleness mirroring the log-cleanup cron's filename-date rules; memory pressure + top-5 RSS, OOM kills (kill-verdict lines only — one event spans several matching lines), swap/zram, temps via sensors -j (k10temp Tctl / coretemp package / amdgpu edge), Intel thermal-throttle count, EPP read, battery capacity/health/charge-cap, unclean-shutdown rate from wtmp (last -x pairing). Throttle/EPP/battery self-gate on sysfs and drop out of the envelope. Review subagent (live-verified) found 1 Blocking — windowed-empty coredumpctl exits 1 (my earlier --since-now pin was wrong), which left the metric permanently unprobed on healthy hosts — plus OOM ~3× overcount and a malformed-curation-entry envelope crash; all fixed red-first, re-review Approve. maint suites 169→227; full make test green. Live read-only both hosts: ratio 34 metrics (telega-server crashing 3× in window — real find), velox 38 (genuine 75% battery-health warn; charge_control_end_threshold ABSENT on velox's cros_ec hardware → Phase 9's SET 80% lever must gate on the file). TOML +journal_disk_warn_gb/coredump_window_days, new [memory], [power] temp/health/unclean keys; installed both hosts.
*** 2026-07-07 Tue @ 22:35:28 -0500 Built Phase 5 — network posture + services collectors (dotfiles 2e92a9f)
All Phase 5 collectors TDD'd after live-pinning every tool format on both hosts (velox presence sweep over tailscale: full tool set present): nm_state (passive — NM's own connectivity check covers DNS+HTTP, so status never generates traffic), chronyc offset vs [network].ntp_offset_warn_ms, tailscale Health + peer tally, fail2ban per-jail bans, listeners digest via priv ss (expected-entry curation tallied-not-counted, unexpected+wildcard=crit, v4/v6 dedup, multi-holder sockets match any name), firewall (priv ufw status; fallback requires unit-active AND ufw.conf ENABLED=yes; names "ufw down — N public binds exposed" from the digest), rsyncshot freshness (1MB log-tail parse; hourly graded only where the log shows hourly runs — velox is daily-only; daily ≥48h/missing = crit), docker df JSONL + stopped containers w/ [curation.expected_containers], libvirt roster (queried only when libvirtd already active — a read must not socket-activate), cron drift (root rsyncshot expectations gate on backup-source). priv.py +4 read-only verbs; thresholds.curation_entries() tolerant helper. Review subagent (live-verified): Request Changes — 1 Important (malformed fail2ban counts raise ValueError into the envelope) + ufw-disable unit-state false positive, docker unknown-unit sizes reading 0, unfloatable chrony offset; all fixed red-first, re-review Approve. maint suites 227→297; full make test green (54 suites, unpiped exit 0 — an earlier tail-piped run masked make's exit code, rerun properly). Live both hosts: ratio 45 metrics, velox 49. REAL FINDS: listeners crit on both (ratio postgres/8080 wildcard binds, velox syncthing *:8384/*:22000) — curation material for Craig; backups stale on both since 2026-07-06 (truenas down for repairs, Craig confirmed — clears when it returns). TOML +[network]/hourly-backup/cron keys, installed both hosts.
*** 2026-07-07 Tue @ 23:14:56 -0500 Built Phase 6 — remedy layer, priv verbs, guard, doctor CLI (dotfiles e171c08)
The full remedy layer TDD'd in one pass (no 6a/6b split needed): remedies.py allowlisted table (~30 remedies — exact argv, tiers per the design tables as amended by the dated decisions, re_probe_ids, long flags, RECLAIM SPACE macro resolving steps against the envelope incl. per-config snapper expansion); priv.py +28 write verbs (closed builders, type-validated args, per-verb timeouts; =build()= returns the exact sudo-prefixed argv so dry-run and the GUI arm-press share one string); guard.py pure live-update matcher over new TOML =[updates] guard_patterns= (fnmatch, case-sensitive, pairwise-tested; cold pending-cache is surfaced as a note, never silent); curation.py user-layer writes (mark/unmark/clear + KEEP/MARK KNOWN/EXPECTED wrappers, disable-flags for shipped defaults, self-verifying TOML emitter); doctor.py clean|review + iter_fix streaming the event feed (begin/ok/fail/guard/note; failures red without aborting; re-probed truth after each action); cli =maint fix <id> [--dry-run] [--force]= + =maint doctor clean|review= wall renderer; status envelope now stamps =levers= (always-on for free controls: epp_set, charge_limit, update, topgrade). KILL four guards: pid:name victim, TERM-only verb, session-critical denylist ([curation.session_critical], new TOML table) at preview, comm revalidation + denylist re-check at fire. Live-pinned before coding: coredumpctl HAS NO clean verb (find -mtime +N -delete via priv), log-cleanup at ~/.local/bin/cron/, rsyncshot takes <mode> <keep>, ufw enable needs --force, EPP value enum. Review subagent (live-verified): Request Changes — 1 Blocking (json.dumps astral-char surrogate pairs = invalid TOML: one emoji in a MARK KNOWN example would brick thresholds.load() for the whole console → ensure_ascii=False + write self-verifies by re-parsing before replace) + 1 Important (KeyError from sparse installed TOML escapes the event stream → caught, degrades to a red wall entry) + 2 Minor (cold-cache guard note, comm-vs-denylist re-check); all fixed red-first (6 new tests), re-review Approve. maint suites 297→393 (96 in the new suite); full make test green ×2 (unpiped, exit 0). VERIFIED LIVE on ratio: doctor clean executed the Auto tier for real — paccache -ruk0 freed 223.91 MiB and the re-probe measured the cache drop exactly; the live-update guard trips on ratio's genuinely-pending mesa/vulkan-radeon set. TOML +guard_patterns/+session_critical/+timeline_quarterly installed both hosts.
*** 2026-07-07 Tue @ 23:52:40 -0500 Built Phase 7 — GUI shell (dotfiles d28435e)
The GTK4 window lands as the fourth panel sibling (net.cjennings.maintpanel, humble-object layering: GTK-free PanelModel presenter + pure viewmodel formatters + thin gui.py view). Read-only shell: faceplate (worst lamp, CVE/ATTN badges, MNT·01, close), inert doctor/update keys, updates strip (pending/CVE/AUR/firmware from the strip ids), eight-band selector (lamp + fixable/watch split from the envelope's levers; updates/security categories fold into PACKAGES), metric-row subpanel per band with per-id value formatting (bytes/percent/days/°C/ms/bool). Hydration off-thread; 3s live tier (build_live: cheap mem·pwr reads) + 30s full rebuild, both visibility-gated; MAINT_PANEL_FIXTURE=good|bad renders shipped envelope fixtures (reshaped from the E5 prototype by tests/maint/gen_fixtures.py, deterministic, real remedy ids) and freezes both tiers so the degraded board stays put on a healthy machine. Review subagent: Request Changes — 1 Blocking (build_live returned bare metrics, so the 3s merge stripped the levers the full envelope established and the band splits oscillated between tiers → attach_levers in build_live) + 2 Minor (synthetic error envelope missing worst read as healthy green → panel.error_envelope with worst=unprobed; smoke leaked the panel process on mid-run assertion errors → try/finally reap); all fixed red-first, re-review Approve (+1 hardening: merge into an error envelope is a no-op). maint suites 393→447 (54 in test_panel.py); full make test green (56 suites, unpiped exit 0). AT-SPI smoke PASS on live Hyprland (faceplate, badges, strip, all 8 tiles, band switch, clean close; window parked on a special workspace, never the active one). Screenshots off-workspace via headless output: bad fixture + live board vs the E5 reference. Live board on ratio honest: 32 CVE / 13 ATTN, NETWORK red from the real listeners crit, SERVICES fixable from the truenas-down backup staleness. GOTCHAS: windowrules need hyprctl reload before first launch (panel opens tiled otherwise); pkill -f "maint panel" self-matches the invoking shell — anchor with "maint panel$".
*** 2026-07-08 Wed @ 00:39:23 -0500 Built Phase 8 — GUI levers: digests, armed keys, rotary band, live strip (dotfiles 388521e)
The storage/snapshots/packages subpanels grew their evidence digests (disk hogs, per-drive SMART, device errors, snapper by-type, orphans KEEP/REMOVE, pacnew DELETE/MERGE, CVE advisories), packages got the E5 rotary band selector (ORPHANS/PACNEW/ADVISORIES), and the updates strip went live: state-tiered border, UPDATE/TOPGRADE behind the arm-to-override live-update guard, REBOOT offered when required or after an update lands. Every lever is arm-then-fire — first press shows the exact argv on the new act line (identical to =maint fix --dry-run=, the parity criterion), second press fires through =doctor.iter_fix= with the re-probe landing back on the act line; one key armed at a time, band/deck switch disarms; fixture boards always dry-run and never write curation, which is what lets the AT-SPI smoke exercise the full press-press path safely. Engine support: snapper probe emits =stale_singles= (DELETE STALE's candidates, newest kept), both scrub probes parse running-%, =doctor.pending_updates()= public for the arm-press guard read, fixtures regenerated with deterministic evidence. Review subagent (live-verified): Request Changes — 2 Important (refresh tiers raced a firing remedy: a 30s tick could probe the package DB mid-transaction and land stale data after the fire's own re-probe → tiers pause on =firing= + post-fire rebuild retries past an in-flight tick; digest builders KeyError'd on identity-less evidence rows replayed from cache files → skip-and-degrade, 8 new red-first tests) + 4 Minor (all taken: broadened strip-key except, scrub key offers first idle target instead of vanishing while any runs, busy-refused press no longer consumes the arm, fire-time guard refusal names the tripped set in GUI wording). Re-review: Approve. maint suites 447→516; full =make test= green (exit 0, unpiped); AT-SPI smoke PASS end-to-end on the bad fixture; headless-output screenshots vs E5; live board honest on ratio (real orphans w/ KEEP/REMOVE, 10.5 GB cache CLEAN, arm-press names the genuinely pending mesa set). Velox pulled to 388521e (no restow/TOML/windowrule changes). Scroll-position-on-arm check added to Manual testing.
*** 2026-07-08 Wed @ 01:51:26 -0500 Built Phase 9 — GUI: systemd, logs, mem·pwr, network, services (dotfiles 9a2985f + a3da942)
Shipped in two gated halves. 9a (9a2985f): failed-unit + timer rosters (armed RESTART/RESET per unit; ENABLE only on present-but-inactive timers), logs rotary deck (SIGNAL / KNOWN NOISE / COREDUMPS / KERNEL·HW) with the journal MARK KNOWN lifecycle (arm-then-fire mark bound to identifier + sample, UNMARK, armed CLEAR MARKS, empty-sample withhold), OPEN JOURNAL terminal delegation, row levers VACUUM/CLEAR/RUN. 9b (a3da942): CPU-mode segmented control (current + "default" excluded — priv's closed verb refuses "default"), top-memory guarded KILL (session-critical disabled pre-press, arm refusal behind it, fire-time comm revalidation behind both), battery SET 80% gated on the sysfs knob (velox: unsupported → withheld, verified live), listeners curation (probe emits pid + owning unit from the cgroup v2 line; STOP for system.slice services, KILL otherwise, "?" watch-only), firewall ENABLE / tailscale UP / chrony STEP row levers, services deck (containers MARK EXPECTED + START, docker df, cron roster + backups w/ worst-of chip lamp). RECLAIM SPACE deferred to Phase 10's wall by design — the act line can't honestly render a 7-step stream. Reviews: 9a Request Changes (curation done-callbacks bypassed the busy-retry rebuild → routed through _refresh_after_fire) → Approve; 9b Request Changes (DEFAULT EPP dead control + cgroup hybrid-hierarchy, "?"-curation, cron chip lamp, unprobed ✓ minors — all fixed red-first) → Approve. maint 516→612 tests; make test 58 suites exit 0; AT-SPI smoke PASS across all 8 bands; headless screenshots vs E5; live envelopes verified both hosts.
**** 2026-07-08 Wed @ 01:13:48 -0500 Built 9a — systemd + logs subpanels (dotfiles 9a2985f)
Failed-unit roster (armed RESTART/RESET per unit, since/exit context, journalctl hint) + expected-timer roster (ENABLE only on present-but-inactive timers); logs rotary deck (SIGNAL / KNOWN NOISE / COREDUMPS / KERNEL·HW) with the journal MARK KNOWN lifecycle (arm-then-fire mark bound to identifier + sample, UNMARK, armed CLEAR MARKS, empty-sample withhold), OPEN JOURNAL terminal delegation, and row levers VACUUM / CLEAR / RUN. Review subagent (live-verified): Request Changes — 1 Important (curation done-callbacks called refresh_full, which silently no-ops while a tick refresh holds busy → a mid-tick write left a pre-write envelope on the board up to 30s while the act line claimed success; routed all four incl. Phase 8's keep through _refresh_after_fire) + minors taken (single _mark_token definition, three test gaps closed, docstring truthfulness, stale arm wording cleared on band/deck switch — my own find during screenshot eyeballing). Re-review: Approve. maint 516→562 tests; make test 58 suites exit 0 unpiped; AT-SPI smoke PASS incl. the new rosters/deck/mark path; headless screenshots vs E5. velox pulled (no restow). Accepted gap: KNOWN NOISE subsec lists the live curation table on a fixture board — fixture-sourced entries list is later polish.
*** 2026-07-08 Wed @ 02:36:54 -0500 Built Phase 10 — results wall + doctor keys (dotfiles cc2fb5d)
Results wall shipped as the session action log: doctor events stream per-event (begin opens a running entry with the exact argv, ok/fail resolves in place, re-probe notes follow), 3.5-visible-row scroll cap with autoscroll, HIDE/COPY (COPY emits CLI-wall text), curation writes logged (fixture-suppressed as dim notes). CLEAN UP streams the auto tier with no arm press; REVIEW & FIX swaps the subpanel to the confirm-tier roster (item-less remedies get armed FIX keys, per-item ones point at their subpanel; review events carry item + worst severity). RECLAIM SPACE macro landed on the disk-usage row (deferred from 9b); macro arm line shows first step + remaining count; zero-step macros refuse by name. Review (live-verified): Request Changes — 1 Important: the roster's FIX for update/topgrade could never override the live-update guard (fire-time re-arm used the bare rid while the FIX key arms on "rid:", and _on_lever never passed force — an infinite arm→guard loop); fixed by threading the pressed token through _fire and sharing the strip's guard arming (_guard_arm_line + panel.guarded). Minors all taken: wall_abort resolves stranded running entries red when a stream dies, empty-macro IndexError → named RemedyError, test predicate tightened. Re-review: Approve, no residuals. maint 612→645; make test 59 suites exit 0 ×2; AT-SPI smoke extended (wall, HIDE/SHOW, CLEAN UP stream, review roster, 14-line RECLAIM stream) PASS; screenshots vs E5; LIVE verify on ratio: real CLEAN UP on the live board streamed "3 done" with real re-probes (coredumps warn 5, disk_usage ok 60). Velox pulled cc2fb5d.
*** 2026-07-08 Wed @ 03:24:44 -0500 Built Phase 11 — waybar glyph + timers (dotfiles 10033be)
custom/maint replaced custom/sysmon: waybar-maint renders from the state file maint-scan.timer writes every 30 min; color tracks the worst diagnostic (lever-less) metric only, actionable findings never color the bar; missing/stale scan data dims to an honest unprobed state naming the timer. Battery hosts show live battery % + charging icon from sysfs on waybar's interval; low charge forces red. Left-click toggles the console (new maint-panel wrapper); right-click keeps the btop scratchpad. Bare =maint scan= became the glyph scan (envelope → state → RTMIN+12); maint-net-scan.timer drives =--net --slow= hourly. Retired same commit: waybar-sysmon, sysmon-cycle, both suites, #custom-sysmon CSS ×3 — plus unnamed collateral the grep caught: waybar-collapse's collapsed base set (maint glyph stays on battery hosts — it IS the battery display now) and a stale dotfiles todo. Review (live-verified): Approve with 4 Minor, all taken red-first — scope!=Device gate so a wireless mouse's battery can't masquerade as the machine's (shared capability.device_scope), unprobed diagnostics named in the tooltip instead of "all clear" under a dimmed glyph, comment truth, dead state field dropped; re-confirm clean (677 maint tests). Full make test exit 0; smoke PASS (first run's pipe masked a transient AT-SPI race — rerun unpiped clean). LIVE both hosts: ratio timer fired for real (glyph amber from genuine diagnostics: firmware update + journal errors; 11 actionable NOT coloring), velox pulled + one-time stow + battery 100% text w/ warning class. velox's user systemd manager found wedged (pre-existing) — timers enabled via manual symlinks + state seeded; Craig rebooting to clear.
*** 2026-07-08 Wed @ 04:15:45 -0500 Built Phase 11b — prototype fidelity pass (dotfiles 3ee22a8)
Rebuilt the GUI presentation to the E5 instrument-card idiom. Each band renders a 4-column card grid: big-number readouts (viewmodel.card_spec registry — one builder per bespoke metric id, count-card fallback for the rest), cairo progress bars w/ threshold ticks (cache scaled to 1.5× the warn line), radial gauges (scrub cadence, keyring/topgrade age, temperatures — sweep scaled to the metric's own threshold), status chips (PASSED/FAILING/INACTIVE/YES; firewall chips the state word, exposure detail moves to the caption), and the CPU-mode segmented control promoted from digest section to card (current mode lit, alternatives as joined armed keys — same DIGEST_ONLY epp_set arm-then-fire). Evidence digests stay full-width below the grid. Selector became a two-row 4-col grid of wide tiles w/ count chips (3! · 7✓, crit reddens) + severity left borders; subpanel header carries the attention/ok/fixable/watch summary right-aligned beside a gold band title. The scoped NVMe wear dial was skipped honestly — no standalone wear metric exists (wear rides SMART evidence rows). Presentation only: engine/remedies/doctor/curation and the lever contracts unchanged. New 42-test suite (card kinds, threshold scales, running-scrub override, unprobed degrade incl. tick drop, seg exclusions, chips/subhead/band_counts, both-fixture sweep). Review (live-verified): Approve w/ 1 Important (pre-existing: chrony "not running" string crashed format_value's ms branch — fixed w/ card guard) + 4 Minor all taken red-first (disk None guard, _pct negative clamp, unprobed tick, dead row_detail/split_label/.maint-val removed); re-review Approve. make test 61 suites exit 0 ×2 unpiped; AT-SPI smoke 92 checks PASS (new gauge/chip/subhead assertions — DrawingAreas carry accessible_role=IMG + names so the smoke can see them); all 8 bands + review roster screenshotted on both fixtures + the live board against the settled headless-Chrome prototype render. Velox pulled (no restow). Gotcha pinned: a card body's vexpand propagates up a Gtk.Grid — a one-card band ballooned until grid.set_vexpand(False).
*** 2026-07-08 Wed @ 05:05:20 -0500 Ran the granted /refactor sweep over the maint package (dotfiles a178470..73e9d94)
Four parallel read-only scans (complexity, duplication, dead-code, simplification) converged on the same hotspots; ten behavior-preserving commits applied and pushed. Headlines: six curation handlers folded into one _curation helper (~150 lines of repeated fixture/done plumbing, act/wall strings byte-identical), _on_lever/_on_strip_key merged into _press_lever, _on_fired split (_rearm_after_guard + _fire_summary), the "tool: not found / exit N" wording single-sited in cmd.why() across 20 probe sites, exact clones deduped (packages curation_entries, _parse_asctime, double VERSION), ten uniform digest sections folded into _evidence_digest, _card_body split per kind, listener key rule named, plus a small-simplifications batch. Deliberately skipped: format_value's flat chain (readable as-is), _journal_lines generalization (rc-semantics risk), test-panel-maint Makefile target (Phase 12 scope). Verification: suite parity exact pre/post (59 suites / 2490 tests, exit 0 unpiped), AT-SPI smoke PASS end-to-end, live ratio envelope 45 metrics zero unprobed, review subagent walked the full diff against 3ee22a8 and approved (no behavior change; accessible names untouched; its one latent-robustness minor pinned as an invariant comment). gui.py 1680→1605; net −103 lines. Velox pulled.
*** 2026-07-08 Wed @ 05:46:02 -0500 Built Phase 12 — VM remedy scenarios + AT-SPI target (archsetup d6993d3, dotfiles 0636554)
Scenario orchestration landed as =run-maint-scenarios.sh= over the existing vm-utils.sh snapshot primitives: nine break → =maint fix= → assert scenarios in three groups (logs, packages, systemd), batched per Decision 11 (one boot per group, maint-ready snapshot restored between groups, clean-install restored at the end). =run-maint-nspawn.sh= is the fast lane — the packages group against a cached pacstrap rootfs in seconds. Plan layer is pure (=--list=, no KVM) with a 19-test unit suite; =make test-maint= wires it in. Dotfiles side: panel smoke now runs GOOD + BAD fixture passes (healthy render, badge tallies from the fixture, no-REBOOT, leak check) and =make test-panel-maint= is enumerated. Verified: full VM run 9/9 green from pristine snapshot ×2 (test-results/maint-20260708-054029), nspawn 4/4, smoke 100 checks exit 0, unit suite green under both FS_PROFILE values. Review subagent: Request Changes (FS_PROFILE env leak in the unit suite, journal-vacuum assert vacuous both directions) — all six findings fixed red-first, re-review Approve. FS_PROFILE=zfs scenarios: filtered but unexercised — the zfs base image fails to build (see the zfs DKMS task).
*** 2026-07-08 Wed @ 06:18:58 -0500 Built Phase 13 — install wiring, workflow move, docs (archsetup bef7053 + 18c081f, dotfiles 9a3f0c7)
Installer: install_maintenance_config in user_customizations installs the shipped TOML to ~/.config/archsetup/ (re-run refreshes it, ~/.config/maint/ user layer never touched) and enables maint-scan/maint-net-scan timers via wants-symlinks (hyprland-only — the units live in that stow tier; no session bus during install, syncthing idiom); orchestrator + dispatch-branch tests pin the wiring. Deps swept from the probes' argv: expac, lm_sensors, fwupd added (arch-audit was already in from Phase 3; the rest ride base/archinstall or existing sections). maint/README.md in dotfiles covers user (CLI, panel, glyph, config layers, guard, privilege) + developer (module map, env seams, four test layers, add-a-metric recipe). system-health-check.org moved home → archsetup docs/workflows/ and rewired: TOML-authoritative severity section, threshold lines cite TOML keys (TOML wins on disagreement), inventory paths → docs/homelab-inventory/, maint-CLI fast-path note; only the four #+HOSTNAME: host inventories moved (+ the truenas specs asset they link) — the personal gear records stay in home per the domain split; discoverability via .ai/project-workflows symlink + notes.org entry; handoff note in home's inbox (2026-07-08-0607) lists home-side removals and flags strix-soak-watch as a candidate to move later. Verified: fresh-shell =maint status= green from installed paths on ratio; make test-unit exit 0; dotfiles make test exit 0; shellcheck count unchanged; review subagent Approve (2 Minor doc nits, both taken).
*** 2026-07-08 Wed @ 06:18:58 -0500 Flipped the spec to IMPLEMENTED
Spec keyword DOING → IMPLEMENTED with a dated history line naming the closing commits (phases 1-13, dotfiles 43a39ac..9a3f0c7, archsetup d6993d3/bef7053/18c081f) and the Status mirror updated. Residuals tracked as their own tasks: manual-testing checklist, zfs base-image DKMS bug [#C], vNext [#D].
** DONE [#B] Signal and known-noise counts disagree with the journal :bug:maint:solo:
CLOSED: [2026-07-09 Thu]
Not a counting bug — a semantics bug (dotfiles eded0c0). journal_errors grouped by identifier and summed lines, but displayed one sample message, and logs.py overwrote that sample on every iteration so it named the *last* error rather than the counted one. Live on ratio: systemd had 10 error lines this boot — 6 "Failed to start Emacs text editor." and 4 org-roam auto-sync — which is exactly the 10-vs-6. Now grouped by identifier + normalized message (pids, hex addresses, uuids, long integer runs blanked), so a row's count is that message's count, and each row's hint carries a =--grep= reproducing it (verified: 6 and 4). MARK KNOWN binds the volatile-free literal instead of the pid-bearing sample, so marking suppresses exactly what the row counted. The KNOWN NOISE headline counts matched lines while the deck lists patterns; the caption now names both units.
Logs > SIGNAL reports 10 journal errors and the signal item claims 10, but the group holds one item and the open journal shows it 6 times. KNOWN NOISE reports 19 against 12 visible entries. The counts are the point of the band, so a wrong number undermines the whole diagnostic. Find where the tally diverges from the rendered set — likely counting matched journal lines rather than distinct grouped entries, and counting curated entries against a different window than the one displayed.
** DONE [#B] Persist the CPU pill setting across reboot :feature:maint:
CLOSED: [2026-07-09 Thu]
The kernel resets energy_performance_preference to the driver default every boot. A successful epp_set now records the preference in the maint user config; maint-epp-restore.service replays it at login. No-op when nothing is remembered, the host lacks EPP, or the mode already matches. A hand-edited bogus value is refused by priv's closed verb. Installer enables the unit via the existing wants-symlink idiom. Verified live on ratio: unit enabled, ran clean, EPP untouched. (dotfiles a7f34bd, archsetup 430ef1a)
The power/perf/balance pill sets EPP live, but the value reverts to its original setting after a reboot. Add a way to make a selection permanent. Needs a call on where persistence lives — a systemd unit, a udev rule, or a config file the maint package owns.
** DONE [#B] Preview the pacman + AUR update queue before upgrade :feature:maint:solo:
CLOSED: [2026-07-09 Thu]
A QUEUE key on the updates strip streams the pending pacman + AUR set onto the results wall, each package with its version move; `maint queue` prints the same in a terminal. The cap reports how many it dropped rather than truncating silently, and a cold cache says it has no pending set instead of rendering "nothing pending". Verified live against ratio's 105 pending packages. (dotfiles 8837a47)
Show which pacman and AUR packages are queued before UPDATE or topgrade runs, so the armed live-update guard isn't the first place the package set becomes visible.
** DONE [#B] Audio PTT state desync across panel, waybar icon, and actual state :bug:audio:solo:
CLOSED: [2026-07-09 Thu]
Two state stores and two copies of the logic. The panel flipped an in-memory ptt_armed flag with its own enter/exit rules; the CLI toggle and the bar tag read a runtime state file. The file is now the only state and ptt.toggle_plan the only decision. The panel adopts the persisted state on every refresh, routes its toggle through the shared plan, and persists before signalling waybar (writing after would race the bar into re-rendering the pre-toggle class). Tests drive both entry points against one state across armed/disarmed x muted/live. (dotfiles 9524a44)
The audio panel's push-to-talk, the waybar PTT icon, and the real PTT state disagree. All three must agree at all times. Cover every state transition with tests, not just the happy path — this is the second PTT bug after the pre-talk mute restore landed 2026-07-05 (dotfiles 1443b9e).
** DONE [#B] Publish the homelab inventory into the agent knowledge base :chore:docs:
CLOSED: [2026-07-09 Thu]
The four host inventories, the TrueNAS specs asset, and the ratio USB/xHCI record now live as roam nodes in =~/org/roam/hardware/=, linked from the "Homelab Hardware Inventory" index node. =docs/homelab-inventory/= is gone; roam is canonical.
Carried rather than pointed, per home's argument, which beat mine. Pointing keeps one canonical copy but breaks the write path: a third project that discovers a durable hardware fact would have to write into archsetup's repo to record it, which the cross-project rules forbid, so the fact would sit in an inbox instead of on the device's page. Moving answers the canonical-copy worry without that cost.
The =* Automated Capabilities= drawer moved with the pages. =system-health-check.org= now resolves a host's node by its =#+HOSTNAME:= keyword under =${ROAM_DIR:-$HOME/org/roam}/hardware/= — never by filename, since the timestamp prefix isn't stable. Verified: all four hosts resolve, an unknown host degrades to "NO INVENTORY FILE", and ratio's capability drawer still parses. A host without the roam clone (mybitch, truenas) takes the same no-inventory path it always did.
The strix kernel watch stayed at =docs/workflows/strix-soak-watch.org= — it's a workflow, not an inventory page.
** DONE [#C] Optional ticking sound for the pomodoro :feature:timer:
CLOSED: [2026-07-09 Thu]
WTIMER_TICK=1 plays a quiet tick each second through a pomodoro's work phase; off by default, silent through rest (a tick there would undo the break), while paused, and while awaiting a manual advance. Rides the bar's existing once-a-second `wtimer render` call. Playback is fire-and-forget so it can never stall or break the bar. Sound is an 18ms 1.4kHz sine at a tenth of full scale (notify/tick.ogg); WTIMER_TICK_SOUND overrides. Craig still has to judge whether the tick sounds right. (dotfiles 886dafd)
Timers module. Off by default; needs a sound choice and a volume decision.
** DONE [#C] Refresh indicator for the top-like items :feature:maint:
CLOSED: [2026-07-09 Thu]
A hairline under the top-memory section drains toward the next live refresh, so a slow board reads differently from a frozen one. Withheld on a fixture board, which never live-ticks — a countdown that never counts is worse than none. The fraction is a pure viewmodel function; only the cairo stroke lives in the GUI. (dotfiles 5d384d9)
Show when the process/memory rows are about to refresh, so a stale-looking board is distinguishable from a frozen one. Shape is a design call — a countdown, a pulse, or a progress hairline.
** DONE [#C] CPU pill should not reposition on selection :bug:maint:quick:solo:
CLOSED: [2026-07-09 Thu]
epp_rows dropped the current preference from the key list, so the remaining segments slid sideways on every selection. Every preference now renders in a fixed order with the current one lit and unpressable in place, at full contrast rather than GTK's disabled dimming. The separate lit label is gone — the active segment is the lit one. Three separate tests encoded the old contract (unit, AT-SPI smoke, phase11b card); all moved with the behavior. The smoke caught a real gap the unit tests missed: the EPP row renders through the card path, which ignored the disabled flag. (dotfiles 5d384d9)
The perf/balance/power pill moves position depending on which option is selected. The segmented control should hold a fixed layout and mark the active segment in place.
** DONE [#C] Session identifier needs more width :bug:waybar:quick:
CLOSED: [2026-07-09 Thu]
Not waybar — the tmux status line. status-left-length was never set, so tmux clipped the drawn status-left to its default of 10 columns: "[aiv-archsetup] " came out "[aiv-archs", losing the closing bracket and the separating space, which is why the window list ran into it. Set to 40. Applied to the live tmux server too. (dotfiles dc3c27f)
The session section truncates; give it more characters, and keep the window sections that follow from running into it. Screenshot: [[file:/home/cjennings/pictures/screenshots/2026-07-07_145236.png][2026-07-07_145236.png]]
** DONE [#C] minimal tier zsh login PATH gap :bug:dotfiles:quick:solo:
CLOSED: [2026-07-09 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-09
:END:
Fixed (dotfiles =c6a7878=). =minimal/.zprofile= added, mirroring =common/='s PATH-only prepend and its rationale comment.
Tested the invariant rather than the file, because the bug *was* a per-file fix missing a tier: any stow tier shipping a =.zshrc= must ship a =.zprofile= that prepends =~/.local/bin=. Red first, and it named =minimal=. Verified behaviorally as the task asked — stowed the tier into a throwaway HOME and read a real =zsh -lc 'echo $PATH'=, which now leads with =~/.local/bin=. Full =make test= green.
The 2026-07-08 fix gave common/ a .zprofile (zsh logins never read .profile, so ~/.local/bin was missing from TTY/ssh PATH — dotfiles 39cef40). The minimal/ tier has the same gap: it carries .zshrc and .profile but no .zprofile, so headless installs (DESKTOP_ENV=none stows minimal/ instead of common/) still get zsh logins without ~/.local/bin. Add a matching minimal/.zprofile (PATH-only prepend, same rationale comment). Verify: fresh minimal stow, zsh -lc 'echo $PATH' shows ~/.local/bin first. The installer itself is clean — it writes no shell profiles; the stow layer owns this.
** DONE [#B] Doctor button for the audio panel :feature:audio:
CLOSED: [2026-07-10 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-09
:END:
What happens when pipewire or pulseaudio is broken — would we know? Spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] — *READY*, all eight decisions closed 2026-07-09, Phase 0 shipped. Buildable: Phase 1 diag, Phase 2 classify, Phase 3 repair + doctor, Phase 4 the DOCTOR key and wall. Grounded in a live survey of ratio's stack: there is no PulseAudio (pipewire + wireplumber + pipewire-pulse, all user-scope, so no sudo anywhere), =pactl= hangs against a server that accepts and never answers, and the panel can't diagnose itself because =pactl= is the layer most likely to be down. Mirrors the net panel's diag/classify/repair/doctor split, not the maint console's priv-verb table.
Re-graded =[#C]= → =[#B]= by the 2026-07-09 audit: the spec is written, Phase 0 shipped, and six decisions are all that stand between this and a build. Active backlog, not parking lot.
*** 2026-07-09 Thu @ 14:12:00 -0500 Fixed: waybar's audio module died against a half-wedged server
My first read of this was wrong. =pactl.run()= already carries =timeout=8= and raises =PactlTimeout=, so the panel degrades and does not freeze. Correcting the record rather than leaving the claim standing.
The real bug, found by driving it: =build_status()= guarded its two device-list reads but called =default_sink()= and =default_source()= outside the guard. A server that answered the lists and then stopped answering raised =PactlTimeout= straight out of a function whose whole contract is to degrade to =ok: False=. =waybar-audio= calls it with nothing catching it, so the bar's audio module died instead of dimming; the GTK panel survived only because its background worker catches everything.
A server that hangs on the *first* call never exposes this — =list_sinks= raises inside the guard and masks the unguarded reads behind it — so the obvious regression test passes against broken code. =fake-pactl= grew =FAKE_PACTL_SLEEP_DEFAULTS= to express "answers the lists, hangs on the defaults". Verified end to end: =waybar-audio= now emits =class: degraded= / "Audio graph unavailable" and exits 0. dotfiles =4d42eb3=; full =make test= green.
*** 2026-07-10 Fri @ 06:41:00 -0500 Shipped: the audio doctor, phases 1 through 4
=audio diag=, =audio doctor=, =audio doctor --fix=, and the panel's DOCTOR section header with DIAGNOSE / FIX keys streaming into a results wall (copy + close). Spec is IMPLEMENTED. dotfiles =01a1d80=, =76857e3=, =7223c51=, =1550ac8=, =4320255=, =bd33440=.
Live verification on ratio replaced the planned VM run for remedies 1, 4 and 5, and found two bugs no fake could surface: =pactl get-default-sink= prints the literal =@DEFAULT_SINK@= placeholder when no default is set, and remedy 1 re-probed before WirePlumber had re-created the devices, calling a repaired stack broken. Both fixed.
Left open, not a v1 gap: mpv played silently while the doctor read healthy. The stack was genuinely fine, and per-application stream routing is an explicit spec Non-Goal. Worth a task only if it recurs.
Second sighting, 2026-07-10: Chrome stopped recognizing the microphone while the stack was healthy (Shure MV7+ default, unmuted, 82%, PTT off). Restarting Chrome fixed it; nothing on the machine was touched. Both sightings share a shape the doctor cannot see: the graph is fine and one client cannot use it. A third sighting turns this into a design question, namely whether the doctor should say "the stack is fine, the fault is in the application" rather than a bare healthy.
** DONE [#C] Net panel: Enterprise error never dismisses :bug:dotfiles:network:
CLOSED: [2026-07-12 Sun]
Fixed in dotfiles =a157bed=. Root cause: error toasts are sticky by design (so background refreshes can't wipe an unread error), but the enterprise join hint's flow posts no follow-up status and row clicks post none either, so nothing ever replaced it. Fix: a window-wide capture-phase click gesture dismisses a sticky toast on the user's next interaction; policy in =viewmodel.toast_action_plan= (unit-tested), timed toasts and background clears unchanged. Panel smoke run confirms launch/doctor/close with the gesture installed. Pointer-level dismiss is a manual-testing child (AT-SPI can't drive pointer gestures). Repro screenshot: =~/pictures/screenshots/2026-07-10_195911.png=.
** DONE [#C] Net diagnostics leak connection names + SSIDs into copyable report and --json :bug:dotfiles:network:solo:
CLOSED: [2026-07-12 Sun]
Resolved in dotfiles =df1543a=: the =redact_ssid= toggle now scrubs saved profile names, active SSIDs, envelope-carried names, and =.nmconnection= keyfile basenames from the copyable report and the diag/doctor =--json= envelopes (one systemic pass in =redact.py=; MAC/IP scrub applies to those envelopes too). On-screen output and functional envelopes (status/list) unchanged. 15 new tests; live-verified on ratio (toggle on removes the active connection name from =diagnose --json=, default unchanged).
The net doctor's copyable report (=report.py=, =scrub_text=) scrubs only MAC/IP, and =net diag/doctor --json= (=cli.py=) dumps the raw dict with no redaction. SSID redaction lives only in the event log (=redact_event=, gated on =redact_ssid=, default off). So a connection name (usually the SSID) appears in the clear in the link-step evidence and in every =--json= consumer — the copyable report is exactly the text a user pastes into a bug report. Secrets (PSK/password/token/portal URL) are already stripped, so this is names, not credentials: Minor severity, graded on severity alone per the privacy carve-out.
Split out of the 2026-07-11 net-doctor-expansion spec review: that spec's new rival-manager/keyfile-perms verdicts keep parity with this pre-existing behavior rather than half-solve it. Fix shape: extend redaction to cover the connection name + keyfile basename across the copyable report and =--json= (one systemic pass, not per-verdict), with a redaction test. Engine-wide, so it wants one coherent change rather than being bolted onto the expansion work.
** DONE [#B] Bt doctor expansion v1 — build the READY spec :feature:dotfiles:bluetooth:
CLOSED: [2026-07-12 Sun]
:PROPERTIES:
:SPEC_ID: 3d4d61c4-e5df-44e9-b8e0-40b31452c3f7
:END:
Build the [[file:docs/specs/2026-07-11-bt-doctor-expansion-spec.org][bt doctor expansion]] (IMPLEMENTED). Adds a dmesg firmware-hint probe (names the missing blob on a no-adapter fault) and a boot-enablement probe (catches an adapter disabled at boot) to the shipped bt doctor (=~/.dotfiles/bluetooth/=). Archsetup owns the dotfiles work end to end. All phases shipped and fake-verified (d19fdca, f05a9b4, d7d859f); the live reboot-persistence half is on the manual-testing checklist.
*** 2026-07-11 Sat @ 03:06:32 -0500 Built the two read-only probes
New module =bluetooth/src/bt/probes.py= plus =doctor.py= wiring, on dotfiles main (=d19fdca=, pushed). Two reads the diagnose chain never did: =firmware_hint()= scans the current boot's kernel log for per-vendor firmware-load failures (Intel ibt-*.sfi, MediaTek BT_RAM_CODE, Realtek rtl_bt, Broadcom .hcd, Qualcomm QCA), returning the named blob via a bounded =cmd.run(journalctl -k)= that reuses the =doctor.py:84= precedent; =boot_enablement()= reads three boot-persistence signals (bluez AutoEnable from main.conf [Policy], =systemctl is-enabled bluetooth=, whether TLP lists bluetooth in =DEVICES_TO_DISABLE_ON_STARTUP=). =diagnose()= gates the firmware read to the no-adapter branch and the boot read to the soft-blocked/powered-off branch, so a healthy run reads neither; the raw signals ride a new =probes= key that =doctor()= carries into =--json=. Detection only: no verdict, formatter, or repair change (that's Phase 1). Every read degrades to None on an unreadable tool/file, so a probe that can't see never invents a fault. AutoEnable absent/unset reads None, not false, matching bluez's compiled default of true, so only an explicit =AutoEnable=false= is the fault. New env roots for tests (=BT_MAIN_CONF=, =BT_TLP_CONF=, defaulting to absent temp paths in the Sandbox base so no test reads real /etc); =fake-journalctl= branches on =-k=, =fake-systemctl= answers =is-enabled bluetooth=. 125 bt tests, full =make test= green; =/review-code= approved (no Critical/Important; one Minor noting the firmware read also covers the btctl-unavailable branch, harmless). Inbox note sent to dotfiles.
*** 2026-07-11 Sat @ 03:16:59 -0500 Built the firmware-hint Guide verdict
On dotfiles main (=f05a9b4=, pushed). The no-adapter step now names the blob: a new =_no_adapter_step= consults =probes.firmware_hint()= on a genuine no-adapter fault and, on a per-vendor signature match, sets =evidence= to "no Bluetooth adapter found — <Vendor> firmware <blob> failed to load" and =next_action= to "update linux-firmware and reboot", tagged with a new =code="no-adapter-firmware"= so a =--json= consumer can branch without string-matching. A clean log keeps the generic hardware/driver verdict. A Guide, not a repair: the step carries no =repair= action, so =--fix= never touches it, and it needs no privilege model (so Phase 1 lands independently of the shared cross-panel model). A missing =bluetoothctl= (=BtctlError=) short-circuits before the firmware read, so the verdict fires only on a real no-adapter fault, not a broken install — this also tightened Phase 0 (which read the log on both None branches) to the genuine no-adapter case. =_mk= gained a uniform =code= key (default None) added to every diagnose step, mirroring the existing =repair= key; no test asserts an exact step key-set, verified. =format_doctor_human= already renders =evidence=/=next_action=, so no formatter change. 133 bt tests (+8), full =make test= green; =/review-code= clean. Inbox note sent to dotfiles.
*** 2026-07-11 Sat @ 06:48:21 -0500 Built the persistent-power verdict + fix
On dotfiles main (=d7d859f=, pushed). =_powered_step= consumes the bt Phase 0 boot-enablement probe: AutoEnable explicitly false, service disabled at boot, or TLP listing bluetooth → =powered-off-persistent= (code + evidence naming the cause) carrying the =persist-power= repair; absent/unset config reads as auto-enable-on (bluez default) → the plain =power-on=, so healthy machines can't false-positive. The =persist-power= repair fixes only the causes set (AutoEnable=true, =systemctl enable bluetooth=, drop bluetooth from the TLP list), powers the adapter on now, and verifies each cause cleared. Config edits are pure idempotent text transforms in a new =bootconf= module (comments preserved), staged and installed via a fixed-destination =cp= verb so the write needs root but the mutation is unit-testable. =priv.py= gained three narrow verbs (=enable-bluetooth=, =write-main-conf=, =write-tlp-conf=). The repair is Privileged and resolves through =panelkit= before running: can't-elevate degrades to the guide. The bt shim gained =panelkit= on its path. 287 bt tests + 65 make-test suites green, review-code Approve, voice. Inbox note sent. Live half (real reboot persistence) is the VM/manual checklist.
*** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the bt spec to IMPLEMENTED and logged the vNext items
Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (stale-bond signature, connection-parameter hints, bt-audio-profile expansion) logged as the "Bt doctor vNext" task. The live half (real reboot persistence) remains on the manual-testing checklist — findings there come back as bugs.
** DONE [#B] One copy + close control pair on every output wall :feature:dotfiles:solo:
CLOSED: [2026-07-12 Sun]
Resolved in dotfiles =dccd744=: every wall carries the o-copy/o-clear overlay pair. bluetooth gained the copy key (transcript via the new =viewmodel.step_copy_line=, CLI-shaped); maint traded its header COPY key for the overlay pair, kept HIDE, and its ✕ clears the session log via =PanelModel.wall_clear=; the four hand-rolled wl-copy calls collapsed into =panelkit.clipboard.copy_text= (PANELKIT_WLCOPY test seam), moving maint off the GTK clipboard so its copies survive the panel closing too. 15 new tests (8 clipboard, 4 step_copy_line, 3 wall_clear); full suite 66 green; all four panel smokes run live off-workspace — maint + audio fully pass, net + bt fail only the pre-existing state-word startup race.
Converge all four instrument-console output walls on the net panel's well controls: a copy glyph and a ✕ close, as an overlay at the top right, hidden until content lands. Craig's call, 2026-07-10, while reviewing the audio doctor's wall: "network panels as standard across all others, make it consistent."
Where they stand today, no two alike. net has copy + ✕ (=net/src/net/gui.py=, the =o-copy= / =o-clear= overlay). bluetooth has ✕ but no copy. maint has COPY + HIDE as keys in a header row, and no ✕. audio just gained copy + ✕ (dotfiles =bd33440=).
Work: give bluetooth a copy key, give maint the overlay pair, and lift the four hand-rolled =_copy_output= implementations into one shared helper rather than a fifth copy. maint keeps HIDE alongside close, because its wall is a persistent session action log you collapse and keep, where net's, bt's, and audio's are per-run results you dismiss.
Copy text is per panel but one rule: it pastes as that panel's CLI prints, so the paste lines up with the terminal a user is already looking at. audio's =viewmodel.wall_copy_text()= is the worked example.
Consistent with the 2026-07-07 scope note on the sibling task above: net's compact glyph overlay is what standardizes, not maint's wide COPY-key header row.
** DONE [#C] Org-capture float popup grows too large :bug:hyprland:quick:solo:
CLOSED: [2026-07-14 Tue]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-13
:END:
Craig answered the pre-flight (2026-07-14): cap at 120 wide, height proportional. Applied as 120 Emacs columns (11 px/col measured from the live daemon) = 1320 px wide, height 653 px from the old rule's aspect. Ratio's size rule shrank from the 1892x936 scratchpad match and both hosts gained a max_size growth cap (the field is max_size — bare "maxsize" is invalid and hyprctl reload won't say so; check hyprctl configerrors). Verified live: config clean, a probe window floats at exactly 1320x653. Dotfiles 9c4dc2f.
** DONE [#C] Panel smoke: faceplate state-word assertion fails on the live compositor :bug:dotfiles:test:
CLOSED: [2026-07-14 Tue]
Diagnosed and fixed within the session: not a race — test drift. Dotfiles b581d5d (2026-07-05) made the faceplate word the static subsystem identity (NETWORKING / BLUETOOTH / AUDIO) and updated the audio smoke, but the net and bt smokes kept asserting the retired live-state words and had failed on every run since. Both now assert the identity word like audio's (dotfiles 32cd99f); both smokes run RESULT: OK end to end, which also green-gates the doctor-streaming change.
** DONE [#C] Realtime lamp output for the net + bt doctors :feature:solo:
CLOSED: [2026-07-14 Tue]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-09
:END:
Shipped in dotfiles 0318a91. Both doctors stream: diagnose() emits each step as it completes (bt streams the first diagnosis only — the fix loop's re-diagnoses would replay the chain), and a repair's row goes up amber at attempt start and settles green/red with narration + evidence at completion, so the lamp blinks for the repair's real duration. The 3.5-entry height cap turned out to already be in both wells (it landed with the doctor expansions), so only the streaming half needed building. 5 new tests across net + bt; both suites green; AT-SPI smokes at parity with HEAD (one pre-existing state-word failure, filed separately).
Retrofit the net doctor (=~/.dotfiles/net/src/net/doctor.py=) and bluetooth doctor (=~/.dotfiles/bluetooth/src/bt/doctor.py=) to stream results as a live output wall — one lamp per escalation step, amber while running, green on success, red on failure — instead of a final summary. Matches the maintenance-console doctor design (see [[file:docs/design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]], "Doctor = live output wall"). Goal: every doctor in the system reads the same way. Both doctors already step through an escalation chain re-probing after each, so the steps are natural lamp boundaries.
Scope note (Craig, 2026-07-07): realtime lamp *behavior* only. The maintenance console's wider results-wall layout (date+time stamp column, COPY, persistent history) does NOT backport — the net/bt panels are ~400px wide and lack the horizontal real estate. Their existing output wells keep their compact layout; this task just makes them stream live.
Addendum (Craig, 2026-07-07): DO backport the 3.5-entry height convention — every panel's output well caps at 3.5 visible entries, the half-visible entry being the scroll cue, with the dark slate-on-black scrollbar. Layout stays compact per above; only the height cap + scroll affordance carries over.
** DONE [#B] Absorb the clock-panel project into the dotfiles :feature:waybar:dotfiles:
CLOSED: [2026-07-18 Sat]
Absorbed into =~/.dotfiles= (commit 3fab11d): package =clock/src/clock/= (renamed from clock_panel), the six PNG watchface layers packaged inside the module at =clock/src/clock/assets/=, a stowed =clock-panel= shell shim (LD_PRELOADs gtk4-layer-shell), waybar left-click now =clock-panel toggle= with the absolute path dropped, tests converted pytest→unittest into =tests/clock/= plus an asset-load guard. Kept the layer-shell overlay and the socket toggle. The standalone repo is archived (ARCHIVED.md), kept for its design history. Verified live: the bar click renders the polished watchface.
** DONE [#A] Velox boot recovery — no kernel in BE :bug:velox:zfs:
CLOSED: [2026-07-19 Sun]
Recovered. Velox boots linux-lts 6.18.38 and is back on the tailnet (up 1d+, /boot holds initramfs-linux-lts.img). The pre-pacman ZFS snapshot rollback restored the kernel from the ZBM recovery shell.
Velox won't boot: ZBM prompts for the passphrase, unlocks, then reports no bootable environment with a kernel. Cause: an interrupted kernel =-Syu= removed the old kernel and never installed the new one — /mnt/be/boot (from zroot/ROOT/default) holds ONLY intel-ucode.img; vmlinuz-linux + both initramfs are gone. /boot lives inside zroot/ROOT/default (no separate boot dataset), so root-dataset snapshots capture it.
Status 2026-07-15: a first rollback attempt did NOT fix it (square zero after reboot) — suspected typo in the snapshot name, so the rollback likely errored and did nothing. NOT verified. Next session: verify state in the ZBM recovery shell BEFORE any reboot.
Recovery lever: the pre-pacman ZFS snapshot hook (live on velox since 2026-06-29) snapshots zroot/ROOT/default@pre-pacman_<ts> before every pacman transaction. The newest =pre-pacman_<ts>= predating the failed upgrade holds the intact old kernel — roll back to it.
Morning steps (Craig at velox ZBM → recovery shell, Ctrl+R):
#+begin_src sh
# 1. pool writable + key loaded
zpool get readonly zroot
zfs get -H -o value keystatus zroot/ROOT/default
# if readonly=on: zpool export zroot && zpool import -f -N zroot
# if keystatus=unavailable: zfs load-key zroot
# 2. list snapshots — COPY THE EXACT NAME (the typo bit here last time)
zfs list -t snapshot -o name,creation zroot/ROOT/default | grep pre-pacman
# 3. see current /boot state (read-only mount)
umount /mnt/be 2>/dev/null; mkdir -p /mnt/be
mount -t zfs -o zfsutil,ro zroot/ROOT/default /mnt/be
ls -la /mnt/be/boot
# 4. if /boot still shows only intel-ucode.img: redo rollback with the exact name
umount /mnt/be 2>/dev/null
zfs rollback -r zroot/ROOT/default@pre-pacman_<EXACT-TS> # -r, NOT -R
# 5. VERIFY before reboot — remount RO, confirm the kernel is back
mount -t zfs -o zfsutil,ro zroot/ROOT/default /mnt/be
ls -la /mnt/be/boot # MUST show vmlinuz-linux + initramfs-linux.img
umount /mnt/be
# 6. only once /boot shows a kernel:
zpool export zroot && reboot
#+end_src
Scope: only zroot/ROOT/default reverts; /home, /var, /media are separate datasets, untouched. After boot: =pacman -Syu= attended, confirm /boot holds vmlinuz-linux + initramfs before any shutdown. Full diagnosis: =inbox/PROCESSED-2026-07-15-0002-from-.emacs.d-velox-boot-failure-handoff.org=; ZBM photo: =inbox/PROCESSED-2026-07-15-0002-from-.emacs.d-PXL_20260715_043758976.jpg= (local on ratio; inbox is gitignored).
** DONE [#C] Restore date-format scrolling on the waybar date module :feature:waybar:dotfiles:quick:
CLOSED: [2026-07-19 Sun]
Shipped dotfiles 9dfe082: date-only ring (ordinal/full/longdate), on-scroll rewired, layout guard flipped. UTC/time stay on the time module.
Date and time are separate fixed-position controls. The time display cycles its
own formats, including UTC; the date/calendar control cycles date-only formats
and never displays a second time. Implement the dedicated format rings,
tooltip behavior, and tests together in the dotfiles Waybar configuration.
Reference material for the compact clock/chronograph treatment is filed in
[[file:working/clock-display-references/][working/clock-display-references/]].
*** 2026-07-19 Sun @ 04:36:26 -0500 Folded clock-panel interaction direction
The clock-panel handoff settled the prior open question: UTC belongs only to
the time ring, while the date ring is date-only. The existing task is therefore
a focused follow-up, not a two-line restoration of the old combined ring.
** DONE [#C] Notification sound loudness :chore:audio:quick:solo:
CLOSED: [2026-07-19 Sun]
Shipped dotfiles 808ca23: NOTIFY_VOLUME default 65536->39322 (0.6 gain) in both notify copies.
Reduce notification-sound playback loudness by 40% (0.6 gain, approximately
-4.4 dB). Change the =NOTIFY_VOLUME= playback control rather than re-encoding
the normalized sound files; verify each notification type still plays clearly.
** DONE [#C] Show the active wired interface in the Waybar network module :feature:waybar:network:
CLOSED: [2026-07-19 Sun]
Shipped dotfiles 22867f9: select_device prefers connected wifi -> connected ethernet -> wifi fallback, so a live cable shows the wired glyph+iface instead of Offline.
When Ethernet is active, replace the offline-WiFi presentation with the wired
interface glyph and interface name.
** DONE [#C] Let the clock panel dismiss itself on right click :feature:clock:waybar:
CLOSED: [2026-07-19 Sun]
Shipped dotfiles fc9a2b7: secondary-button gesture -> ClockApplication._dismiss hides the open panel. Live-verified with Craig 2026-07-19.
Make a right click inside the open clock panel toggle it closed. Preserve left
click for its established interaction; the Waybar time module remains the
explicit way to reopen the panel.
** DONE [#C] Make the WiFi toggle connect the best available profile :feature:network:
CLOSED: [2026-07-19 Sun]
Shipped dotfiles 9105361: manage.wifi_radio -> _connect_best_saved activates the strongest in-range saved profile on enable; nothing in range falls back to NM autoconnect.
When enabling WiFi, automatically connect to the highest-priority available
saved network instead of requiring a panel selection first.
** DONE [#A] Tracked WireGuard private keys in repo — public leak, resolved :bug:security:network:
CLOSED: [2026-07-20 Mon]
Confirmed a live public leak, not just at-risk: git.cjennings.net runs cgit (scan-path=/var/git), so archsetup.git was anonymously cloneable over https. An unauthenticated clone pulled the configs with intact PrivateKeys. Exposed 2026-07-05 (c7b7d16) to 2026-07-20. Regraded to P1/[#A] (public credential exposure, severity-alone carve-out) from the initial [#B].
Scope was wider than first found: the current 3 configs (assets/wireguard-config/wg-*.conf) plus 7 older ones at the pre-reorg path assets/wireguard/ (switzerland x2, USCALA/USCASF/USDC/USGAAT/USNY) — 10 config files, all with real keys.
Resolution: Craig expired all the Proton WireGuard configs (keys dead). Purged all 10 from every commit with git filter-repo, force-pushed main + v0.5, and ran git gc --prune=now on the server bare repo. Verified via anonymous clone: zero real-key blobs reachable, all old exposed commits gone. Stopped tracking plaintext (gitignore + README, out-of-band configs only).
Follow-ups filed below: harden cgit exposure; installer no longer ships configs.
** DONE [#C] Installer chpasswd unguarded — unloggable primary user :bug:solo:quick:
CLOSED: [2026-07-20 Mon]
Fixed (fa3135a): extracted set_user_password, which guards the chpasswd with error_fatal so a failure aborts loudly instead of silently leaving no password. Fake-chpasswd test pins the guard fires on failure and stays quiet on success.
Grading: Major severity (fresh system's primary user can't log in) x rare edge case (chpasswd seldom fails) = P3 = [#C].
archsetup:1168 runs =echo "$user:$pass" | chpasswd= with no guard, then unsets the password next line; set -e is off (line 21), so a silent failure leaves no password and no log entry. Fix: guard with error_fatal (report + "set it by hand: passwd $user") before unsetting. See findings doc (S2).
** DONE [#C] Installer nvme early module never built into initramfs :bug:solo:
CLOSED: [2026-07-20 Mon]
Fixed in e0d22bd: extracted ensure_nvme_early_module, which rebuilds the initramfs whenever it changed the conf (regardless of ZFS root) and scopes the presence check to the MODULES line. TDD via tests/installer-steps/test_ensure_nvme_early_module.py.
Grading: Minor severity (module autoload still boots the system) x most-machines (all Craig's ZFS-root boxes) = P3 = [#C].
archsetup:2910 writes MODULES=(nvme) but the only mkinitcpio -P in boot_ux runs =if ! is_zfs_root=, so on ZFS-root non-Framework machines the early-load hardening is never compiled in. Also archsetup:2918 greps the whole file for "nvme" (not the MODULES line). Fix: rebuild initramfs after the MODULES edit regardless of ZFS; scope the presence grep to =^MODULES=(=. See findings doc (S3).
** DONE [#C] Installer disk-space pre-flight check is fragile :bug:solo:quick:
CLOSED: [2026-07-20 Mon]
Fixed in aef074f: extracted check_disk_space using df -P (wrap-safe) and a KB comparison (no truncation bias); non-numeric df output falls back to zero so a malformed read aborts loudly. TDD via tests/installer-steps/test_check_disk_space.py.
Grading: Major severity (aborts a valid install) x some (df wraps long device names on a live ISO / device-mapper root) = P3 = [#C].
archsetup:487 parses =df / | awk 'NR==2'=, which reads the device-name line (empty $4 -> 0 GB) when df wraps; archsetup:488 also integer-truncates the GB compare against the 20 GB floor. Fix: =df -P /= (single-line) or =df --output=avail=; compare in KB to avoid the rounding bias. See findings doc (S1).
** DONE [#C] Installer run_step state + exit-code handling :bug:solo:
CLOSED: [2026-07-20 Mon]
Fixed in 6de55d2: run_step records the state marker whenever the step function returns (a return past error_fatal's exit means only a non-fatal warning is left), added local to run_step/show_status, and captured pacman's real exit in the refresh loop. TDD via tests/installer-steps/test_run_step.py.
Grading: Major severity (resume re-runs steps and can abort on a survivable warning) x some (a step whose last action is a non-fatal failure) = P3 = [#C].
archsetup:298 marks a step complete only when its function returns 0, but error_warn/run_task return 1, so a non-fatal-failing step never writes its marker and re-runs on resume. Also archsetup:1034 reports =$?= of the =false= test, not pacman's real exit code; and run_step locals (290/318) leak to global scope. Fix: step functions =return 0= explicitly (or gate run_step on a per-step error flag); capture the real exit code; add =local=. See findings doc (S1).
** DONE [#C] cmail password decrypted world-readable before chmod :bug:security:solo:quick:cmail:
CLOSED: [2026-07-20 Mon]
Already fixed in dffecf5 (before this session): decrypt_to_secure wraps the gpg decrypt in a 0077-umask subshell so the file is 0600 from creation, with tests/cmail/ verifying the umask at write time. The task was stale; verified green and closed.
Grading: security carve-out — brief local plaintext exposure of the mail password, requires a concurrent local shell during install; narrow window = low severity = P3 = [#C].
scripts/cmail-setup-finish.sh:52 gpg-decrypts to ~/.config/.cmailpass at the process umask (often 0644), then chmod 600 on the next line. Fix: =(umask 077; gpg ... --output ...)= or decrypt to a mktemp 0600 file and mv into place (mirror the import-wireguard mktemp -d 0700 pattern). See findings doc (S4).
** DONE [#C] Installer sudoers.pacnew blind copy risks lockout :bug:solo:quick:
CLOSED: [2026-07-20 Mon]
Fixed in c80e855: extracted replace_sudoers_pacnew, which runs visudo -cf on the pacnew and only copies a validated file (warns and keeps the working sudoers otherwise). TDD via tests/installer-steps/test_replace_sudoers_pacnew.py.
Grading: Major severity (a malformed sudoers locks out privilege escalation) x rare edge case = P3 = [#C].
archsetup:1146 does =[ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers= with no validation, right before the NOPASSWD rule at 1183. Fix: =visudo -cf /etc/sudoers.pacnew && cp ... || error_warn=. See findings doc (S2).
** DONE [#C] WireGuard import leaves full-tunnel VPN live on failure :bug:solo:network:
CLOSED: [2026-07-20 Mon]
Fixed in 36daf76: the down now runs before the rename modify (targets the stable UUID), so a failed modify under set -e can't leave a live full-tunnel VPN. Added a connection-down case to fake-nmcli and two ordering tests.
Grading: Major severity (all traffic silently routed through Proton until manual cleanup) x rare (nmcli modify failure) = P3 = [#C].
scripts/import-wireguard-configs.sh:51-62 imports (which brings the 0.0.0.0/0 tunnel up), renames, then deactivates; under set -e a failed modify aborts before the down, leaving the tunnel live. Fix: bring the connection down right after parsing the UUID, before the rename. See findings doc (S4).
** DONE [#C] net-scenarios diagnose failure exits green :bug:test:solo:
CLOSED: [2026-07-20 Mon]
Fixed in cf211cd: a diagnose miss sets a per-scenario rc carried to the subshell exit, so the run fails honestly while still running fix + assert. New harness at tests/net-scenarios/ drives the real script with stubbed ssh/rsync/jq.
Grading: Major severity (a net-doctor diagnosis regression is reported as a passing run — false green on a diagnostic tool) x rare edge case (only when a diagnosis regresses and this first-draft harness is relied on) = P3 = [#C].
scripts/testing/run-net-scenarios.sh:103 — the scenario_diagnose_expect else-branch prints fail "...diagnose did NOT name it" but never forces a non-zero subshell exit, so ( ... ) || fails=... leaves fails unincremented and the script prints "all scenarios passed" + exit 0. Fix: exit 1 in that branch like the other two checks. See findings doc (S5).
** DONE [#C] pacman-hook-order test is a tautology :test:solo:quick:
CLOSED: [2026-07-20 Mon]
Fixed in 1b7236b: the test now extracts the hook filenames the installer writes and compares them against the stock 60-mkinitcpio-remove name (pacman's filename ordering is the real invariant, not source position). Mutation-verified: a 05->70 rename fails the new compare where the old literal compare stayed true.
Grading: Major severity (guards boot-critical hook ordering — a reorder that removes the current initramfs without a rebuild is unbootable, and this test would ship it green) x rare (hook order rarely changes) = P3 = [#C].
tests/installer-steps/test_pacman_hook_order.py:20 — the two assertLess calls compare string literals ("05..." < "60..."), a constant ASCII fact always true regardless of file content; the ordering the test exists to protect is never measured. Only the assertIn presence checks do real work. Fix: assert on positions — text.index("05-zfs-snapshot.hook") < text.index("60-mkinitcpio-remove.hook") (and the guard hook). See findings doc (S6).
** DONE [#C] Add inetutils to install base :feature:solo:quick:network:
CLOSED: [2026-07-20 Mon]
Already done in 1115543 (earlier today): inetutils sits in install_required_software, with tests/installer-steps/test_required_software.py pinning it (test_installs_inetutils_for_ftp, green). The task was stale; verified and closed. The next full VM run covers the install-path verification.
Original context: TRAMP's /ftp: method needs =/usr/bin/ftp= (GNU inetutils); dirvish has an FTP quick-access entry. Installed manually on ratio 2026-07-14. From .emacs.d handoff 2026-07-14-1751.
** DONE [#D] Installer resume-idempotency cluster :bug:solo:
CLOSED: [2026-07-20 Mon]
Fixed in 8917f2f: extracted crontab_append_once (dedup guard), zfs_scrub_timer_units (one timer per pool, warn on none instead of @.timer), and enable_user_service (wants-symlink; gamemode now uses it and syncthing folds into the shared helper). TDD via tests/installer-steps/test_idempotency_cluster.py.
Grading: Minor severity x rare edge case (re-run after a mid-step failure) = P4 = [#D]. Group of small non-idempotent / wrong-target spots.
crontab log-cleanup line duplicates on resume (archsetup:1713 — guard on absence); zfs scrub timer picks an arbitrary pool via =head -1= and yields =@.timer= when empty (archsetup:1857); gamemode enabled via =systemctl --user= which the script itself documents fails at install time (archsetup:2419 — use the manual wants-symlink like syncthing). See findings doc (S2, S3).
** DONE [#D] Installer unguarded chmod/cp after non-fatal ops :bug:solo:quick:
CLOSED: [2026-07-20 Mon]
Fixed in dd41036: extracted install_executable (guarded cp + chmod +x) for the two zfs scripts; guarded the two hypr-live-update-guard chmods inline with error_warn. TDD via tests/installer-steps/test_install_executable.py.
Grading: Minor severity x rare edge case (only when a preceding non-fatal cp/clone failed) = P4 = [#D].
With set -e off, unguarded chmod/cp hit missing/partial files silently: hypr-live-update-guard chmods (archsetup:2108/2144), zfs-replicate cp (archsetup:1820) leaving a service with a dead ExecStart, zfs-pre-snapshot cp (archsetup:1943) leaving a broken pacman hook. Fix: wrap each in =(...) >> log 2>&1 || error_warn=. See findings doc (S2, S3).
** DONE [#D] normalize-notify-sounds temp/atomicity can corrupt tracked file :bug:solo:quick:
CLOSED: [2026-07-20 Mon]
Fixed in a29769e: resolves the real target via readlink -f, stages the temp beside it, guards on a non-empty encode, and atomically mv's into place (preserving the stow symlink); an EXIT trap cleans a leaked temp. TDD via tests/normalize-notify/ with fake ffmpeg.
Grading: Minor severity (corrupts a repo-tracked sound file, recoverable via git) x rare (ffmpeg failure/interrupt) = P4 = [#D].
scripts/normalize-notify-sounds.sh:39-46 has no EXIT trap on the mktemp and does =cat "$tmp" > "$f"= (truncate-first) where $f is a stow symlink into the repo; a zero-byte/failed encode writes a corrupt file. Fix: EXIT trap; =[ -s "$tmp" ]= guard; write $f.tmp and overwrite on success. See findings doc (S4).
** DONE [#D] VM test-framework robustness cluster :bug:test:solo:
CLOSED: [2026-07-20 Mon]
Fixed in 866d327: profile-suffixed PID/monitor/serial paths, kill_qemu reaps-or-polls to death before the snapshot restore, debug-vm uses DISK_PATH, and both runners report an honest ARCHSETUP_COMPLETED marker instead of a fake exit code. TDD via tests/vm-framework/test_vm_utils.py (suffix red->green; kill_qemu as a contract pin).
Grading: Minor severity x rare edge case (each fires only in a narrow test-harness path) = P4 = [#D]. Group of four small framework bugs from the S5 audit.
scripts/testing/debug-vm.sh:49 hardcodes the btrfs base disk, ignoring the profile-correct DISK_PATH from init_vm_paths (FS_PROFILE=zfs boots the wrong base or fatals); lib/vm-utils.sh:284 kill_qemu -9's and deletes the PID file without waiting, so a force-kill restore races the dying qemu's qcow2 lock and silently leaves the base image dirty (fix: wait for the PID); lib/vm-utils.sh:69 leaves PID_FILE/MONITOR_SOCK/SERIAL_LOG un-suffixed so parallel btrfs+zfs runs collide (fix: suffix by FS_PROFILE like DISK_PATH); run-test.sh:287 (and run-test-baremetal.sh:234) reports a completion-marker grep as ARCHSETUP_EXIT_CODE, not the installer's real exit — misleading since the installer runs set -e off and can error then still write the marker (fix: rename + capture the true status). Testinfra remains the real pass/fail backstop. See findings doc (S5).
** DONE [#D] Gallery-widget prototype elisp bugs :bug:design:solo:quick:
CLOSED: [2026-07-20 Mon]
Fixed in 552736e: shared clamp feeds needle + readout (150 renders 100%), explicit cl-lib require, and gallery-widget--source-dir with a default-directory fallback. TDD: 3 new ERT tests (clamp red->green; the other two land as pins since svg.el transitively loads cl-lib).
Grading: Minor severity x rare edge case (out-of-range input / cold byte-compile / interactive re-eval) = P4 = [#D]. Prototype code, all three Minor.
docs/prototypes/gallery-widget.el:139 renders the readout from the unclamped value while the needle clamps 0-100, so at value 150 the needle pins at +60 degrees but the text reads "150%" (fix: clamp once, format both from it); :69 calls cl-loop without (require 'cl-lib) — works only via the autoload cookie, bites on a cold byte-compile (fix: add the require); :29 computes its dir from (or load-file-name buffer-file-name), both nil on interactive re-eval outside a load/file buffer (fix: fall back to default-directory). See findings doc (S7).
** DONE [#D] Audit test-quality cluster (Python + elisp) :test:solo:
CLOSED: [2026-07-20 Mon]
Fixed in 179fbd5 (plus 552736e for the gauge-level clamp test): socket check via find -type s, gen_tokens degenerate case pinned exactly as characterization, tick count as direct occurrences, and write-svg covered. All five items dispositioned.
Grading: no runtime behavior change; test-suite quality. Group of five weak/missing tests from the S6/S7 audit.
scripts/testing/tests/test_desktop.py:96 passes a shell glob to `test -S`, which breaks on zero or multiple sockets (masked today because the test always skips); tests/gallery-tokens/test_gen_tokens.py:181 asserts properties too weak to notice the marker output is garbled (impossible input, so low); tests/gallery-widgets/test-gallery-widget.el:77 counts ticks via split-string + cl-count-if :start 1 (a coincidence of split semantics, not a match count); :47 tests the needle-angle helper's clamp but never the rendered readout at an out-of-range value (exactly why the S7 readout/needle bug ships green — add a gauge-level boundary case); :159 leaves gallery-widget-write-svg uncovered (add a Normal write-to-temp case). See findings doc (S6, S7).
** DONE [#B] Installer GRUB_CMDLINE overwrite drops boot params :bug:solo:
CLOSED: [2026-07-21 Tue]
Fixed in f9da097: update_grub_cmdline merges the current value with archsetup's tokens (existing tokens survive, same-key conflicts resolve to archsetup's value) behind a refuse-to-write safety check, via awk + mv with a backup_system_file first. TDD via tests/installer-steps/test_grub_cmdline.py (8 cases incl. cryptdevice/resume/zfs survival and idempotence).
Grading: Critical severity (unbootable) x some-users-sometimes (machines whose base install set a cryptdevice=/resume=/zfs= cmdline param) = P2 = [#B].
archsetup:3054 rewrites the whole GRUB_CMDLINE_LINUX_DEFAULT line with a fixed string; nothing re-adds a pre-existing cryptdevice/resume/zfs token, so grub-mkconfig (3059) can bake an unbootable config. Fix: read the current value and append only the missing tokens; assert any pre-existing boot-critical token survives before grub-mkconfig. See [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (S3).
** DONE [#C] Maint status wall copy buttons :feature:maint:dotfiles:
CLOSED: [2026-07-21 Tue]
Shipped in dotfiles 8bc79ba per Craig's calls (one global button, rendered text): COPY on the doctor row serializes every category band via the same card_spec the GUI renders, through panelkit clipboard. TDD tests/maint/test_status_copy.py, full dotfiles make test green, inbox note sent. Live check pending: open the maint panel, press COPY, paste.
Craig's roam capture 2026-07-20, routed via .emacs.d sentry inbox-zero as archsetup-owned UI work. Dotfiles maint panel work; archsetup drives it end-to-end per the standing rule.
** DONE [#B] Build: desktop-settings panel :feature:hyprland:dotfiles:
CLOSED: [2026-07-22 Wed]
:PROPERTIES:
:SPEC_ID: d6bb1e73-ec90-4327-85ee-bfa762da5bce
:END:
The GTK build of the desktop-settings panel per the spec (docs/specs/2026-07-02-desktop-settings-panel-spec.org, DOING; normative reference: prototype 37). Work happens in dotfiles settings/ — archsetup drives the lifecycle. Two non-blocking build-time picks live in the spec's Review findings (wallpaper setter tool; store location/format) — decide in phase 1 and record there.
*** 2026-07-22 Wed @ 13:14:01 -0500 Built the backings engine (phase 1) — dotfiles 7a15237
Landed as dotfiles settings/src/settings (10 modules) + tests/settings (118 tests against fake binaries, auto-discovered by make test — 81 suites green). Covers brightness/kbd (5% floor, x10 drum), toggles (dim, pointer cycle via toggle-touchpad, caffeine), DND class-split (dunst pause level 60, close-all before unpause, alarms punch through live), powerprofilesctl, nightlight (resident gammastep), hypridle.conf renderer + symlink-safe write + caffeine-respecting reload + hyprlock grace, suntimes (pure NOAA math), and the wallpaper engine (awww/mpvpaper/projector adapters, galleries, random draw, atomic JSON store). All three build-time picks recorded as DONE findings in the spec (setter=awww, store=state.json, nightlight=gammastep). Handoff note in ~/.dotfiles/inbox/.
*** 2026-07-22 Wed @ 15:26:44 -0500 Built the presenters (phase 2) — dotfiles 5172289
Three GTK-free models per prototype 37, all at 100% line coverage (tests/settings/test_presenters.py, 100 tests; full repo suite green before and after). programs.py: the matrix — eight complete programs (Craig's four factory scenes drafted here per the pre-flight pick, slots 1-4 first-class), pin rows + power radio row, activate returns the full sets, member writes return apply/updated with active-is-live surviving. bench.py: drum mapping (screen never reads 0, floor 5%; kbd floors at 0), idle rail order clamping between enabled neighbors, park/unpark with re-clamp, caffeine bypass, view-state builder tolerant of no-backlight None. channels.py: the eight-channel bank, per-mode sources visibility, alpha/recency sort (unlabeled last), the shared mint/edit/delete grammar for pairs/sets/colors (press arm-cycle, two-picture set minimum, dup rejection, selection clamping), sources guardrails, interval wheel, previews. Handoff note in ~/.dotfiles/inbox/.
*** 2026-07-22 Wed @ 16:03:52 -0500 Ported prototype 37's instruments to GTK (phase 3) — dotfiles 33d82eb
The panel renders P37 end to end. New instruments.py carries the three Cairo instruments as clock-free humble objects: ProgramMatrix (glyph/numbered heads over jewel pins + CPU POWER paper letter wheels), DrumRoller (paper drums, drag-to-set, dimmed n/a on no-backlight machines), TripDial (sqrt 300° scale, colored stage tabs, OFF-notch parking, exact-minutes drag counter, BYPASSED · CAFFEINE stamp, bottom legend). gui.py rebuilt to P37's layout with the wallpaper sub-view: channel bank with drawn faces, minted pair/color/set trays (alpha/time sort, edit/delete chip feet), the three presses (pair arm-cycle, color picker, set press + interval wheel), sources with a folder picker. New GTK-free glue all unit-tested (test_panel_glue.py, 33 tests): dial geometry in bench, matrix/idle/wallpaper wiring in panel, presenter-vocabulary channels (pair/solid/random-from-set) in wallpaper.apply. AT-SPI smoke (make test-panel-settings) drives the real wiring against faked backings + a sandboxed store, pinned to its own child pid so it can never fire a live panel's backings. Visually verified on a headless output against P37 captures (main + pair/single/solid/random). Adaptations recorded in the handoff: five-stage dial (WATCH gets its own green — the engine runs watch separately, P37 merged the label), DESKTOP_SETTINGS_START_VIEW test seam. Full suite 84 suites green; window rule widened for the 540px panel. Handoff note in ~/.dotfiles/inbox/.
*** 2026-07-22 Wed @ 16:47:54 -0500 Integrated phase 4 — dotfiles 680b50d
Bar consolidation had landed early (74f723e); this pass shipped the rest. settings-project hosts the watch/clock/world channels as HTML faces (settings/faces/) on a gtk-layer-shell background window over WebKit2 — all three visually verified on a headless output, world reading the waybar worldclock roster via query param. settings-watch is the hypridle watch-stage host: throwaway-profile chrome kiosk that reveals only after its window maps behind the lock and relocks before teardown — a failed face degrades to the plain lock, never a bare desktop (unlocked lifecycle verified live; the locked swap goes to the manual checklist). Sun-pair location reads whereami live per transition with last-good cache in state.json (verified live: 9.5s first beat, New Orleans coords, Gogh day side applied); desktop-settings-tick.timer (2 min, enabled on ratio, added to the installer) drives flips and random draws — 23ms no-op beats. dunstrc history_length 100 protects held alarms (full DND cycle verified against live dunst; wtimer alarms already CRITICAL via the notify wrapper, no promotion rule needed). Live hypridle rewrite verified — five-stage regime rendered through the stow symlink, caffeine respected (found engaged, daemon correctly left stopped). Refresh signals needed no rewiring (touchpad signals itself via toggle-touchpad). 45 new tests; suite 84 suites green; smoke 13/13. Handoff note in ~/.dotfiles/inbox/.
Velox one-time steps (sync doesn't carry): mpvpaper (AUR), optionally power-profiles-daemon (service off), and systemctl --user enable --now desktop-settings-tick.timer.
*** 2026-07-22 Wed @ 17:05:58 -0500 Landed the 17-point end-to-end pass — dotfiles 9038eee
Prototype 37's 17-point suite re-derived against the real panel (the original Playwright script wasn't preserved; the functional surface in the spec's Final prototype section is the source). tests/settings/panel_e2e.py + run-panel-e2e.sh + =make test-panel-e2e=: points 1-14 drive the running panel over AT-SPI (program recall with per-backing verification across FOCUS/BATTERY/slot1, pointer console keys, all eight wallpaper channels including projected watch/world stop/start ordering, close); points 15-17 cover the Cairo instruments (drums, tripper dial clamp/park/render/reload, matrix pins + letter wheels with active-is-live) at the backing layer, since AT-SPI can't reach a DrawingArea's hit-tests. Same safety posture as the smoke: sandboxed store, faked backings, pid-pinned a11y node. 17/17 green on ratio's live compositor; full suite 85 green; smoke 13/13; ruff clean. The drag gestures go to the manual checklist below. Handoff note in ~/.dotfiles/inbox/.
*** 2026-07-22 Wed @ 17:05:58 -0500 Flipped the spec to IMPLEMENTED
docs/specs/2026-07-02-desktop-settings-panel-spec.org DOING → IMPLEMENTED with a dated history line naming the shipping commits (dotfiles 7a15237 / 74f723e / 5172289 / 33d82eb / 680b50d / 9038eee) and the verification evidence (85 suites, smoke 13/13, e2e 17/17). The four panel drag-gesture checks and the locked-path night-watch swap live under "Manual testing and validation" — human-eye checks, not implementation blockers.
** CANCELLED [#B] Hyprland layoutmsg crash — bad_variant_access (upstream) :bug:hyprland:
CLOSED: [2026-07-21 Tue]
Dropped 2026-07-21 (Craig's call) — not tracking the upstream report. The crash evidence (both reports + tmpfs log excerpts) and the voice-passed issue draft stay preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] if it recurs and is worth reviving.
Grading: Critical severity (SIGSEGV kills the whole desktop session; every GUI app's unsaved state lost) x rare edge case (twice in ~4.5 months: 2026-03-07 on v0.54.1, 2026-07-20 on v0.55.4) = P2 = [#B]. Upstream Hyprland bug, not this repo's code — the task tracks reporting it and picking up the fix.
A layoutmsg mfact dispatch (layout-resize, mod+H/L) throws std::bad_variant_access inside Layout::CAlgorithm::layoutMsg, uncaught, SIGSEGV. Both crashes fired from the layout-resize mfact path (keycode 104 shrink today, 108 grow in March). Layout at crash was master and the identical mfact had worked seconds earlier; the pre-crash window held monocle<->master toggles, two window closes dropping focus to "[Window nullptr]", and togglefloating x2. Monocle is a registered v0.55 layout (log shows graceful "Unknown monocle layoutmsg" rejects), so the config is not at fault; related edges are guarded ("mfact -> no window") while this path misses its variant guard. Repo has no newer build (0.55.4-1 installed and repo).
Evidence preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] (both crash reports + excerpts from the tmpfs session log, extracted before reboot loses it).
Next: Craig posts the issue himself (2026-07-20 decision) — the voice-passed draft is [[file:working/hyprland-layoutmsg-crash/issue-draft.md][issue-draft.md]], with both crash reports and the log excerpts beside it for attaching. Watch the repo for a fixed release and close on confirmation. The layout-resize script guard was declined (a script can't observe the internal desync).
** DONE [#C] WireGuard import is now config-less — decide feature fate :feature:network:
CLOSED: [2026-07-21 Tue]
Decided 2026-07-21 (Craig): KEEP the import feature. The out-of-band flow is already in place — =assets/wireguard-config/= carries a README documenting "drop plaintext =*.conf= locally at install time (gitignored); ship encrypted =*.conf.gpg= to track", and its =.gitignore= enforces it (=*.conf= blocked, =!*.conf.gpg= allowed). The script also already no-ops gracefully on an empty dir (=shopt -s nullglob= + a =found= flag), so nothing ships and nothing errors when no configs are present. Nothing to build; the fate decision was the whole task.
scripts/import-wireguard-configs.sh reads assets/wireguard-config/*.conf, but no configs ship in the repo anymore (removed as a public-leak fix; .gitignore blocks plaintext).
** DONE [#C] Dupre theme waybar.css drifted from live style.css :bug:dotfiles:waybar:
CLOSED: [2026-07-21 Tue]
Fixed in dotfiles 3e4e7ff (2026-07-20, "fix(theme): sync dupre waybar.css with the live weather rules") — dupre/waybar.css is byte-identical to live again, restoring the =#custom-weather= selectors/hover/gold divider, so =tests/theme-css= is green.
Grading: Minor severity (cosmetic, reverts only on a theme switch) × rare edge case (dupre is already the active theme) = P4 = [#D] on user impact, bumped to [#C] because the dotfiles =make test= stays RED until synced, poisoning the green baseline for every future commit.
The weather-kit work added =#custom-weather= selectors to =hyprland/.config/waybar/style.css= but never mirrored them into =hyprland/.config/themes/dupre/waybar.css=. =tests/theme-css= asserts the two files are identical (set-theme copies the theme file over the live one), so switching to dupre would silently revert the weather chip styling. Fix: sync the theme file to live. Pre-existing; found 2026-07-19 during an unrelated commit's green-baseline run.
** DONE [#B] Dotfiles tests leak state across files :bug:test:dotfiles:solo:
CLOSED: [2026-07-23 Thu]
Resolved 2026-07-23 as dotfiles =c333598=. The polluter was =tests/weather/test_weather.py=, and it accounted for all 38 failures on its own.
The mechanism was not the env leak the body below guessed at — tests/weather never writes =os.environ=. Its whereami fake did =weather.subprocess.run = ...= on a freshly-loaded module object. The fresh module isolated the weather code, but =weather.subprocess= is the one shared stdlib module object every module in the process holds, so the assignment replaced =subprocess.run= process-wide and never restored it. Every later test file got weather's fake result back from =subprocess.run=; the tell was wtimer asserting on =r.returncode= and getting "'R' object has no attribute 'returncode'", where =R= is weather's fake result class.
Triage: TEST HYGIENE, not production global state. The weather script reads env at import and never writes, so no long-lived-process caching defect sits behind it. A scan for the same pattern (patching a stdlib module attribute reached through another module's namespace) finds exactly one instance in the suite — the three other =setattr= sites all snapshot and restore. So the planned shared env helper across 28 files was aimed at the wrong target and wasn't needed.
Fix: rebind the loaded module's own =subprocess= name to a stub namespace, so nothing outside that module changes and there is nothing to restore.
Gate: =make test= now runs two gates per the add-don't-replace decision — =test-forked= (one process per file, catches order dependence) and the new =test-shared= (every suite in one process, catches leakage). Built on stdlib unittest rather than pytest, since pytest was only the diagnostic tool and isn't a project dependency. Verified as a real gate, not just green today: with the defect deliberately reintroduced it goes red, and green once restored. A focused test in tests/weather pins the invariant on the culprit as well, because the shared gate alone blames the three victim files.
Verification: 3500 tests, both gates, exit 0.
Original finding follows.
Found 2026-07-23 during the speedrun. =make test= is green, but it runs each test file in its own =python3 -m unittest= process, which hides cross-file state leakage. A single-process whole-tree run (=python3 -m pytest tests/ -p no:randomly=) fails 38: 22 in =tests/wtimer/test_wtimer.py=, 10 in =tests/zoom-web/test_zoom_web.py=, 6 in =tests/wlogout-menu/test_wlogout_menu.py=.
Not a regression — a worktree at the pre-speedrun commit produces the identical 22/10/6 profile, so this predates tonight's work. Those three files also pass cleanly when run together (170 passed), so the polluter is a fourth file somewhere in the tree that mutates global state (env var, cwd, or a module-level patch) without restoring it. 28 test files write =os.environ= directly.
Why it matters: the green gate can't see this class of bug, so a real isolation defect — or a genuine failure that only appears under a different order — passes CI silently. Bisect by running the tree with subsets until the polluter is identified (pytest's =-p no:randomly= keeps the order stable while bisecting), fix its cleanup, then decide whether =make test= should gain a single-process pass so the gate covers it.
** DONE [#B] Wallpaper view freezes the panel — thumbnail decode :bug:dotfiles:solo:
CLOSED: [2026-07-23 Thu]
Craig reported 2026-07-23: selecting the wallpaper button freezes the module and the compositor asks whether to kill it. Root cause proven: =_Thumb._draw= decoded each source image with =new_from_file_at_scale= on the GTK main thread. Measured on Craig's 78 wallpapers — a viewport of the 8 largest takes 3.7s, the whole set 13s. That block trips Hyprland's "not responding" watchdog.
Grading: Critical severity (panel unusable, watchdog kill) × every user every time the wallpaper view opens = P1 = [#A] by the matrix. Held at [#B] because step 1 already shipped and removes the user-visible freeze; the remainder is a latency enhancement, not a showstopper.
*** 2026-07-23 Thu @ 15:40 Step 1 — async decode (dotfiles f45f321)
Moved the decode to a worker thread via a new =settings/thumbcache.py= (pure, injected decode/scheduler/thread; 6 tests). The thumb shows its dark ground until the pixbuf lands, then redraws. Verified live on a headless output: worst main-loop stall opening the pair view dropped from multi-second to 68ms; the cache filled with 81 decoded pixbufs (the one miss is a .webm, correctly falling back to the ▶ glyph). Full suite 3512, both gates, smoke OK. This alone fixes the reported freeze.
*** 2026-07-23 Thu @ 16:30 Step 2 — persistent on-disk cache (dotfiles 463cc4f)
Built the persistent layer: =settings/thumbstore.py= decodes each source once to a 512px PNG under =~/.cache/settings/thumbs=, keyed by path + mtime so an edited wallpaper self-invalidates. The hot-path decode reads that PNG and scales in-memory. Warming rides the existing =settings tick= CLI verb (the 2-min timer already runs it), building up to =WARM_PER_BEAT=8= missing thumbnails per beat — best-effort, journals a line on failure, never blocks the wallpaper flip. thumbstore is pure (stat/decode/load/save injected); 10 tests.
Went with incremental warming (8/beat, ~10 beats to full) as the safe default rather than full-warm-on-change — the per-beat cap is a one-line flip if Craig wants it faster. Measured: hot-path decode of a viewport dropped from 3.7s cold to 47ms warm. No installer change (the tick service already runs =settings tick=); cache lives outside the repo. Full suite 3522, both gates, smoke OK, live panel verified (81 pixbufs render, 48ms worst stall warm).
** DONE [#C] Panel scrollbars too short :bug:dotfiles:quick:solo:
CLOSED: [2026-07-23 Thu]
Shipped 2026-07-23 as dotfiles =0d64837= (22px scrollbar, 16px trough, 14px slider thickness with a 48px floor along the travel axis). Left open by oversight during the speedrun; closing now.
Follow-on, and my own regression: enlarging the bar to 22px is what made it start covering the thumbnails, because nothing grew the tray to match. Craig reported it the same day ("scrollbars that obscure the images") and it's fixed in =c0ddf57= — the tray now reserves a 22px lane for the bar as a margin on the scrolled box, so the bar sits below the images instead of across them. Measured before: tray 68px, content 68px, a visible 14px bar inside the same 68px. After: tray 90, content 68, bar clear. The lane is a constant under the scrollbar CSS with a note to keep the two in step, since the coupling between bar thickness and tray height is exactly what broke.
From the roam inbox (Craig, claimed 2026-07-23): all scrollbars need to be much taller than before. The always-visible scrollbars shipped in 7e8eb4a set =min-height: 10px; min-width: 10px= on the slider (=settings/src/settings/gui.py=, the =.dupre-panel scrollbar slider= rule) — that's the floor for a short slider, and the trough itself is thin. Raise both the slider floor and the trough thickness so the bar is comfortably grabbable. Cosmetic × every glance at the wallpaper trays = P3 = [#C].
** DONE [#C] Velox refresh sweep :chore:maint:
CLOSED: [2026-07-23 Thu]
From the roam inbox (Craig, claimed 2026-07-23): velox needs bringing up to date, the mouse/touchpad module is still there, investigate what else didn't move over.
Resolved 2026-07-23 by a full sweep over tailscale. The touchpad module was already gone — velox's running waybar (started 01:05, after the reboot) and its tracked config both carry zero =custom/touchpad= entries; what Craig saw was the pre-restow waybar process from before the reboot, and the reboot cleared it. Sweep results: both machines at dotfiles f9b6404 (all three hyprland lock/exit fixes live on velox, config errors clean, =allow_session_lock_restore= reads true); stow restow clean, only the expected skip-worktree files; rulesets pulled to 50fc7ca and =make install= run (agent-text verified working by invoking it — an earlier "MISSING" reading was a PATH artifact of the non-interactive ssh shell, not a real gap); desktop-settings tick timer active; mpvpaper, power-profiles-daemon, gtk4-layer-shell, webkit2gtk all present.
Genuine remaining differences, all per-machine installs rather than sync failures: =cmail-action=, =gcalcli=, and =playwright= aren't installed on velox, and =obsbot-wb-guard.service= isn't enabled there (the OBSBOT lives on ratio). None block anything; file separately if velox should send mail or drive browser tests.
*** 2026-08-20 Thu @ 09:53:04 -0700 One of those three closed itself; two still stand
=cmail-action= is on both daily drivers now, and nothing did it deliberately. It moved into rulesets at =claude-templates/bin/=, and rulesets' =make install= links that whole directory into =~/.local/bin= at every session start — so velox picked it up on its own. Verified here: the symlink was written 05:44 this morning by this session's own startup, and the tool runs.
=gcalcli= and =playwright= are still absent on velox, which stays correct until I say velox should send calendar invites or drive browser tests. =obsbot-wb-guard= is still right to be off here; the camera is on ratio.
Leaving the paragraph above as written rather than striking it (rulesets suggested striking). It is the resolution note of a task closed 2026-07-23 and it was accurate that day. Editing a closed record to match today makes it a worse record, and the useful correction is this dated entry, not a redaction.
** DONE [#C] Weather tooltip sunrise and sunset :feature:waybar:weather:quick:solo:
CLOSED: [2026-07-23 Thu]
Shipped 2026-07-23 as dotfiles =de62e9d=. The two rows sit directly below Humidity in the current-conditions block, rendered in the footer's 12-hour format (=%-I:%M %p=) so the tooltip reads one way throughout.
Confirmed the no-extra-round-trip premise held: =sunrise,sunset= joined the existing =&daily== block. Split =forecast_url= and =reading_from= out of =fetch= so both the request and the reading are testable without network — that's what let the new cases cover a payload missing the fields. Six tests (Normal/Boundary/Error): row placement and format, a pre-change cache with no sun fields, an unparseable stamp, today's pair picked out of the six-day arrays, and the API omitting them. Reused the existing =_at= helper rather than adding a near-duplicate =_first=.
Live-verified against the real API: sunrise 6:14 AM, sunset 7:59 PM for today in New Orleans, rendering in the actual tooltip. Full suite 3506 tests, both gates, exit 0.
Open, not blocking: every other header row carries a glyph (thermometer, droplet, wind arrow) and the sun rows are plain text. The file's glyphs are marked font-confirmed codepoints, and I haven't verified a sunrise/sunset glyph renders rather than showing tofu, so I left them bare. Craig's call.
From the roam inbox (Craig, claimed 2026-07-23): in the weather module's hover text, the section immediately after the location ends with the current humidity. Add the sunrise and sunset times for the current location directly below it.
Cheap to source: the module already calls Open-Meteo with a =&daily== block (=common/.local/bin/weather=, the forecast URL around line 336), so =sunrise,sunset= joins that same request with no extra round trip — normalise_daily already parses the daily arrays. Times arrive as local ISO strings; render in Craig's canonical clock format rather than re-deriving one. The settings package's =suntimes.py= (pure NOAA math, no network) stays the offline fallback path if the API field is ever absent — don't duplicate its math here.
** DONE [#C] Maint doctor-row copy button :refactor:maint:quick:solo:
CLOSED: [2026-07-23 Thu]
Shipped 2026-07-23 as dotfiles =761fa5c=, "fix(maint): drop the COPY key from the doctor row" — the key and its orphaned handler removed from =maint/src/maint/gui.py=. =viewmodel.status_copy_text= stays: it's a tested pure serializer and the obvious source if a copy surface returns somewhere better placed.
Correction to the body below: it describes a per-row button and a separate global one. There is only one COPY key, and it IS the global one Craig added in 8bc79ba two days earlier. He tried it and wanted it gone, so the row now reads DOCTOR · CLEAN UP · REVIEW & FIX.
From the roam inbox (Craig, claimed 2026-07-23): remove the per-doctor-row copy button (next to REVIEW and FIX) from the maint status wall. The global COPY key (dotfiles 8bc79ba, "one global button copying rendered text") stays the one copy surface — the per-row button turned out to be clutter next to it.
** DONE [#C] WiFi tooltip signal strength :feature:waybar:network:
CLOSED: [2026-07-22 Wed]
From the roam inbox (Craig, claimed 2026-07-22): add signal strength to the WiFi tooltip.
Resolved 2026-07-22: the tooltip's signal line existed but never fired on ratio — the mt7925 driver leaves /proc/net/wireless empty (legacy WEXT procfs unimplemented), so the dBm read returned None and the bar glyph fell to the weakest tier. Fix in dotfiles net/: an iw-dev-link nl80211 fallback (only spawns when procfs is empty), a signal_percent mapping, and an enriched line — Signal: ▂▄▆█ 100% · -32 dBm (excellent) — bars by band, percent, raw dBm, band word. The bar icon tier fixed itself as a side effect.
** DONE [#B] Desktop-settings dropdown panel :feature:waybar:
CLOSED: [2026-07-22 Wed]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-22
:END:
Resolved 2026-07-22: shipped end to end via the "Build: desktop-settings panel" task (dotfiles 7a15237 → 9038eee; spec IMPLEMENTED, 85 suites + smoke 13/13 + e2e 17/17). Every open question below got settled in the spec: bar consolidation landed (74f723e), the wallpaper manager became the in-panel sub-view, and the format pickers split into their own sibling spec ([[file:docs/specs/2026-07-19-display-format-single-source-of-truth-spec.org]], DRAFT stub). Remaining human-eye checks live under "Manual testing and validation".
Original body follows as the record.
Initial spec written 2026-07-02: [[file:docs/specs/2026-07-02-desktop-settings-panel-spec.org]] (DRAFT — four decisions await Craig's review before build; architecture updated to the net panel's Blueprint/GTK4 stack).
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.
Home handoff 2026-07-19 (inbox, resolving the open "few other things" decision — fold into the spec, close the open decision, extend the controls table, then run spec-review, may flip DRAFT→READY). Ownership: home drives the build (dotfiles settings/), archsetup keeps the canonical spec. Full reconciliation in home docs/design/2026-07-19-desktop-settings-module-brainstorm.org.
- ADD controls: night-light / color temperature; Do Not Disturb / notifications (dunst); lock / suspend quick actions; power profile (performance/balanced/saver); scenes/profiles — one control flipping several toggles at once (Focus, Presentation, Battery-saver, Night). Scenes are the payoff of consolidating everything.
- OUT (record reasons): volume / master-mute stays with the audio panel (no mirror here); theme light/dark goes to the theme-studio task.
- FORMAT PICKERS pulled to their own future sibling spec — time/date/weather format is out of THIS panel. Rationale: format settings live in many programs, so the design problem is a single source of truth for the canonical format. Track a future sibling-spec stub in docs/specs (time/date/weather format single-source-of-truth); Craig thinking it through separately, not started.
- STILL OPEN (spec already flags): wallpaper manager confirmed in scope, but row-that-opens-a-sub-view vs its own sub-spec undecided — resolve at spec-review.
** DONE [#C] Gallery probe: the fader-drag check is flaky :bug:test:design:quick:solo:
CLOSED: [2026-07-23 Thu]
Fixed 2026-07-23. Root cause confirmed rather than suspected: =panel-widget-gallery.html= line 74 sets =html{scroll-behavior:smooth}=, so =scrollIntoView= animates and the fixed 200ms sleep sometimes read =getBoundingClientRect= mid-scroll. The drag then dispatched at stale coordinates, the press missed the fader, and the check reported a dead widget.
Fix: scroll with =behavior:'instant'=. The probe never needed the animation, so this removes the race instead of waiting it out. Also added a =settledRect= guard (rect stable across two reads AND on-screen) for zoom/column relayout, and a =hits()= assertion that the press actually lands on the fader before the drag goes out.
Applied to the toggle-click check too — it shares the same fixed-sleep shape, and it failed for this exact reason during the diagnosis, so fixing only the fader would have left half the defect.
Worth recording: my FIRST fix was wrong and made it worse. Polling until the rect stopped changing returned pre-scroll coordinates every time, because two identical samples are also what you get before the animation starts — an intermittent failure became a consistent one. The new hit-test assertion is what caught it, printing the press point at y=1326 against a 1200px window. That's the argument for asserting the press landed rather than only asserting the readout moved.
Verified against the measured 1-in-6 failure rate: 8 consecutive runs, all three checks passing, with the press point identical every run (429,480) — deterministic, not lucky. Full probe 96 PASS, 0 FAIL, exit 0.
=probe.mjs= check 3 ("fader drag tracks at 3x") intermittently reports =level 68 -> level 68=, i.e. the synthetic drag never registers. It has presumably been doing this all along unnoticed, since the suite is normally run once per batch.
Grading: *Minor* severity (a false FAIL costs a re-run and a few minutes, and never ships a defect) x *most users, frequently* = P3 = =[#C]=.
Frequency measured 2026-07-16, not estimated: 1 failure in 6 consecutive runs, having already fired twice in about fifteen that afternoon. The first grading guessed "some users, sometimes" (~1 in 10); at ~1 in 6, both people who run this suite hit it most sessions, so the row is "most users, frequently". The letter lands on =[#C]= either way, but the input was wrong and the matrix is only worth anything if its inputs are measured.
Suspected cause: the check clicks the 3x size chip, calls =scrollIntoView=, waits a fixed 200ms, then reads =getBoundingClientRect= and dispatches the drag against those coordinates. If the zoom relayout or the smooth scroll hasn't settled, the rect is stale and the press lands off the fader — so the drag is a no-op and the readout never moves. The other timing-sensitive checks share the same fixed-sleep shape.
*Do not fix this by raising the sleep.* That hides the race rather than removing it and leaves the check failing again on a slower run. Wait on the actual condition instead: poll until the rect stops changing between frames, or assert the press landed on the fader before dispatching the drag (the probes' own README already warns that a =find()= miss dispatches into nothing and reports as a widget bug).
Why it matters beyond the annoyance: a gate that cries wolf gets its real failures ignored, and this suite is the only thing standing between the gallery and a silent regression.
Recurrences: 2026-07-18 batch-6 gate (first run, passed 3 reruns); 2026-07-18 batch-9 gate (first cold run, =level 68 -> level 68=, passed 2 reruns); 2026-07-21 double-speedrun run (flashed one RED mid-run, passed on rerun). All were a session's first/early probe run — consistent with the stale-rect theory (cold-start relayout settling slower than the fixed 200ms sleep).
** DONE [#C] Dotfiles stow conflicts: first-launch risk + restow directory handling :bug:dotfiles:quick:solo:
CLOSED: [2026-07-23 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-14
:END:
Closed 2026-07-23. The last open item was the velox check, and velox is reachable again. Pulled it from =02df01a= to =de62e9d= (clean tree, fast-forward), then ran =make conflicts hyprland=, which dry-runs every tier: "No stow conflicts", exit 0. Its old conflict copy had already cleared against the updated repo, so there was nothing for =make reset= to do.
Verified the pull is live through the symlinks rather than just present in the repo: =~/.local/bin/weather= resolves into the dotfiles tree and returns today's sun times on velox.
Note for the record: =make conflicts common= is not a valid invocation — =check-de= rejects it, because common and the host tier are auto-included in the DE-scoped run. =make conflicts hyprland= is the whole check.
*** 2026-07-14 Tue @ 00:51:51 -0500 Ratio calibre check passed; waypaper canonical decided (dark-lion)
Ratio's ~/.config/calibre is a directory symlink into the dotfiles repo (stow folded the whole dir), so the first-launch gap never existed there — check closed. Craig decided dark-lion.jpg is the canonical waypaper wallpaper; the repo config.ini updated from the that-one-up-there.jpg placeholder (the file is skip-worktree volatile, unskipped for the commit and re-flagged). Remaining: when velox is back online, run make conflicts / make reset there so its old conflict copy clears against the updated repo.
*** 2026-07-02 Thu @ 17:30:00 -0400 Shipped the Makefile hardening + first-launch guard (dotfiles 42a82d2)
The solo-able subset landed in the speedrun. =make conflicts <de>= is the loud first-launch guard: dry-runs all tiers, parses all four stow error shapes (plain file conflict, foreign symlink, dir-over-file, and restow's unstow_contents non-directory ERROR), lists each blocker with a directory/foreign-symlink marker, exits 1 when any exist. =make reset= now pre-clears the directory and foreign-symlink blockers =--adopt= aborts atomically on (removals printed; repo version wins per the target's contract), then adopts + git-checkouts as before. =make restow='s overwrite path switched rm -f → rm -rf so directory conflicts clear. 8 sandbox tests drive the real Makefile against a throwaway HOME (44 suites green). Also verified on velox: the whereami and mpd-playlists conflicts noted in this task were already hand-converted 2026-06-29 — =make conflicts hyprland= reports clean live. REMAINING (deferred per Craig's speedrun pre-flight): the waypaper canonical decision (live velox dark-lion.jpg vs repo that-one-up-there.jpg) and the ratio calibre-symlink check (ratio paused).
From the velox calibre incident (2026-06-27, note in ~/.dotfiles/inbox/processed/): calibre was launched before =make stow= ran, wrote its own default config into =~/.config/calibre/=, and silently blocked its own stow — it ran on factory defaults while the rest of common/ stowed fine. General pattern: any GUI app that auto-creates config on first run, launched before stow, blocks its own stow the same way. Velox was repaired by hand (=ln -srf= symlinks byte-identical to =stow --no-folding= output).
Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefile handling shipped in the speedrun; what's left is a paused-machine check):
- Waypaper canonical decision (Craig): RESOLVED 2026-07-14 — dark-lion.jpg is canonical (dotfiles fea3e93), repo config.ini updated off the that-one-up-there.jpg placeholder.
- Ratio check: RESOLVED 2026-07-14 — ratio's =~/.config/calibre= is a directory symlink into the repo (stow folded the dir), so the first-launch gap never existed there.
- When velox is back online: run =make conflicts= / =make reset= there so its old conflict copy clears against the updated repo. (velox carries a separate boot-recovery task; check once it's reachable.)
** DONE [#A] Hyprlock lockout: AMD-iGPU DPMS invalidates the lock, session wedges :bug:hyprland:installer:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as archsetup =a9391c9= + dotfiles =3046c9c=, both pushed; applied live to ratio and velox. Reboot ratio to activate the root fix (=amdgpu.runpm=0=); the watchdog covers until then.
WHAT HAPPENED. Ratio's screen idle-locked, then wedged: hyprlock gone, the compositor still holding the ext-session-lock, no password prompt, recoverable only from a console. Recovered live with =hyprctl dispatch exec hyprlock= (=allow_session_lock_restore=true= was already set, so a replacement client adopted the dead lock).
ROOT CAUSE (evidence, not the first guess). My first read was "hyprlock crashed on its screenshot buffer" — WRONG. Coredumps are captured here (two telega SIGSEGVs the same afternoon) and there is NO hyprlock coredump, so it did not segfault; memory was fine, so not OOM. The hyprland log shows the real chain: =Modesetting DP-4= / =Restoring crtc 86= (a display modeset) → =color management protocol is enabled and outputs changed= → =SessionLock.cpp:50 SessionLockSurface object remains but surface is being destroyed=. A display power cycle tore down the lock surface. Online research confirms it's a documented AMD-integrated-Radeon issue (hyprlock#953, Hyprland#5822): the GPU resources the lock client holds become invalid when the display powers down and back up. Ratio is a Strix Halo Radeon 8060S — exactly that hardware, and its cmdline already carried =amdgpu.dcdebugmask=0x10= + =no_vpe_idle_pg=1= display workarounds, a history of the same fragility.
THE FIX, four layers, research-validated:
1. Root cause: =amdgpu.runpm=0= on the kernel cmdline (AMD only, added in =update_grub_cmdline= behind =detect_gpu_vendors=). Keeps GPU runtime PM from invalidating the resources on a display cycle. Live in ratio's grub.cfg; effective next boot.
2. Separate crash cause: =configure_hyprlock_pam= writes a complete =/etc/pam.d/hyprlock= (auth/account/session). The package default is =auth include login= only, so pam_end() crashes on uninitialised handles. Applied live to both machines.
3. Recovery net: the =screen-lock= watchdog (dotfiles) relaunches hyprlock on a non-zero exit; hypridle's =lock_cmd= routes through it. Independently the same shape as the community's watchdog layer.
4. NOT done, deliberately: the =dpms off= listener stays in the committed hypridle — =runpm=0= makes it safe on AMD, and it's wanted on Intel/velox for idle display-off. Ratio's test rail already removed it as a local choice.
REVERTED a wrong turn: I'd first built a screenshot-to-file change (grim the desktop, point hyprlock at the file) on the theory the live screencopy buffer crashed. The research showed the cause is GPU runtime PM, not the background source, so I dropped it and reverted hyprlock.conf to =path = screenshot=.
PROCESS NOTE — I hit the pathspec-commit trap AGAIN (the one the =Two agent sessions sharing one repo= VERIFY documents). After surgically staging only the =lock_cmd= line via =git update-index=, I ran =git commit <path> -m ...=, which commits the WORKING TREE of that path, not the index — so it committed ratio's test rail (dpms-off removed, timeout 450) with a message claiming dpms-off stays. Caught it before push, =git reset --soft=, re-verified. The rule: after =update-index=, commit with =git commit= (no pathspec), never =git commit <path>=.
Tests: archsetup 372 (test_grub_cmdline AMD-runpm cases + test_hyprlock_pam, both call sites in CALL_SITES); dotfiles 3687 incl. tests/screen-lock. Each guard proven by deletion.
Grading: Critical severity (full session lockout, console-only recovery) x rare edge case (needs an idle lock plus a display modeset on the AMD iGPU) = P2 = [#B by the matrix]. Raised to [#A] here because it stranded a live machine and the root fix needs a reboot to arm — worth Craig seeing at the top until he reboots ratio.
** DONE [#B] Adversarial review of the sentry run — six fixes reworked :bug:test:tooling:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Craig asked for a skeptical review of every sentry change. Eight agents covered all 23 code commits, each told to disbelieve by default and to answer three questions per commit: does the problem exist and is it reachable, is the fix correct or is there a better one, would each test fail with the fix reverted. Every finding below was re-verified by hand before acting on it.
SIX COMMITS NEEDED WORK, now fixed: archsetup =1207ca5= (wipedisk), =96e12b5= (firmware trim), =560e1dd= (autologin), =3c2155d= (initramfs tabs); dotfiles =ec7229b= (tunnel import), =a81aa0e= (thumbnail sweep), =56807e5= (three residual guards), =c90ee34= (event-log isolation). Both suites green: archsetup 341, dotfiles 3687 on both gates.
THE ONE THAT MATTERED MOST. =wipedisk= ran =blkdiscard -f= BEFORE the busy check. =-f= disables the exclusive open util-linux has used since 2.36, so on the exact case the round-11 commit reasoned about — the user picked the wrong disk — it discarded a live filesystem and only then let sgdisk fail, printing "could not clear the partition table ... run this again". Data gone, user told nothing happened. The ordering predates the sentry commit, but round 11 wrote reasoning about the busy-disk case into the comment and error text while leaving the discard first, which made the misreport worse in the one direction that costs something. Dropping =-f= makes the kernel's own O_EXCL the gate.
THREE PATTERNS WORTH MORE THAN THE INDIVIDUAL FIXES:
1. CALL SITES WENT UNTESTED IN FIVE SUITES. Every helper had thorough tests; not one proved it was called. Deleting the call left everything green — including the guard on a =pacman -Rdd= of twelve firmware packages, whose removal would have run the trim on ratio. Closed with =CALL_SITES= in =test_orchestrators= (nine pairs, static) and a wiring assertion in the settings suite. Static on purpose: the behavioural harness runs un-stubbed bodies for real, which is fine for an orchestrator and not for a leaf that removes packages.
2. A NEW OUTCOME VALUE NEEDS EVERY CONSUMER WALKED, EVERY TIME. Done for the portal enum in round 3, skipped for the tunnel-import one in round 4 — where =import_configs= folded a disarm failure into "none imported (N failed)", the opposite of what happened, in the multi-select flow the GUI actually uses.
3. MY FIXTURES TWICE CLAIMED A FIDELITY THEY DID NOT HAVE. The wipedisk fixture used this machine's real disk names, so five of six tests passed with the seam removed. The mkplaylist fake does a full =cat > /dev/null= drain while its docstring says it "drains stdin exactly when the real one would" — which is what let the wrong failure mode survive.
AND ONE FINDING WAS DISPROVED OUTRIGHT: round 1's =a57c443= claimed ffmpeg drains the read loop so only the first track is processed. Measured under strace and driven end to end with real ffmpeg (three runs of three, four 120s mp3s), the loop never truncates. The hazard is real and =-nostdin= is right; the symptom was reasoned from shellcheck SC2095 and never run. Corrected in =a30741a=, along with the OpenVPN autoconnect claim and the "four consumers" undercount.
ALL THREE NOW CLOSED, in dotfiles =c7cb40d= (pushed). =_restore_dot='s =noop= split into =already-on= and =not-managed=, so the step stops claiming a restore that never happened. =_disable_dot= checks its restart as well as its move, since moving the drop-in aside does nothing until resolved reloads.
The thumbnail one could not be built as described, and that is worth recording. The cache name is a SHA-1 of realpath plus mtime, so no filename says which source it came from; per-source sweeping would mean changing the key format and invalidating every cached thumbnail. Bounding the growth gets the same result for less: a deferred sweep now trims to a 500-file ceiling, oldest first, because eviction is safe exactly where sweeping is not (an evicted thumbnail is rebuilt on the next warm pass, costing one decode and never a file). WHEN A FIX CANNOT BE BUILT AS SPECIFIED, SAY SO AND SOLVE THE ACTUAL HAZARD — the hazard here was unbounded growth, not imprecise attribution.
** DONE [#D] Repair tiers call an unverifiable service restart a failed one :bug:network:bluetooth:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as dotfiles =041d6b9= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). 7 new tests across =tests/bt/test_bt.py= and =tests/net/test_net.py=; dotfiles suite 3665 -> 3672, =make test= exit 0 on both gates. Each of the three guards proven a real gate by deleting it and watching the suite go red.
Found in the 2026-07-24 sentry bug-hunt, round 14, on the cross-package =repair.py= diff that rounds 5-13 had left unspent.
=cmd.service_active= is tri-state in both the net and bt packages, and its docstring says so outright: True, False, or None when systemctl itself can't answer (absent binary, or a timeout). Six callers. Three rule on it correctly — =bt/doctor._service_step= branches on None with "systemctl unavailable — can't check the service", and =net/diag= compares =is False= at both its call sites. Three tested it with plain truthiness:
- =bt/repair.py= =repair_service_restart=
- =net/repair.py= =_service_restart= (the nm-restart and resolved-restart tiers)
- =net/repair.py= =repair_unmask_nm=
So an unanswerable systemctl was reported as "bluetooth.service is still not active" / "NetworkManager still isn't running after a restart" — a statement about the service made on no evidence at all. Each then pointed the user at =journalctl -u <unit>=, which is the same systemd client stack that had just failed to answer. That last part is round 10's read again: an error message advertising a remedy it cannot honour.
All three now report =warn= on None, with evidence naming the verification rather than the service, and a next action of checking systemd is reachable and re-running the doctor. Control flow is unchanged: =warn= was already a status both packages emit, both CLIs already exit non-zero on anything but =pass=, and =net/doctor= only inspects a repair step's status for the =dns-test= tier — every consumer was checked before the change, not after. (An adversarial re-review counted twelve, not four; all twelve handle =warn= correctly, so the conclusion held while the claim understated the work.)
THE SEAM FOR THE TESTS, worth reusing: both suites already carry an exec-failure harness that plants a non-executable file on an emptied PATH, which is exactly what makes =cmd.run= return None. So the None case is reachable through the real code path with no mocking at all. Each test class asserts that premise first (=service_active= really is None in the sandbox) rather than assuming it.
Grading: Minor severity (the claim is wrong but errs pessimistic — it says a repair failed when it may have worked, rather than falsely reassuring; nothing is damaged) x rare edge case = P4 = [#D]. Fixed rather than filed because the change is three branches and it completes a class — leaving two of three sites collapsed is the failure mode the round-6 =c2eb3e1= commit exists to remember.
NOT PART OF THIS CLASS, checked and left alone: =settings/toggles.dim_state= is the only other genuine True/False/None helper in the tree, and both its callers pass the value through to the viewmodel rather than collapsing it. Every other "or None" in the packages is two-state (a value or nothing), where falsy handling is correct.
** DONE [#B] Firmware trim gated on a DMI field that never carries the vendor :bug:tooling:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as archsetup =2e228f7= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_framework_firmware_trim.py=, 12 tests carrying the real DMI strings off both daily drivers. Each of the three conditions proven load-bearing by deleting it and watching the suite go red, and the old gate proven wrong by restoring it (4 failures).
Found in the 2026-07-24 sentry bug-hunt, round 13, reading archsetup's remaining state-mutating steps. =trim_firmware= gated on =grep -qi "framework" /sys/class/dmi/id/product_name= and no Framework machine has "framework" in =product_name= — it lives in =sys_vendor=. Read live: velox is =Framework= / ="Laptop (13th Gen Intel Core)"=, ratio is =Framework= / ="Desktop (AMD Ryzen AI Max 300 Series)"=. The gate returns false on both, so the step has been a silent no-op on the exact hardware it was written for. velox IS trimmed today (=linux-firmware-{atheros,intel,realtek,whence}= and nothing else) but not by this code path.
THE REPAIR IS WHERE THE DANGER IS, which is why this is worth reading twice. Swapping =product_name= for =sys_vendor= is the obvious one-word fix and it is wrong: ratio is a Framework Desktop, and =trim_firmware= runs =pacman -Rdd linux-firmware-amdgpu=, which takes the firmware its Ryzen AI Max iGPU needs to bring up a display. Today only the =grep -qi intel /proc/cpuinfo= second gate stands between ratio and that. So =is_framework_intel_laptop= wants three DMI facts — vendor Framework, and a model naming both Laptop and Intel — and the cpuinfo read stays as an independent second gate rather than the only one.
Verified live after the change: velox TRIM=yes, ratio TRIM=no, where the old gate said no to both.
Grading: Minor severity (the trim never happens; nothing breaks, the machine just carries ~550MB it was meant to shed) x every user, every time (every Framework Intel install, which is the whole population the step targets) = P2 = [#B]. The AMD-firmware removal is not graded separately because it never shipped — it is the hazard the fix is shaped to avoid.
** DONE [#B] Fresh install leaves the dotfiles repo permanently dirty :bug:tooling:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as archsetup =c3b3617= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_mark_volatile_configs.py=, 8 tests against a fixture git repo with =sudo= stubbed on PATH. Every guard proven a real gate by deletion. A note went to =~/.dotfiles/inbox/= because =skip-volatile= now has an outside caller.
Found in the 2026-07-24 sentry bug-hunt, round 13, diffing archsetup's =stow_dotfiles= against the dotfiles Makefile's =stow= target — two implementations of one operation, which is round 5's read applied across repos rather than across packages.
The Makefile's =stow= target ends with =$(MAKE) skip-volatile=, setting git's skip-worktree bit on the four configs their apps rewrite in place (=btop=, =qalculate=, =calibre=, =waypaper=; the list is =volatile-configs=). archsetup stows inline with raw =stow= calls and never ran that step. So a machine archsetup installed goes dirty the first time one of those apps writes its config, and every later =git pull --ff-only= trips over paths the user never edited. Confirmed by grep: archsetup contains no =skip-volatile=, no =volatile=, and no =make stow= — yet both daily drivers carry the bits, so they came from a hand-run =make stow=, not the installer. ratio in fact carries seven, three more than =volatile-configs= lists, which is evidence the churn is real and ongoing.
The fix calls the dotfiles target rather than copying its logic, so the volatile list stays single-source. Two details that are load-bearing: it runs *after* =git restore .= so the bit lands on a pristine tree, and it runs as the user, because root writing =.git/index= leaves it root-owned and the user's next git command then cannot update the index at all. A checkout with no Makefile is a quiet no-op — nothing to delegate to is not an error.
DELIBERATELY NOT DONE: replacing the whole inline stow with =make -C "$dotfiles_dir" stow "$desktop_env"=. The Makefile stows =--target=$(HOME)=, which during an install is root's home, and it carries interactive conflict handling; archsetup stows =--target=/home/$username --adopt= as root on purpose. =skip-volatile= is the one target with no such coupling — it works on the repo through =git -C= and never reads HOME.
Grading: Minor severity (a repo that reads dirty forever and pulls that need a stash; the workaround is one command) x every user, every time (every fresh install that stows dotfiles) = P2 = [#B].
** DONE [#C] Unattended install blocks on an interactive prompt :bug:tooling:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as archsetup =cbcb53f= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_configure_autologin.py= (11) and =tests/installer-steps/test_select_locale.py= (11). Every guard proven a real gate by breaking it and watching the suite go red: dropping the autologin unattended branch fails 1 (on the leftover-stdin assertion, which is the real gate — the drop-in still gets written because the read swallows the sentinel and treats it as "yes"); dropping the locale unattended branch fails 1; breaking either precedence rule fails 2.
Found in the 2026-07-24 sentry bug-hunt, round 12, continuing through archsetup's own installer. Two members of one class, which is the point: round 10 fixed the third member and left these.
THE CLASS: an advisory prompt — one that carries its own default — still reading stdin under =--config-file=, the documented unattended mode. Round 10 ruled on it for =nvidia_preflight='s rc-10 prompt. Two sites never got the ruling.
1. =configure_autologin=. When =enable_autologin= is unset (=AUTOLOGIN= is optional, and =archsetup.conf.example= line 31 ships it commented out) and the root is encrypted, it prompted =Enable automatic console login for $username? [Y/n]= on a bare =read=. It runs from =configure_encrypted_autologin=, inside =boot_ux=, the last entry in =STEPS= — so an unattended install of an encrypted machine works for 40-60 minutes and then sits at a prompt nobody is watching. Under =curl | bash= it is worse: stdin is the script itself, so the read eats a line of source.
2. =select_locale= (extracted from =preflight_checks= by this commit). The =Choice [1]:= menu fired whenever =/etc/locale.conf= carried no =LANG== and =LOCALE= was unset — also commented out in the example config. archsetup does not require an archangel install, and =configure_build_environment='s own "no LANG=" branch is proof it expects that state.
Both now take the prompt's own default under =--config-file= and print an =[OK] ... (unattended, --config-file)= line saying so. An explicit =AUTOLOGIN=yes/no= or =LOCALE== still wins; the default only answers a question nobody can.
WHAT MADE THEM TESTABLE, which is round 10's read (d) applied again: =configure_autologin= hardcoded =/etc/systemd/system/getty@tty1.service.d= and =select_locale= hardcoded =/etc/locale.conf=, so neither could run against a fixture — while their siblings =replace_sudoers_pacnew= and =ensure_nvme_early_module= both take a defaulted path argument for exactly that reason. Both now do. Zero shellcheck delta against HEAD; =make test-unit= 276 -> 298, exit 0.
Grading: Major severity (unattended installation, a documented feature, does not complete; recoverable by pressing a key, no data loss) x some users, sometimes (needs unattended mode plus an omitted key) = P3 = [#C].
THE PROMPTS DELIBERATELY LEFT ALONE, because the class is "prompts with a default", not "all prompts": username (line 636) and password (648/650) have no default to take — there is no sane fallback for either, and =archsetup.conf.example= documents both as "If not set, you will be prompted". They also fire in =preflight_checks=, in the first second of the run, where a blocked prompt is visible rather than silent. The "Enter locale" sub-prompt is reachable only from menu choice 9, which unattended never picks.
** DONE [#C] wipedisk says "Disk erased." when it erased nothing :bug:tooling:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/wipedisk/test_wipedisk.py=, 6 tests running the real script against a fixture device directory with fake blkdiscard/sgdisk on PATH. All four guards proven real by deleting each and watching the suite go red.
Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 30 lines, no tests, and the most destructive script in the repo. Not installed by the installer; it is run by hand from the checkout, which is why the frequency axis stays low.
Three defects, all of which make the script's final word untrue:
1. =sgdisk --zap-all= had its result discarded, and "Disk erased." printed unconditionally. sgdisk refuses a busy device — a mounted filesystem or a live md/LVM/ZFS holder — which is exactly what a user hits after picking the wrong disk. So the tool announced an erase it had not performed and exited 0.
2. "Disk erased." overstates what the tool does even on success. =sgdisk --zap-all= destroys partition tables, not data, and =blkdiscard -f ... || true= deliberately tolerates a device that cannot discard. On a disk without discard support the script cleared the partition table and left every byte readable, while telling the user the disk was erased. That is the one path where the wrong belief has a privacy consequence — someone trusting the message before disposing of a drive.
3. The prompt says "Select the disk id to use" and then listed every entry in =/dev/disk/by-id=. On this machine that is 18 entries of which 12 are =-partN= partitions (verified by listing it). The menu promised disks and offered partitions.
Fix: whole disks only (globbed rather than =ls | grep=, so a name with whitespace cannot split into two menu entries); the zap's result is checked and a failure exits 1 naming the busy-device cause; the closing message reports what actually happened, and when discard was unsupported it says the data is still recoverable and points at =nvme format= / =hdparm= for a disposal-grade wipe.
Grading: Major severity (the tool reports an outcome it did not achieve; in the disposal case that is a data-exposure consequence) × rare edge case (a hand-run helper the installer does not install, and defect 1 additionally needs sgdisk to fail) = P3 = [#C].
Worth recording about the tests rather than the code: two of the six passed against the unmodified script for the wrong reason. Without the =WIPEDISK_BY_ID= override the script read the real =/dev/disk/by-id=, so the harness was driving a menu of this machine's actual disks (harmless — the fake blkdiscard/sgdisk shadowed the real ones on PATH — but it was not testing the fixture). And =test_empty_by_id_directory= was not a gate at first: with the guard deleted the empty select menu still falls through to the confirm prompt, reads EOF and declines, so exit code and call log alone pass either way. It now asserts the message.
** DONE [#B] zfs-replicate reports success when every backup failed :bug:backup:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). Diagnostics moved to stderr; the loop counts failures and exits 1 when any dataset failed. New =tests/zfs-replicate/test_zfs_replicate.py=, 9 tests driving the real script with a fake syncoid and a fake ping on PATH (the =tests/zfs-pre-snapshot/fake-zfs= pattern). Both fixes proven real gates by reverting them: dropping the counter fails 3, putting =error()= back on stdout fails 1.
Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 73 lines with no test file, installed by =configure_zfs_snapshots= as =/usr/local/bin/zfs-replicate= and run by =zfs-replicate.service=, a =Type=oneshot= on a nightly timer. Its exit code and its journal output are the only signals anyone ever sees.
Two defects, both verified by running the script rather than argued:
1. The full-replication loop caught each =syncoid= failure, warned, carried on, then printed "Replication complete." and exited 0 regardless. Driven with a fake syncoid failing all four datasets: four =[WARN] Failed= lines, then "Replication complete.", exit code 0. systemd records =Result=success=. A backup that has not run for months is indistinguishable from a working one — and the whole point of the tool is to have a copy when the primary is gone.
2. =determine_host= runs inside a command substitution (=TRUENAS_HOST=$(determine_host)=) and its =error()= wrote to stdout. On an unreachable TrueNAS the message was captured into =TRUENAS_HOST= and discarded, and =set -e= then killed the script. Driven with both hosts unreachable: exit 1 and completely empty output. A nightly service failing with nothing in the journal to say why.
Same class as three bugs already fixed this session — =_restore_dot= claiming "DNS-over-TLS restored" without checking, =portal_restore_watch= discarding its outcome, =import_config= returning ok on an unchecked modify. A mutating operation that reports a success it did not get.
Grading: Critical severity (a backup system that reports success while backing nothing up; the failure surfaces only when the backup is needed — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs a ZFS root, a reachable TrueNAS, and the user enabling the timer by hand — archsetup deliberately does not enable it, and =findmnt -n -o FSTYPE /= on this machine says btrfs, so it is latent here) = P2 = [#B].
Left alone: =BACKUP_PATH="backups" # TODO: Configure actual path= is still an unresolved TODO in the destination, and single-dataset mode relies on =set -e= to propagate a syncoid failure rather than reporting it. Neither is a defect in the sense above; the TODO is Craig's call.
** DONE [#D] Wireless regdom is silently unset for a three-letter-language locale :bug:installer:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as archsetup =249bb93=. =locale_country= matches the =_CC= group instead of counting characters, and =set_wireless_regdom= verifies the substitution landed rather than trusting sed's exit code. 16 tests.
=configure_networking= derives the wireless regulatory domain by fixed offset: =wireless_region="${current_lang:3:2}"=, with a comment reading "extract country code (positions 3-4)". That is correct only for a two-letter language code.
=validate_config= accepts =^[a-z]{2,3}(_[A-Z]{2})?...=, so a three-letter language is a legal =LOCALE=, and glibc ships 75 of them (=agr_PE=, =ast_ES=, =ber_DZ=, =ayc_PE=, ...). Verified by running the expansion: =ber_DZ.UTF-8= yields =_D=, =ayc_PE.UTF-8= yields =_P=, =C= yields the empty string, =POSIX= yields =IX=.
The sed that follows only uncomments an existing =#WIRELESS_REGDOM="XX"= line in =/etc/conf.d/wireless-regdom= (176 of them, owned by wireless-regdb). A garbage region matches nothing, sed exits 0, and the =|| error_warn= never fires — so the regdom is never set and nothing says so. The task line does print the garbage region ("configuring wireless regulatory domain (_D)"), so it is visible in the log rather than fully silent.
Confirmed the mechanism itself works for the normal case: line 168 of this machine's =/etc/conf.d/wireless-regdom= reads =WIRELESS_REGDOM="US"= uncommented, which is archsetup's own edit.
Grading: Minor severity (WiFi falls back to the conservative "00" regdomain — fewer channels and lower tx power, but WiFi works) × rare edge case (one of 75 three-letter-language locales, or a =LOCALE= with no country) = P4 = [#D].
Fix when it comes up: derive the country from the =_CC= group by pattern rather than by offset, and warn when it cannot be derived or when the sed changed nothing. Worth doing together with the sibling gap — nothing in the installer verifies that a =sed -i= uncomment actually matched, so a distro reshuffling one of these config files would fail the same silent way. All 22 =sed -i= sites share that stance, so it is a uniform design choice rather than an odd one out.
** DONE [#B] Initramfs hook swap can leave a LUKS machine unbootable :bug:installer:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The swap moved into =switch_udev_hook_to_systemd=, which declines when =hooks_need_busybox_init= sees a standalone =encrypt= token, and the caller now rebuilds the initramfs only when the conf actually changed. New =tests/installer-steps/test_switch_udev_hook.py=, 10 tests; both guards proven real by breaking them (removing the refusal: 4 failures; loosening the token match to a bare =encrypt= substring: 1 failure).
Found in the 2026-07-24 sentry bug-hunt, round 10. =configure_initramfs_hook= ran =sed -i '/^HOOKS=/ s/\budev\b/systemd/'= on any non-ZFS root, then =mkinitcpio -P=. Its only guard was =is_zfs_root=.
Why that breaks a LUKS machine, verified against the installed mkinitcpio rather than argued:
- =/usr/lib/initcpio/install/systemd= line 70 is =add_symlink /init usr/lib/systemd/systemd=, so the systemd hook replaces the busybox init outright.
- =/usr/lib/initcpio/hooks/encrypt= is an =#!/usr/bin/ash= script whose entire body is a =run_hook()= function — the busybox init's mechanism. Under systemd init nothing calls it.
- =mkinitcpio= carries no conflict check for the pairing (grepped; nothing), so the rebuild succeeds and archsetup reports success.
- This machine's own =/etc/mkinitcpio.conf= documents the two valid pairings as separate examples: =udev= + =encrypt= (line 45) and =systemd= + =sd-encrypt= (line 51). The sed converted half of the first pairing and produced neither.
Effect: on a LUKS root using the standard busybox =encrypt= hook, archsetup rewrites HOOKS to =systemd= while leaving =encrypt= behind, rebuilds the initramfs, and exits cleanly. At the next boot the root is never unlocked. The machine needs live media and manual mkinitcpio surgery to recover.
The sibling asymmetry: =is_encrypted_root()= already exists in this script and =configure_autologin= uses it to branch on exactly this condition. The initramfs step consulted neither it nor HOOKS. =merge_grub_cmdline='s own comment names =cryptdevice== as a boot-critical parameter to preserve — and =cryptdevice== is read only by the =encrypt= hook, so archsetup explicitly anticipates the configuration that another of its steps then breaks.
Grading: Critical severity (the machine will not boot and recovery needs external media — graded on the harm once in the failure state, not on how rarely it is entered) × some users, sometimes (LUKS-encrypted non-ZFS root using the busybox =encrypt= hook; deterministic for those machines, absent everywhere else) = P2 = [#B].
Deliberately not attempted: migrating =encrypt= to =sd-encrypt=. That means rewriting the kernel cmdline from =cryptdevice== to =rd.luks.name== against the volume's UUID, which is a real migration and not a mechanical edit. Refusing the cosmetic swap keeps a working machine working, which is the right trade against quieter fsck output.
** DONE [#D] keymap and consolefont hooks are inert under the systemd initramfs :bug:installer:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as archsetup =249bb93=. The swap rewrites both to =sd-vconsole=, collapsing them into one entry and never duplicating an existing one. The open question is answered: this machine is KEYMAP=us with no encrypt hook, but the function runs on LUKS machines where a non-US layout at the passphrase prompt is exactly what sd-vconsole restores. 7 tests.
Same class as the =encrypt= bug above, but cosmetic rather than boot-critical, so it was filed rather than bundled into that fix.
Enumerating the busybox-only hooks on this machine (every hook under =/usr/lib/initcpio/hooks/= defining =run_hook=/=run_earlyhook=/=run_latehook=) gives: btrfs, consolefont, encrypt, grub-btrfs-overlayfs, keymap, memdisk, resume, sleep, udev, usr. All go inert once =/init= is systemd. Of those, =encrypt= is the only boot-critical one — =resume= is handled natively by systemd's hibernate-resume generator, and =btrfs= by udev rules (this machine runs =btrfs= alongside =systemd= and boots fine).
=keymap= and =consolefont= are the live leftovers. Run =grep '^HOOKS=' /etc/mkinitcpio.conf= on this machine: the line carries =systemd= plus =keymap consolefont= and no =udev=, so archsetup's swap has already run here and both hooks are installed into the image and never executed. The systemd equivalent is the single =sd-vconsole= hook, which is what the distro's own systemd example on line 51 of =/etc/mkinitcpio.conf= uses.
Effect: the early-boot console keeps the default font and keymap until =systemd-vconsole-setup= runs in the real root. =add_nvme_early_module= sets =FONT=ter-132n= in =/etc/vconsole.conf= expecting it to apply at that stage, so the configured font is briefly not what archsetup asked for.
Grading: Cosmetic severity (a few seconds of default console font on a machine that boots normally) × some users, sometimes = P4 = [#D].
Fix when it comes up: have =switch_udev_hook_to_systemd= also rewrite =keymap consolefont= to =sd-vconsole= when it performs the swap, and add the fixture cases to =tests/installer-steps/test_switch_udev_hook.py=. Worth confirming first whether a non-US keymap is ever needed at the initramfs prompt on a machine that reaches this path.
** DONE [#B] NVIDIA Wayland preflight blocks dwm and headless installs :bug:installer:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The NVIDIA block moved out of =preflight_checks= into a new =nvidia_preflight= function that returns early unless =desktop_env= is =hyprland= and archsetup is the one installing drivers. New =tests/nvidia-preflight/test_nvidia_preflight_gate.py=, 11 tests; each of the three guards was proven a real gate by deleting it and watching the suite go red (3, 1, and 1 failures respectively).
Found in the 2026-07-24 sentry bug-hunt, round 10, reading archsetup's own installer. =preflight_checks= called =nvidia_preflight_report= unconditionally and exited 1 on rc 11 (repo driver below the 535 Wayland floor, or =pacman -Si nvidia-utils= unable to answer). The check is Wayland-specific — every line it prints names Wayland/Hyprland — but it ran before any =desktop_env= branch and consulted neither =desktop_env= nor =skip_gpu_drivers=.
Effect, proven empirically rather than argued (three scenarios driven against the extracted block): =DESKTOP_ENV=dwm= plus =--no-gpu-drivers= on an NVIDIA machine with an old repo driver aborts the install; so does =DESKTOP_ENV=none=. Neither install ever runs a compositor, and =--no-gpu-drivers= means the user installs the driver themselves. Worse, the abort's own fix hint reads "install with DESKTOP_ENV=dwm (X11) instead" — the one remedy it prints is the one it refuses to honor, so the user has no working workaround short of editing the script.
The sibling asymmetry that makes it an oversight rather than a decision: =install_gpu_drivers= returns early on =skip_gpu_drivers=, and =display_server= / =window_manager= both branch on =desktop_env= with a =none= arm that skips outright. The preflight gate applied neither ruling.
Second defect at the same site, fixed in the same commit: the rc-10 path (card detected, driver fine) prompts with a bare =read=. =--config-file= is documented as "unattended installation", and =aur_install= already rules that a prompt not covered by =--noconfirm= "blocks forever waiting for input" on a headless install. The rc-10 prompt is advisory, so it now answers itself with its own =[Y/n]= default when a config file was supplied. rc 11 stays a hard stop either way.
Grading: Critical severity (archsetup cannot be run at all on that machine, and the printed workaround does not work — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs an NVIDIA card, a repo driver below the floor or an unsynced pacman db, and a non-hyprland =desktop_env=; hyprland is the default and Craig's own machines are AMD and Intel) = P2 = [#B].
Noted, not fixed: =display_server= and =window_manager= both point their unknown-value hint at a =--desktop-env= flag that the argument parser does not implement. Both arms are unreachable today (=validate_config= rejects a bad =DESKTOP_ENV=, and without a config file the value is always the default), so it is a stale string rather than a live defect.
** DONE [#B] mkplaylist retags only the first file :bug:music:quick:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as dotfiles =a57c443= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). =ffmpeg -nostdin= on the conversion call. New =tests/mkplaylist= suite, 12 tests; removing the flag turns the suite red (verified by reverting: 5 failures, green on restore). NOTE: the fake ffmpeg does a full =cat > /dev/null= drain, which the real one does not do — so the suite gates the flag's presence, not the production failure mode. The docstring claiming the fake "drains stdin exactly when the real one would" is false and should be corrected.
Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2095). =common/.local/bin/mkplaylist=: =generate_music_m3u= pipes the file list into =tag_music_file= (line 130), which consumes it with =while IFS= read -r file=. Inside that loop, =ffmpeg -i "$file" -vn -c:a flac "$outputfile"= (line 46) reads stdin by default for its interactive keyboard controls, so it consumes bytes the loop is relying on.
CORRECTION (2026-07-24, from an adversarial re-review): the failure mode stated above — "the loop sees EOF and exits after the first file" — is WRONG, and this task originally asserted it. Measured under strace, ffmpeg polls fd 0 and reads roughly one byte per half-second of transcode wall time; flac encoding runs about 2000x realtime, so a ten-minute mp3 converts in ~0.28s and yields zero or one stolen byte, never a drain. Driven end to end with real ffmpeg against four 120s mp3s, three runs of three: all four were converted and retagged every time. The loop never truncated.
What is real is the hazard, not the observed symptom: one stolen byte mangles a path, which makes mid3v2/metaflac fail and =set -e= abort the run loudly. =-nostdin= is still the right fix and the commit still stands. The original finding came from shellcheck SC2095 plus reasoning, and was never run — which is exactly what "verify before filing" exists to prevent.
Effect: on a directory of non-flac audio, only the first file is converted and retagged. Files 2..N are silently skipped — no error, no output, and the playlist itself still generates (a separate =find=), so nothing signals that the retagging stopped.
Grading: Major severity (the retagging feature is broken past the first file, and it fails silently) × most users frequently (the script exists to batch-process a directory, so more than one non-flac file is the normal case) = P2 = [#B].
Fix: =ffmpeg -nostdin= (or =< /dev/null= on the call). Verifiable with a fake =ffmpeg= on PATH asserting it is invoked once per input file.
** DONE [#C] timezone-change prints command-not-found instead of its help :bug:tooling:quick:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review), together with the Portugal-zone defect below. New =tests/timezone-change= suite, 12 tests.
Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2288). =common/.local/bin/timezone-change=, default =*)= case (lines 63-67): =echo= sits alone on its own line, so the following quoted string runs as a *command* rather than as its argument.
#+begin_src sh
*)
echo
"Invalid option chosen."
echo
"Some valid options are: eastern, central, pacific, rome, london, st_lucia, italy, france, spain ."
;;
#+end_src
The user gets two blank lines and two =command not found= errors; the list of valid options never prints. The timezone is correctly left unchanged, so this is an output defect only.
Grading: Minor severity (wrong output on an error path, nothing corrupted) × some users sometimes (only on an unrecognized option) = P3 = [#C].
Fix: fold each string into its =echo=. Verifiable by running the script with a bogus argument and asserting the option list appears on stdout.
** DONE [#C] Thumbnail sweep wipes the whole cache when a wallpaper source is unreadable :bug:settings:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as dotfiles =0bd8c67= (committed locally, deliberately NOT pushed — held for Craig's morning review). 8 new tests.
Found in the 2026-07-24 sentry bug-hunt, reviewing the orphan sweep shipped the night before (dotfiles =e752a16=). =os.walk= stays silent about a directory it cannot enter, so =wallpaper.scan_sources= returns =[]= for a source that is missing, renamed, or permission-denied — the same answer it gives for a gallery the user emptied on purpose. =settings/cli.py= tick then hands that empty list to =thumbstore.sweep_orphans=, =live_names= comes back empty, and every cache-shaped file is classified an orphan.
Proven empirically rather than reasoned: seeding three well-formed thumbnails plus a stray README, then sweeping against a nonexistent source directory, deleted all three (the README survived, so the cache-name regex guard works — it just doesn't help here).
Effect once entered: the entire persistent thumbnail cache is deleted, so the next wallpaper-view open pays the cold-decode cost the cache was built to remove (measured at 3.7s for a viewport of Craig's largest 8, which is what tripped the compositor's kill prompt), and the tick needs roughly ten idle beats — about twenty minutes — to rewarm at =WARM_PER_BEAT= 8.
Grading: Major severity (grading the being-in-it, per the don't-double-count-rarity rule: the cache is gone, the original freeze returns, and recovery is unattended and slow) × rare edge case (both configured sources — =~/videos/wallpaper= and =~/pictures/wallpaper= — are local directories, so this needs one deleted, renamed, or made unreadable while a beat fires; a removable or network source would hit it routinely) = P3 = [#C].
Fixed in this session: new =wallpaper.sources_available(sources)= tells "readable and empty" apart from "could not read", and =sweep_orphans= grew a =sources_ok= parameter that declines to sweep when it is False. Deferring a sweep costs only some stale files; sweeping wrongly costs the whole cache.
** DONE [#C] timezone-change sets a nonexistent zone for Portugal :bug:tooling:quick:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review). =Europe/Lisbon=. The suite also pins the general invariant: every zone the script can emit must exist in tzdata, so a future bad entry fails at test time rather than in Craig's hands.
Found in the 2026-07-24 sentry bug-hunt, validating every zone the script sets against =/usr/share/zoneinfo=. =common/.local/bin/timezone-change= line 39 maps =portugal= / =lisbon= to =Europe/Portugal=, which is not a tzdata identifier — the real one is =Europe/Lisbon= (a bare =Portugal= legacy alias also exists at the top level, but not under =Europe/=). =timedatectl set-timezone "Europe/Portugal"= fails, so the timezone is never changed.
The other 17 zones the script sets all resolve correctly, so this is the single bad entry.
Grading: Major severity (the option is wholly broken — the zone is not set and the command errors) × rare edge case (one option of eighteen, hit only when actually switching to Portugal) = P3 = [#C].
Fix: =Europe/Lisbon=. Verifiable by asserting the argument handed to a fake =timedatectl=, plus a suite-wide check that every zone the script names exists in the tzdata database.
** DONE [#C] settings-project stop() can SIGTERM an unrelated process :bug:settings:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Found in the 2026-07-24 sentry bug-hunt, round 2, reviewing =settings/src/settings/project.py=. =stop()= read a pid out of =$XDG_RUNTIME_DIR/settings-project.pid= and SIGTERMed it with no check that the pid still belonged to the projection. A projection that dies without running =stop()= (crash, OOM, a failed =execvpe= on the clock path — that last one was already noted as tolerated residue) leaves the file behind, so once the kernel wraps its pid counter that pid can name something else entirely, and the next =start= or =stop= kills it.
This is a hazard the codebase had already ruled on elsewhere and simply hadn't applied here: =maint/src/maint/doctor.py= revalidates =/proc/<pid>/comm= against the expected name before its KILL remedy fires, explicitly to refuse recycled pids.
Grading: Major severity (grading the being-in-it — an arbitrary user process takes a SIGTERM, and an editor with unsaved work is a plausible victim) × rare edge case (needs an unclean exit *and* pid reuse; =pid_max= here is 4194304, so wrap-around takes a very long time) = P3 = [#C].
Fixed as dotfiles =722994e= (committed locally, deliberately NOT pushed — held for Craig's morning review). The pidfile now records the process start time from =/proc/<pid>/stat= next to the pid, and =stop()= fires only when the recorded value still matches the live process. Start time is the right token rather than =comm=: it is mode-independent (the clock channel execs into =python3=, so comm changes while comm-matching would have needed per-mode knowledge) and it is exec-stable, verified directly — pid and start time were identical either side of an =execvpe=. A recycled pid cannot reproduce it. Legacy bare-pid pidfiles keep the old unconditional behavior so the upgrade never strands a live projection.
** DONE [#C] wtimer alarms fire an hour off on the eve of a DST change :bug:timer:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Found in the 2026-07-24 sentry bug-hunt, round 3, reading =timer/src/timer/engine.py=. =parse_alarm= resolves a bare wall-clock time ("07:00") to its next occurrence: it builds today's instant, and when that is already past it rolled forward with =epoch += 86400=. A DST day is 23 or 25 hours long, so a fixed 86400 lands on the wrong wall time whenever tomorrow crosses a transition.
Reproduced against America/Chicago and the two 2026 US transitions. Asking for =07:00= at 08:00 on Sat 2026-03-07 (spring forward that Sunday) gave 08:00 Sunday — an hour late. Asking for =07:00= at 08:00 on Sat 2026-10-31 (fall back that Sunday) gave 06:00 Sunday — an hour early.
The recurring path was never affected, which is what makes this an oversight rather than a design choice: =next_alarm= walks candidate days and rebuilds =datetime(y, m, d, hh, mm)= per day, so it is already DST-correct. Only the one-shot rollover took the shortcut. Both were pinned by the new tests.
Grading: Major severity (grading the being-in-it — an alarm that fires an hour off has wholly failed at the one thing an alarm does, and the fall-back direction wakes you early while the spring-forward direction lets you oversleep) × rare edge case (two nights a year, and only when the requested wall time has already passed today) = P3 = [#C].
Fixed as dotfiles =9b6c2c9= (committed locally, deliberately NOT pushed — held for Craig's morning review). The rollover now rebuilds the local time on tomorrow's calendar date, the same construction =next_alarm= uses. Eight tests pin =TZ=America/Chicago= (saved and restored around each case), covering both transitions, the twelve-hour form, an ordinary-day control, a DST eve where the requested time is still ahead, and two characterization cases asserting the recurring path stays DST-safe.
** DONE [#B] net portal-restore claims encrypted DNS is back without checking :bug:net:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Found in the 2026-07-24 sentry bug-hunt, round 3, reading =net/src/net/repair.py=. A captive-portal login moves the DNS-over-TLS drop-in aside so plain DNS can reach the venue's login page, and =_restore_dot()= moves it back afterwards. It fired both privileged steps — the =mv= and the =systemctl restart systemd-resolved= — and returned ="restored"= without reading either result. =repair_portal_restore()= then rendered a pass step reading "DNS-over-TLS restored".
So a declined or failed =sudo -n mv= left DNS-over-TLS off while the tool told the user it was back on. The same for a resolved restart that fails: the drop-in is on disk but the running resolver is still serving plain DNS.
The asymmetry is what makes it an oversight rather than a decision. The sibling =_disable_dot()=, twenty lines up, checks its own move with =_ok()= and returns False rather than claiming a success it did not get. The restore half simply never got the same treatment, and it is the half where the failure is silent — the disable path's failure is visible immediately because the portal page won't load.
Grading: graded on severity alone under the privacy carve-out. DNS queries continue in cleartext to the venue resolver on an untrusted network, and the affirmative "restored" message is what removes the user's reason to check. Bounded by =net diagnose='s =encrypted-dns= step, which exists precisely to catch a portal run that never restored, so the exposure ends at the next diagnose rather than persisting unseen forever. Major severity = P2 = [#B].
Fixed as dotfiles =018c0c5= (committed locally, deliberately NOT pushed — held for Craig's morning review). Both privileged steps are now checked, with two new outcomes: ="failed"= when the move back fails (encrypted DNS still off, rendered as a fail step) and ="unapplied"= when the drop-in is back but resolved would not restart (rendered as a warn step). Each names the command to run by hand. Four tests cover both failures at the =_restore_dot()= and step levels, mirroring the existing declined-move test on the disable side.
** DONE [#B] the portal restore watcher fails silently, so DNS stays in the clear :bug:net:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Found in the 2026-07-24 sentry bug-hunt, round 4, reading the rest of =net/src/net/repair.py= after the round-3 fix above. =portal_restore_watch()= polls until the link comes back online, calls =_restore_dot()=, and discards the outcome entirely.
Three things compound into a silent failure. The watcher is spawned detached with =stdin=, =stdout=, and =stderr= all on =/dev/null=, so nothing it could print reaches anyone. It runs outside the =repair()= dispatch, so unlike every other mutating tier it never wrote an event-log line either. And =repair_portal_login= tells the user "encrypted DNS restores itself once you're online", which is precisely what removes their reason to check. A ="failed"=, ="unapplied"=, or ="ambiguous"= restore therefore left the machine on plain DNS on a venue network with no signal at any level.
This is the round-3 finding one layer out, and the asymmetry is the tell: =018c0c5= taught =repair_portal_restore()= — the *manual fallback* — to stop claiming a success it did not get, while the *automatic* path, the one that actually runs in the normal flow, kept dropping the same result on the floor. Fixing the fallback and leaving the primary silent is a worse split than the original bug.
Grading: graded on severity alone under the privacy carve-out, exactly as the round-3 sibling. Same exposure (cleartext DNS to an untrusted venue resolver), same bound (=net diagnose='s =encrypted-dns= step catches the stranded state), and the same affirmative promise removing the reason to look. Major severity = P2 = [#B].
Fixed as dotfiles =601c5b4= (committed locally, deliberately NOT pushed — held for Craig's morning review). The watcher now returns the outcome, appends a =portal-restore-watch= event with it, and fires a persistent =notify security= alert on each of the three failing outcomes, each naming the command to run by hand. A clean restore stays silent. Five tests: one per failing outcome, one pinning the silence on a clean restore, and one on the event-log line. The whole =TestPortalLogin= class now shadows =notify= with a logging fake, so no future watcher test can fire a real desktop notification mid-suite.
** DONE [#D] dns-override failure path says "reverted" without checking :bug:net:quick:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as dotfiles =2cf3fb3=. The revert is checked; a declined one now says 1.1.1.1 is still set and names =resolvectl revert <iface>=.
Found in the 2026-07-24 sentry bug-hunt, round 3, sweeping for siblings of the portal-restore finding above. =net/src/net/repair.py=, =repair_dns_override()= failure path: when the 1.1.1.1 override doesn't restore resolution, it calls =priv.run("dns-revert", iface)=, discards the result, and returns evidence reading "override didn't restore resolution — reverted". A failed revert leaves 1.1.1.1 set on the link while the step says it was removed.
Same defect class as the portal-restore bug, three hundred lines up in the same file, and it survived the sweep only because the consequence is much smaller. Every other mutating repair in this file verifies by re-measuring afterwards rather than by reading an exit code, which is the stronger pattern and is why the sweep otherwise came back dry.
Grading: Minor severity (a stale per-link override sends DNS to Cloudflare instead of the venue resolver, it dies on the next reconnect, and =net diagnose='s =dns-override-present= step exists specifically to catch it) × rare edge case (needs the override to fail *and* the revert to fail) = P4 = [#D].
Fix: the same idiom the portal-restore fix now uses. Wrap the revert in =_ok()= and drop the "— reverted" claim (or say the revert failed and name =resolvectl revert <iface>=) when it returns False. The existing =RepairHarness= makes the privileged call fail with =NET_SUDO="false"=, so the test is a near-copy of =test_restore_reports_failure_when_the_move_back_is_declined=.
** DONE [#B] a timezone-less Date header crashes the whole net diagnose run :bug:net:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/diag.py=. =_clock_skew_s()= fetches the probe server's =Date= header with =curl -sI=, parses it with =parsedate_to_datetime=, and subtracts it from a timezone-aware =datetime.now(timezone.utc)=. RFC 5322 allows a =Date= to carry =-0000=, which means UTC while explicitly claiming no local zone, and a =Date= with no zone at all parses leniently as well. Both come back *naive*, and subtracting a naive datetime from an aware one raises =TypeError=.
The =try= wraps only the =parsedate_to_datetime= call, so the =TypeError= from the line below it is uncaught. It escapes =_clock_skew_s=, escapes =_steps_egress_edges=, and takes down the entire =diagnose()= run — no report, no steps, a Python traceback. =net doctor= runs diagnose first, so the panel's doctor button dies with it.
Verified against Python 3.14.6 before writing the fix: =parsedate_to_datetime("Thu, 01 Jan 2020 00:00:00 -0000")= returns =tzinfo=None=, and the subtraction raises. The zoneless form behaves the same. Only the =GMT= form (which the well-behaved probe host sends) comes back aware, which is why this never showed up in normal use.
What makes it more than a curiosity is *when* the code runs. =_steps_egress_edges= fires only after the http-probe has already failed, so the server answering that =HEAD= is frequently a captive portal's interception appliance rather than the real probe host — and a minimal embedded HTTP stack is exactly the kind that emits a non-GMT =Date=. The one path guaranteed to be talking to a non-standard server is the one that can't survive a non-standard header.
Grading: Major severity (grading the being-in-it — the diagnostic tool produces no report at all, and =net doctor= goes with it, on precisely the broken network it exists to diagnose) × rare edge case (needs a failing probe *and* a portal appliance that omits a numeric offset) = P2 = [#B].
Fixed as dotfiles =8933500= (committed locally, deliberately NOT pushed — held for Craig's morning review). A naive parse is now read as UTC, which is what =-0000= means. Two tests, and the second is the one that matters: it drives a *current* =-0000= timestamp and asserts no clock row, so a lazy "catch =TypeError= and return None" fix would fail it while the correct reading passes.
** DONE [#C] a tunnel import that can't be disarmed still reports success :bug:net:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =import_config()= imports a WireGuard or OpenVPN config as an NM profile, then fires =nmcli connection modify <uuid> connection.id <name> connection.autoconnect no= — and discarded the result, returning =ok=True= regardless.
That modify is the whole safety of the feature, and the module's own docstring says so: =nmcli connection import= *auto-activates* the profile it creates, "which nobody asked for by picking a file", so "every import here ends with the profile deactivated and autoconnect off". A failed modify inverts that. For WireGuard — a device-type connection — autoconnect stays on, so the tunnel re-arms itself at the next boot and takes the default route with it, and the profile keeps the transient staged interface name (=wgpvpn=) while the envelope reports the config's real name, so the panel names a profile that isn't there.
CORRECTION (2026-07-24, from an adversarial re-review): the blanket claim originally written here — that a failed disarm re-arms the tunnel at boot — is wrong for OpenVPN. =man 5 nm-settings-nmcli= states autoconnect is not implemented for VPN profiles, and an OpenVPN import is an NM VPN profile, so the modify is near-cosmetic on that half. The bug is real and security-relevant for WireGuard, which is the primary case; the severity as stated overreached to cover both.
The asymmetry, again the tell: =_nmcli_import()=, twenty lines up in the same file, checks its own =returncode= and raises rather than return a UUID it did not get. The modify below it never got the same treatment.
Grading: Major severity (grading the being-in-it — a full-tunnel VPN the user never asked to connect arms on every boot and carries all their egress, it persists across reboots rather than self-healing, and the affirmative "imported X" is what removes the reason to check) × rare edge case (needs the modify to fail after the import succeeded) = P3 = [#C].
Fixed as dotfiles =e0d4d8a= (committed locally, deliberately NOT pushed — held for Craig's morning review). New =_disarm()= returns whether the modify took. On failure the profile is still deactivated first — the import already brought it up, and the verdict shouldn't decide whether it keeps running — and then a =disarm-failed= envelope names the UUID and the exact command to finish the job. Three tests: the failing verdict, =import_configs= counting it as failed rather than imported, and a characterization test pinning that the deactivate still runs on the failure path.
** DONE [#C] a binary that can't be exec'd crashes the panels instead of degrading :bug:net:bluetooth:audio:maint:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Found in the 2026-07-24 sentry bug-hunt, round 5, comparing the four panel packages' subprocess wrappers against each other.
Every wrapper in the panels states the same contract: an unusable tool becomes a degraded result, never an exception. =cmd.run= returns None; =nmcli.run=, =btctl.run= and =pactl.run= raise their own domain error, which every caller already guards on; =speedtest.run_speedtest= returns an error envelope. All of them caught only =FileNotFoundError=, so they kept the contract for a tool that is *absent* and broke it for a tool that is *present but unusable*.
Verified against Python 3.14.6 rather than argued. =subprocess.run= raises =PermissionError= for a file without its execute bit, =OSError= (ENOEXEC, "Exec format error") for an executable file that is neither a binary nor a script with a shebang, =NotADirectoryError= when a path component is a plain file, and =OSError= when a fork is refused under memory or PID pressure. None of the four is =FileNotFoundError=, so each escapes the guard: waybar's net/bt/audio modules die rather than dimming, and a maint probe takes the whole envelope with it — in exactly the machine state maint exists to report on.
The asymmetry, and this codebase had already ruled on it three separate times: =net/iw.py='s =signal_dbm= and =settings/spawn.py='s =detached= both catch =(OSError, subprocess.TimeoutExpired)=, and =audio/cmd.py='s doctor-tier =probe()= enumerates =FileNotFoundError=, =NotADirectoryError= and =PermissionError= as "absent" under a docstring promising it never raises. Its sibling =run()=, twenty lines up in the same file, kept the narrow catch — as did all five copies of =run()= and all three tool wrappers. =audio/status.py='s docstring records that this same class already bit once ("the bar's audio module died rather than dimming"); that fix widened the guard's *scope* and left its *exception set* alone.
Grading: Major severity (grading the being-in-it — the status surface is dead while the condition holds, and for maint the tool that reports the fault is the one that dies of it; no data loss, and it clears when the tool or the pressure does) × rare edge case (needs a binary with wrong permissions, a lost shebang, or a fork refused under pressure) = P3 = [#C].
Fixed as dotfiles =44fdae1= (committed locally, deliberately NOT pushed — held for Craig's morning review). Widened to =OSError= across net, bt, audio, maint and panelkit — five =cmd.run= helpers, the three tool wrappers, =probe._curl= and =speedtest.run_speedtest=. The domain-error wrappers keep their "<tool> not found" message for a genuinely absent binary and add a second arm naming the errno for an unusable one, so the report can still tell the two apart. 28 tests, one class per package, driving all three exec failures against real files on a temp PATH; each was watched failing against unmodified production code first (27 red). Audio's class carries a characterization case pinning =cmd.probe='s existing behavior, so the sibling that got this right can't regress into the one that didn't.
** DONE [#C] a failed pty-backed spawn strands both ends of the pty :bug:net:bluetooth:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Found in the 2026-07-24 sentry bug-hunt, round 6, auditing the =subprocess.Popen= sites the round-5 fix didn't reach.
Two spawns open a pty before launching and catch only =FileNotFoundError= around the =Popen=: =bt/pairing.py='s =pair_interactive= (bluetoothctl under a pty so the passkey agent is interactive) and =net/speedtest.py='s =run_speedtest_stream= (speedtest-go under a pty because it buffers everything to exit when piped). Both are the same exec-failure class as =44fdae1= — a binary present but not executable raises =PermissionError=, a lost shebang raises =OSError= — and neither is =FileNotFoundError=.
What makes these worse than the =run= wrappers is where the cleanup lives. =os.close(master)= and =os.close(slave)= sit *inside* the =FileNotFoundError= arm, so an escaping =OSError= skips them: every failed attempt strands two descriptors. Both call sites are buttons in a long-lived panel process — the pairing flow and the console's SPEED key — and a user who gets no feedback presses again, so the leak accumulates under exactly the conditions that caused it.
Grading: Major severity (grading the being-in-it — a descriptor leak in a process meant to run for days, on a path the user retries, plus the exception escaping a documented "(ok, detail)" / error-envelope contract) × rare edge case (needs an unusable bluetoothctl or speedtest-go) = P3 = [#C].
Fixed as dotfiles =c2eb3e1= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm on each closes both ends and returns the module's own failure shape, naming the errno. Four tests: two pin the return contract, two count =/proc/self/fd= across three attempts — the fd count is what actually fails against unmodified code, and it was watched failing before the fix.
The wider sweep this came from is recorded so it isn't repeated: every =except FileNotFoundError= in production was enumerated. The other exec sites were already correct (=maint/gui.py= x3, =net/kick.py=, =timer/engine.py= x2, =timer/gui.py=, =net/repair.py= x2, =audio/peak.py= all catch =OSError=), and the remaining hits are file-open catches, not exec. =clock/__main__.py='s =toggle()= has no guard at all but spawns =sys.executable=, which is by definition runnable; not filed.
** DONE [#C] one impatient client kills the clock panel's toggle listener for good :bug:clock:waybar:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Found in the 2026-07-24 sentry bug-hunt, round 6, sweeping every acquired resource (pty, socket, mkstemp, tempdir) for cleanup that isn't in a =finally=.
=clock/src/clock/app.py='s =_listen()= guards =accept()= with =except OSError: return= and leaves the request body — =recv=, =runtime_log=, =sendall= — outside any guard. =send_toggle()= in =__main__.py= gives the panel 0.25s to acknowledge, then closes. An ack later than that hits a dead peer and raises =BrokenPipeError=, which escapes the =while= loop and ends the listener thread.
Verified empirically, not argued: a client that connects, sends, and gives up after 250ms makes the server's =sendall= raise =BrokenPipeError= (errno 32) and the listener thread exits.
What makes it Major rather than a nuisance is that it neither self-heals nor announces itself. The socket file stays bound, so every later =clock toggle= still *connects* — then stalls the full 250ms, gets no reply, and falls through to spawning =clock serve=. GTK's single-instance forwarding turns that into =do_activate= on the running service, and =do_activate= calls =show_clock()=, not =toggle()=. So from the first bad client onward, clicking the waybar time module opens the panel every time and never closes it; the only ways out are the right-click dismiss inside the panel or restarting the service. Nothing logs it.
Grading: Major severity (grading the being-in-it — the toggle is one-way from then on, it persists for the life of the service, and there is no signal it happened) × rare edge case (needs a reply to miss the 250ms budget: a busy main loop mid-redraw, a slow runtime-log write, or an interrupted =clock toggle=) = P3 = [#C].
Fixed as dotfiles =7c02614= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm around the request body scopes a dead peer to its own request, mirroring the guard =accept()= already had. =GLib.idle_add= runs before the ack, so the user's click still takes effect — only the acknowledgement is lost. New =tests/clock/test_socket.py=, 3 tests driving the real =_listen= against a stand-in owner (it touches only =self._socket= and =self.toggle=, so no Gtk.Application is needed). The gate is the second toggle after an impatient first: it times out on unmodified code because no listener is left. The other two pin what the fix must preserve — the toggle fires even when the ack can't be delivered, and an unknown command is still answered without toggling.
Left alone deliberately: =do_activate= calling =show_clock()= rather than =toggle()=. Changing it would alter what a cold =clock toggle= does on first launch, which is a design call for Craig rather than part of this defect. Worth raising if he ever wants the spawn path to toggle too.
** DONE [#B] fuzzel breaks the pinentry protocol loop on every passphrase :bug:security:gpg:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Found in the 2026-07-24 sentry bug-hunt, round 7 — from the live journal rather than from reading. Grepping this boot for tracebacks turned up four instances of =pinentry-fuzzel: line 36: read: 0: read error: Resource temporarily unavailable=, and every one sits 4-7 seconds after a =GETPIN= (the time it takes to type a passphrase). The =BYE= handler's log line never appears once.
=hyprland/.local/bin/pinentry-fuzzel= speaks the Assuan pinentry protocol on a pipe gpg-agent keeps open, reading one command per iteration of =while read cmd rest=. The =GETPIN= arm shells out to fuzzel, which *inherits that pipe as its stdin*. fuzzel runs an event loop over its own input, so it sets =O_NONBLOCK= on fd 0 — and =--dmenu= would read the pipe as menu items besides. The flag lands on the shared open file description and outlives fuzzel, so the shell's next =read= fails with =EAGAIN= and the loop ends mid-protocol.
Grading: Minor severity (the passphrase is delivered *before* the break, so decrypts still succeed and nothing is corrupted — what's lost is everything after: =BYE= is never acknowledged, and gpg-agent's same-connection retry after a wrong passphrase, =SETERROR= then =GETPIN= again, can't be served; that retry is what the script's "reenter" label exists for, and it has never once been reachable) × every user, every time (four for four in the journal, and the test reproduces it deterministically) = P2 = [#B].
Fixed as dotfiles =e727dcd= (committed locally, deliberately NOT pushed — held for Craig's morning review). =< /dev/null= on the fuzzel call, so the non-blocking flag lands somewhere harmless; =--lines 0= was already there, so no menu input was ever wanted. =ENABLE_LOGGING= became env-overridable as a test seam — the script logs through an absolute =/usr/bin/logger= that PATH can't shadow, so without it every test run would write ten lines into the real journal.
New =tests/pinentry-fuzzel/=, 8 tests driving the real script over a live pipe the way gpg-agent does. The fake fuzzel sets =O_NONBLOCK= on whatever fd 0 it is handed, exactly as the real one does, which is what makes them a gate rather than a restatement of the fix. Four fail against unmodified code — one reproducing the journal's message verbatim — and one records the fd fuzzel was given, pinning the cause rather than the symptom.
THE CALIBRATION NOTE, and it is about my own earlier sweep. This is the same shape as round 1's =a57c443= (ffmpeg draining the pipe a =while read= loop was consuming). Round 1 swept both repos for siblings of that bug and came back empty — because it searched for the *mechanism* (a child that drains stdin) rather than the *shape* (a child that inherits stdin at all inside a read loop). Two different mechanisms, one shape, and the narrower search missed a live daily-use instance. Scope a class sweep by shape, not by the mechanism of the first instance found.
** DONE [#C] a truncated webcam record strands every camera off :bug:settings:privacy:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Found in the 2026-07-24 sentry bug-hunt, round 8, sweeping production for non-atomic file writes.
=settings/src/settings/webcam.py='s =_record()= wrote =~/.local/state/settings/webcam.json= with a plain truncate-in-place =open(path, "w")=. That record is the only route back on, and the module docstring says so: deauthorizing a camera removes its video4linux nodes, so =usb_devices()= returns nothing afterward and =_recorded()= becomes the sole source of the paths to re-authorize. A write that truncated and then failed left an empty file; =_recorded()= caught the resulting =JSONDecodeError= and returned =[]=; =_known_devices()= then had nothing; and =set_power(True)= returned None without re-authorizing anything. Every camera stranded off, with no way back through the panel until a replug or a reboot.
The asymmetry, seventh instance of this read: six other state writers in the tree already write through a temp file and a rename — =maint/cache=, =net/cache=, =audio/ptt=, =timer/engine=, =settings/store=, =maint/curation=. The one whose loss is most expensive was the one that didn't.
Grading: Major severity (grading the being-in-it — the privacy switch becomes one-way, the panel offers no route back, and the user has to know to replug the camera or write sysfs by hand; bounded by the fact that a reboot re-enumerates USB and restores authorized=1) × rare edge case (needs a crash or ENOSPC inside a microsecond-wide write window) = P3 = [#C].
Fixed as dotfiles =8b40b79= (committed locally, deliberately NOT pushed — held for Craig's morning review). =_record= now mirrors =store.save=: =mkstemp= in the target directory, write, =os.replace=, unlink the temp on any failure. Four tests; the gate is a =_record= whose =json.dump= raises, after which the previous record must still be readable — it isn't on the old code. The other three pin what the fix must preserve: no temp-file residue, the =_recorded()= round trip, and the end-to-end power-off/power-on with the class symlinks removed, which is the scenario the record exists for.
HOW IT WAS FOUND, and it confirms round 7's lesson twice over. Round 4 ran an atomic-write sweep and reported "nine sites, six unique-per-writer, three sharing a fixed =.tmp=" — it enumerated the writers that *were* atomic and compared their temp-file naming, and never asked which state writers aren't atomic at all. Same narrowing that made round 1's stdin sweep miss the pinentry bug: the sweep was scoped to a property of the instances already found rather than to the shape of the hazard.
** DONE [#D] a failed wallpaper apply reports "nothing to apply" :bug:settings:quick:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as dotfiles =2cf3fb3=. The decision moved to =gui.wallpaper_apply_toast=, a module-level pure helper, because the callback lives inside a GTK widget where no test can reach it. 5 tests.
Found in the 2026-07-24 sentry bug-hunt, round 7, sweeping the settings panel's worker callbacks.
=settings/gui.py='s =_async= passes an exception through as the *result* rather than as a separate error argument, so every =done= callback has to test =isinstance(res, Exception)=. Five do — =_mx_pin=, =_mx_letter=, =_after_matrix=, =_set_pointer=, the drum/dial/gallery/refresh callbacks. =_wp_apply= is the one that doesn't:
#+begin_src python
def _wp_apply(self, note="Wallpaper set"):
self._async(lambda: panel.wallpaper_apply(self.state),
lambda ok: self._toast(
note if ok is True else "nothing to apply",
good=ok is True))
#+end_src
=panel.wallpaper_apply= calls =store.save=, which can raise =OSError= (disk full, a permissions change on the config dir). The exception then arrives as =ok=, =ok is True= is False, and the toast reads "nothing to apply" — describing a no-op when the apply actually failed. The toast is at least marked =good=False= (red), so the user gets a negative signal; what's lost is the reason, which every sibling callback surfaces via =str(res)=.
Grading: Minor severity (wrong text on an error path, correctly marked as a failure, nothing corrupted) × rare edge case (needs =store.save= or =wallpaper.apply= to raise rather than return False) = P4 = [#D].
Fix: give it the same =isinstance(res, Exception)= arm its five siblings have — toast =str(res)= on an exception, keep the current two-way message otherwise. One callback, three lines.
** DONE [#D] two manage.py nmcli reads sit outside their own error conversion :bug:net:quick:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as dotfiles =2cf3fb3=. =_key_mgmt= converts both nmcli exceptions to "", which both call sites already treat as neither wpa-eap nor sae. 2 tests, including one driving =_classify_up_failure= end to end.
Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =nmcli.run()= raises =NmcliTimeout= on timeout and =NmcliError= on a missing binary, and every mutation in this module is written to convert both into a result envelope. Two calls escape that conversion because they run through =_key_mgmt()=, which wraps =nmcli.get_value= and catches nothing:
- =edit()= line 243 calls =_key_mgmt(uuid)= for the enterprise-profile refusal *before* its own =try=, while the next four lines catch exactly those two exceptions around =nmcli.run=.
- =_classify_up_failure()= calls it on =up()='s failure path, so a slow =connection show= turns a classifiable activation failure into an exception.
Consequence is a leaked exception where the caller expected an envelope. The panel absorbs it — =gui.bg()= catches =Exception= and renders =str(e)= — so there it degrades to a worse message rather than a crash. =net edit= from the CLI has no such catch and prints a traceback.
Grading: Minor severity (the operation fails either way; what's lost is the classified message, and only the CLI path shows a traceback) × rare edge case (=connection show= has a 2s timeout and nmcli's presence is already established by the time either site runs) = P4 = [#D].
Fix: give =_key_mgmt= the same conversion its callers use — catch =(nmcli.NmcliError, nmcli.NmcliTimeout)= and return "", which both call sites already handle correctly (neither "wpa-eap" nor "sae"). One =try= in one helper covers both sites.
** DONE [#D] three atomic writers share one fixed .tmp name :bug:quick:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Fixed as dotfiles =2cf3fb3=. All three carry =.tmp.$(getpid)=, matching the six writers that already did. 6 tests across audio and maint.
Found in the 2026-07-24 sentry bug-hunt, round 4, sweeping both repos for the temp-file half of the atomic-write idiom. The tree writes state atomically in nine places, and six of them make the temp path unique per writer: =net/cache.py= and =timer/engine.py= both use =f"{path}.tmp.{os.getpid()}"=, and =settings/store.py=, =settings/idle.py=, =bt/repair.py=, =net/probe.py= all use =tempfile.mkstemp=/=NamedTemporaryFile=. Three use a bare =path + ".tmp"=:
- =audio/src/audio/ptt.py= =write_state= (the lead carried over from round 3's Next Steps)
- =maint/src/maint/cache.py= =put=
- =maint/src/maint/curation.py= =_write_user=
=os.replace= makes the *rename* atomic, but a shared temp name is not: two writers open the same path, the second truncates under the first, and the file that gets renamed into place is a blend of both. The loser's own =os.replace= then raises =FileNotFoundError=, because the winner already renamed the name out from under it.
Real concurrent-writer pairs exist for two of the three. =maint/cache.py= =updates_repo= is written by =maint-net-scan.timer= hourly and again by =doctor._fresh_pending()= at UPDATE fire time. =audio/ptt.py= has three writers by design (the CLI toggle bound to a key, the waybar right-click, and the GTK panel) — its module docstring says so. =curation.py= is written by panel key presses and CLI verbs.
Grading: Minor severity (every reader degrades rather than crashes — =cache.get= catches =ValueError= and reports no data, =read_state= reads a torn file as disarmed, and both recover on the next write; the sharpest edge is the loser's =FileNotFoundError= aborting the rest of =scan_net=, which the next hourly run repairs) × rare edge case (the write window is a millisecond or two, and the overlapping writers are an hourly timer against a human keypress) = P4 = [#D].
Fix: give all three the =f"{path}.tmp.{os.getpid()}"= form the two careful siblings already use. It is three one-line changes and needs no new abstraction. Note this closes the torn-file half only — the read-modify-write in =ptt.toggle_plan= and =curation.set_preference= can still lose an update between two writers, which wants a lock rather than a temp-name change and should stay a separate decision.
** DONE [#C] dmenuexitmenu word-splits its menu so no entry matches :bug:dwm:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2128). =dwm/.local/bin/dmenuexitmenu= line 4 expands the menu unquoted: =choice=$(echo -e $menuitems | dmenu ...)=. Word-splitting collapses the runs of spaces the labels carry, so dmenu shows =Lock= where the =case= arm expects =Lock = (two spaces) and =Logout = where the arm expects a trailing space. No arm matches, so choosing an entry does nothing at all.
CORRECTION (2026-07-24): THE BUG AS FILED DOES NOT EXIST. Ran it. =echo= rejoins the words split off the unquoted expansion with single spaces, and no label carries two spaces, so quoted and unquoted produce byte-identical output — verified against the exact literals from git rather than a retyped copy. Every =case= arm matches and every menu action works.
What is real is latent. An unquoted expansion collapses a double space and glob-expands a =*=; the second was demonstrated turning a label into a directory listing. No current label triggers either.
Hardened anyway in dotfiles =2cf3fb3= as robustness, not as a bug fix: the expansion is quoted and the bogus one-element array is now a plain string. Output confirmed unchanged byte-for-byte. New =tests/dmenuexitmenu/= (10 tests) pins the working behaviour, and shellcheck on the file drops from three findings to one.
SECOND SENTRY FILING DISPROVED BY RUNNING IT, after =a57c443= (mkplaylist). Both came from a shellcheck hit plus reasoning, neither was executed. A static-analysis finding says a construct is unsafe, not that it currently misbehaves, and both filings treated the first as the second.
** DONE [#C] Timer module hero hierarchy :feature:waybar:timer:quick:solo:
CLOSED: [2026-07-24 Fri]
From the roam inbox (Craig, claimed 2026-07-22). Which display ("hero") wins the waybar timer module when several timer modes run simultaneously: pomodoro wins over everything (the user is actively working; it's likely their main focus). The rest rank in chronological order of when they would ring. Worked example: with a just-started 15-min timer, a 1-hr timer at 10 minutes left, a pomodoro, and an alarm ringing in 12 minutes — show the pomodoro; when it completes, the 1-hr timer (rings first), then the alarm, then the 15-min timer. Feeds the timer-panel spec (docs/specs/2026-07-02-timer-panel-spec.org).
Shipped as dotfiles =9eedb39=. Pomodoro wins the hero, then soonest-to-ring, in both selectors (=engine.select_primary= for the bar, =panel.primary_id= for the GTK hero). Craig's worked example is a test. FLAGGED FOR CRAIG: the two selectors diverge on a *ringing* alarm (the bar excludes it, the panel gives it the hero) and I left that as-is rather than reverse a deliberate choice. Whether to unify them is your call.
** DONE [#C] Timer module: drop RING message, persistent notifications :bug:waybar:timer:quick:solo:
CLOSED: [2026-07-24 Fri]
From the roam inbox (Craig, claimed 2026-07-22). Remove the RING message from the timer module display; verify all timer and alarm notifications are persistent; the icon returns to normal once the notification has fired. Rationale: keeps timers and pomodoros from interfering with one another's displays (pairs with the hero-hierarchy task above).
Shipped as dotfiles =9eedb39=. The tooltip no longer prints RING or a (ringing) suffix; a fired alarm shows its clock time and its persistent notification carries the alert. Verified the timer and alarm completion notes already set persist=True.
** DONE [#C] PTT icon outline removal :bug:waybar:quick:solo:
CLOSED: [2026-07-24 Fri]
From the roam inbox (Craig, claimed 2026-07-22): the waybar PTT icon should not have an outline. Cosmetic × every-glance = P3 = [#C].
Shipped as dotfiles =e63c0cf= (live style.css + dupre theme source). Removed the amber/green text-shadow glow from the armed/talk states, the only outline-like effect on the icon. FLAGGED FOR CRAIG: this is my read of "outline" (the glow). If you meant the glyph shape itself, it's a one-line revert. Confirm live by pressing PTT.
** DONE [#C] Video wallpapers don't fit the desktop :bug:dotfiles:solo:
CLOSED: [2026-07-24 Fri]
From the roam inbox (Craig, claimed 2026-07-23): videos don't fit the desktop in desktop-settings. The video channel drives mpvpaper (=settings/src/settings/wallpaper.py=); mpvpaper passes options through to mpv, so the fit is a =--panscan=/=--video-unscaled=/keepaspect question rather than a layout one. Reproduce with a video whose aspect differs from the output, pick the mode that fills without distorting (cover, matching how the image channels behave), and cover it in the wallpaper tests. Minor severity × whenever the video channel is selected = P3 = [#C].
Shipped as dotfiles =04d1489=. =set_video= now passes =panscan=1.0=, so mpvpaper fills the output and crops the overflow instead of letterboxing; keepaspect stays on so nothing stretches. Tested against the mpvpaper arg log.
** DONE [#C] World-clock wallpaper arrangement :feature:dotfiles:
CLOSED: [2026-07-24 Fri]
Shipped 2026-07-24 as dotfiles =6afbe09=, iterated live with Craig. The grid of boxed mini-clocks became a centered vertical clock line: cities down a spine, west (Honolulu) top to east (Wellington) bottom, labels alternating both sides, no boxes. Each shows city / time (12h) / day+date / timezone region name ("US Central"). Day/night dimming + amber home carried over, title dropped, cursor restored over the desktop. Prototypes archived in archsetup 40216e7. The face is parameterized (=?layout=vertical|horizontal=, =?hour12=1|0=) so the panel pickers below can drive it.
** DONE [#C] Floating layout — should we? :feature:hyprland:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
From the roam inbox (Craig, claimed 2026-07-23): consider whether Hyprland should offer a floating layout — how it would work, the benefits, and the complexity. A brainstorm/spike, not a build: the deliverable is an assessment Craig reads and decides on, not a shipped layout. Not :solo:. When picked up, run it as a brainstorm — how a floating mode coexists with the current tiling binds (toggle keybind, per-workspace vs global, window-rule interactions), what it buys over the existing =togglefloating=, and the config/muscle-memory cost — then bring Craig the recommendation.
CONCRETE PROPOSAL from a second roam item (Craig, 2026-07-24 via work) — "floating mode as the easiest mode":
- Can't select floating until at least one window is displayed.
- Entering floating freezes each window's position and floats it exactly where it is.
- During floating, drag windows with mod+mouse-drag.
- Exiting floating switches to tiling or monocle and lets that layout take over.
Craig's note: "simple, could be useful for different reasons." This is the design the brainstorm should evaluate first — assess feasibility against Hyprland's actual float/tile transitions (does freezing current geometry survive the tiling↔floating switch, does re-tiling on exit reflow cleanly) before recommending.
ASSESSED, dotfiles =8cf4728=: =docs/2026-07-24-floating-layout-assessment.org=. Verdict: buildable and worth building on a capture-then-restore of window geometry (=hyprctl clients -j= gives at/size), which is a real gesture plain =togglefloating= can't express. Craig's four-rule proposal is folded in and each rule assessed. One taste call flagged (exit to previous layout vs always monocle). Ready to file a build task on Craig's go.
** DONE [#C] World clock wallpaper: bold the city names :feature:dotfiles:quick:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
From the roam inbox (Craig, 2026-07-24 via work): bold the city names on the world-clock wallpaper face (=settings/faces/world.html=, shipped =6afbe09=). Cosmetic × every glance at the world face = P3 = [#C]. Solo — a CSS weight change, screenshot-verifiable — but it's a visual call, so build it and show the render rather than close off a green suite. Pairs with the open world-face picker task.
Shipped as dotfiles =e63c0cf=. =.lbl .city= is now =font-weight:700=. Rendered offscreen and confirmed the bold reads well over the time/zone lines; home city stays amber. Comparison render was on ws5 for Craig.
** DONE [#C] Floating clock toggles on control+mod+c :feature:dotfiles:hyprland:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
From the roam inbox (Craig, 2026-07-24 via work): a control+mod+c keychord should toggle the floating clock, the same as clicking the time waybar module.
This answers the design question the round-6 clock-toggle fix deliberately left open (see the =clock toggle listener= DONE task above): =do_activate= calls =show_clock()= rather than =toggle()=, and the note there flagged "worth raising if he ever wants the spawn path to toggle too." He does. Build: a hyprland keybind bound to =clock toggle=, and confirm the toggle path (not show-only) fires whether the service is cold or warm. Solo — buildable and locally verifiable.
Shipped as dotfiles =e73a70e=. =bind = $mod CONTROL, C, exec, clock-panel toggle= reuses the exact command the time module's click runs, so it toggles identically. Registered clean on reload. Live keypress is Craig's to confirm.
** DONE [#C] Calculator scratchpad won't toggle closed on mod+x :bug:hyprland:solo:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
From the roam inbox (Craig, 2026-07-24 via work): =mod+x= opens the calculator scratchpad but doesn't close it — Craig has to kill the window by hand. A second =mod+x= should toggle it shut. Almost certainly a =togglespecialworkspace= vs plain =exec= binding in the hyprland config, or a scratchpad window-rule mismatch. Minor severity (a workaround exists: kill the window) × every time the calc scratchpad is used = P3 = [#C]. Solo — a keybind/window-rule fix, locally verifiable.
Shipped as dotfiles =e73a70e=. New =calc-toggle= script (mirrors fuzzel-toggle: pgrep -x, pkill or launch), and =mod+X= now points at it, so a second press closes the calculator. 3 tests in tests/calc-toggle.
** DONE [#C] Saving and recalling window configurations :feature:hyprland:
CLOSED: [2026-07-24 Fri]
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
:END:
From the roam inbox (Craig, 2026-07-24 via work), a research idea: Craig wants to save a specific window+app arrangement and have it reappear on demand. What has to be known and built to make that happen — is there prior art (another WM or OS that does session/layout save-restore), what information do those need (app identity, geometry, workspace, launch command), and what are their rules. Explore how far Hyprland can get (hyprctl clients + dispatch, exec rules, window rules by class/title), document thoroughly, and review with Craig next time. Not :solo: — the deliverable is an assessment he reads and decides on, and it may spawn a build task once the shape is clear. Offer to file the build separately if part of it turns out urgent.
RESEARCHED, dotfiles =8cf4728=: =docs/2026-07-24-window-config-save-recall-assessment.org=. Prior art surveyed (i3/sway =append_layout= swallow, KDE window rules, macOS Moom). Three tiers from cheapest: (1) reposition open windows — buildable + testable now; (2) relaunch + place by class rule; (3) full swallow-by-title, which hits the same-class ambiguity every tool hands back to the user. Recommends shipping tier 1; tiers 2-3 need Craig's call on how much manual disambiguation he'll accept.
|