Compare commits

...
Author SHA1 Message Date
Frank b4fc881164 Declare the 24 hand-installed blankpad packages the manifest was missing
The point of the new inventory tool is this commit: packages/blankpad.pkgs declared one package
while the machine had 24 more that no rebuild would reproduce. Declaring them changes nothing on
the running box (install.sh installs only what is missing — its delta is now empty) and makes a
fresh install faithful to this one.

nirimod-git is left undeclared on purpose: it is AUR-only and aur.pkgs is fleet-wide, so declaring
it there would make the headless servers build a niri module.

Refs #51
2026-10-20 18:08:23 -07:00
Frank 6df45d709a Add pkg-inventory.sh: report what a machine has outgrown its manifests
install.sh installs desired − installed from packages/*.pkgs, which guarantees a fresh box reaches
the declared state — and nothing anywhere checked that the declared state still describes the
machines. Measured against the Calamares install day, blankpad carries 41 hand-installed packages
and packages/blankpad.pkgs declared one line.

pkg-inventory.sh walks the other direction: installed − declared, with the pacman log as the
baseline so installer furniture is never reported as a hand install. It splits additions by
whether a sync db can serve them (Chaotic-AUR counts, so only true AUR routes to aur.pkgs),
excludes orphans from --emit, reports flatpak apps without pretending install.sh installs them,
and exits 3 rather than guessing when the sync databases are unreadable.

16 black-box tests over fixtures and a stubbed pacman. Two of them exist because the first
implementation was wrong in ways a small fixture cannot show: sort|head under pipefail dies of
SIGPIPE on a real 1000-line install-day log (awk-exit closes the pipe exactly as head does), and
tr without a trailing echo glues the next report label onto the last folded name.

Docs: omp-docs/tools/packages.md now owns the package tier — install.sh was never documented.

Refs #51
2026-10-20 18:07:42 -07:00
dank 5ab0a49ea4 Merge pull request 'Correct the lock-watcher claims: session restart was the trigger; suspend was covered by noctalia' (#49) from fix/47-attribution-and-inhibitor-correction into main 2026-10-20 17:55:08 -07:00
Frank 4666d0dce0 Correct the lock-watcher law: the trigger is the session restart, and suspend was covered
Two statements in the law added by #48 need measurement rather than inference, and one of them was
wrong.

**Attributed.** `niri` and `noctalia` both started `Mon Oct 12 16:22:31 2026` (`ps -o lstart=`);
swayidle's last state change was `16:22:23`. The compositor session restarted and the watcher died
with it — eight seconds of agreement, which is what `PartOf=graphical-session.target` is for. The
fix that #48 justified by reasoning is now justified by the event itself. The user journal holds no
entries for that window, so the evidence is process ages, not logs.

**Corrected.** #48 said closing the lid suspended blankpad unlocked, because `before-sleep` lived in
the dead unit. `systemd-inhibit --list` says otherwise: `noctalia … sleep "Lock before sleep" delay`
— the shell holds its own sleep inhibitor and locks on suspend independently of swayidle, so the
suspend path held all along. What was dead for eight days is the **idle** lock and `after-resume`.
The method error is worth keeping: `pgrep` for lockers came back empty and I read that as "no lock
path", when a locker can be a hook inside a running shell. The inhibitor table is the instrument.

Added with them, because it answers a question people will ask: while an `omp` session runs, swayidle
logs `Not enabling timeouts: idle inhibitor found` and arms no idle timeout (`omp … idle "omp agent
session" block`). A box with an agent working does not lock on inactivity, by harness design.

Docs only; the unit file from #48 is unchanged and the live watcher is `active` with `NRestarts=1`
after the SIGTERM proof. `kb-lint.sh omp-docs` 0 violations, `kb-coverage.py` 0 issues, `bats tests/`
71/71. Refs #47.
2026-10-20 17:52:59 -07:00
dank d013795922 Merge pull request 'blankpad has had no screen lock for 8 days; the lid suspends it unlocked' (#48) from fix/47-lock-watcher-resilience into main 2026-10-20 17:25:11 -07:00
Frank 2b7b0bc3cd Make the idle-lock watcher survive a clean exit, and outlive a session restart
`swayidle-lock.service` was `enabled`, in `default.target.wants/`, and **dead for eight days**:

    Active: inactive (dead) since Mon 2026-10-12 16:22:23 PDT; 1 week 1 day ago
    Duration: 2d 21h 57min   Main PID: 3181 (code=exited, status=0/SUCCESS)
    Result=success  NRestarts=0

`Restart=on-failure` cannot see a clean exit, and `swayidle -w` exits 0 on SIGTERM. So the
watcher that owns both the 5-minute idle lock and the `before-sleep` lock stopped existing, and
nothing on the box noticed. The consequence is not cosmetic: with `HandleLidSwitch=suspend` and
`HandleLidSwitchExternalPower=""` (which falls back to it), **closing the lid suspended the
machine unlocked**, because the hook that locks on sleep lived inside the unit that had died. No
alternative was running: `pgrep -c -u blankpad 'swayidle|swaylock|hyprlock|gtklock'` = 0, and
neither `autostart.kdl` launches a locker.

Three settings, each with a job:

- `Restart=always` — a supervisor must not assume it can only die badly.
- `StartLimitIntervalSec=0` — with `always`, a fast loop must never wedge the unit into `failed`;
  a lock that keeps trying beats a lock that stopped trying.
- `PartOf=graphical-session.target` — it is wanted by `default.target`, so a session restart
  brought the target back without it; now the watcher is restarted with the session.

What triggered the 16:22 exit is **unattributed**: the user journal has no entries in that window,
so all that can be said is that swayidle exited with status 0 — precisely what the old policy was
blind to. The fix does not depend on knowing the trigger, which is the point.

Verification is behavioural, per the law recorded with it: start the unit, `kill -TERM` the main
PID, and watch `NRestarts` increment with a new `MainPID` — the October event, survived. Not
performed here: starting it arms a 5-minute idle lock on a machine that has gone a week without
one, and that is Frank's call. `systemd-analyze --user verify` clean, `bats tests/` 71/71,
`kb-lint.sh omp-docs` 0, `kb-coverage.py` 0.

Refs #47
2026-10-20 17:24:32 -07:00
dank 3809e1ed22 Merge pull request 'deploy.sh declared this repo's own heartbeat unit foreign, on every box' (#46) from fix/44-unit-specifier-resolution into main 2026-10-20 17:22:52 -07:00
Frank e278e4b863 Expand systemd specifiers before calling a unit foreign
The first real `deploy.sh` run on blankpad printed:

    SKIP   live-sync.service (ExecStart '%h/.omp/scripts/live-sync.sh' absent on this host)

while `/home/blank/.omp/scripts/live-sync.sh` sits right there. `%h` is expanded by systemd,
never by the shell, so the literal string was handed to `command -v`, failed, and the unit was
declared to belong to another machine — through the same code path whose whole purpose is to
keep a *foreign* unit inert, and silently. The victim was this repo's own heartbeat, and the
verdict was the same on every box that ever deployed it, because `%h` is exactly where the
checkout lives.

Nothing broke tonight only by luck of install state: `live-sync.service` is `linked`, driven
by its timer, so the heartbeat kept running while the deploy lied about it. A unit whose only
install route was this loop would simply never have been enabled anywhere.

`%h`, `%u` and `%U` are now expanded before resolution; an unrecognised specifier is left
alone so it fails loudly rather than being guessed at. Pinned by a test that writes a
`%h/.local/bin/helper` unit into the scratch home — red before (`SKIP`), green after
(`already linked; not started`), which also keeps the #45 rule intact: resolving a unit is not
licence to start it. Refs #44.

Verification: `bats tests/` 71/71, `kb-lint.sh omp-docs` 0 violations, `kb-coverage.py` 0
issues, `bash -n` clean.
2026-10-20 17:19:49 -07:00
dank c05f5ad451 Merge pull request 'deploy.sh is unsafe beside a live session: it starts a screen lock and imports another box settings' (#45) from fix/44-deploy-live-safe into main 2026-10-20 17:16:47 -07:00
Frank ecc089c6ef deploy.sh no longer starts units a live session already runs
`sync` on a box with a live session on it was unsafe in two independent ways, found by
preflighting the run rather than performing it. Refs #44.

**`enable --now` would have armed a screen lock mid-session.** The unit loop started every
`systemd-user/` unit whose `ExecStart` resolves. `swayidle-lock.service` on blankpad is
`enabled`/`inactive` — nothing started it, no swayidle process exists, `autostart.kdl` does
not launch one — so one `deploy.sh` would have begun blanking and locking the monitors while
the operator was mid-sentence. `live-sync.service` is `linked`, driven by its timer, and
`--now` forces a heartbeat commit at an arbitrary moment. A unit is now started only when
systemd reports it as nothing-yet (`""`/`disabled`), which is precisely a fresh box; anything
already installed is merely `enable`d. The state has to come from the **printed state**:
`is-enabled` exits 1 for `linked` exactly as for `disabled`, so exit status cannot
distinguish "installed, timer-driven" from "never installed" — the first version of this fix
got that wrong and the test caught it.

**`--check` output was being read as more than it can mean.** It prints `RELINK` for every
path the `zz` tier carries, even when the live link already points at the override
(`rules.kdl`, `keybinds.kdl`, `misc.kdl`, `autostart.kdl`, `.zshrc` all did on blankpad),
because the walk visits the base package first and reports the intermediate owner change.
The sentence committed in #43 — RELINK noise is "the sound of a stale override waiting" — is
too strong and is corrected: the line proves a pending change only when `readlink` shows the
live target is not already the `zz-$HOST` file, and `actions=0` is the honest summary. The
stale-override hazard #43 removed was and is real; the signal for it is the target, not the line.

**The dconf dump carried six of its 24 keys from another box**, none of them preferences:
a Firefox file-chooser `last-folder-path` of `/home/dank/Downloads`, a nautilus
`window-state` section, and `picture-uri`/`picture-uri-dark` pointing at
`file:///home/dank/.config/background`. The last five showed no conflict because blankpad's
live tree *already* held those `/home/dank` values — an earlier deploy wrote them here — and
a foreign value that agrees with live is invisible to any diff while still being wrong. They
are dropped; `picture-options='zoom'` stays, because it is a preference. The live wallpaper
URIs are cleared on the box as part of landing this (niri draws the wallpaper, noctalia owns
it, nothing reads a dead path).

`tests/deploy.bats` grows by four, over a stub `systemctl` — the real one on this box is
exactly what must not be invoked from a test. Red before: an already-`enabled` unit and a
`linked` unit both get `--now`. Green after, including the two directions that keep the
change honest: a `disabled` unit is still `enable --now`'d so a fresh box comes up, and a
unit whose `ExecStart` is absent here is never touched at all.

Verification: `bats tests/` 70/70, `kb-lint.sh omp-docs` 0, `kb-coverage.py` 0. The dump now
reconciles against live state with **0 conflicts and no `/home/` values in 18 keys**, proven
by key-by-key comparison rather than `diff` — `dconf load` writes only the keys present, so a
diff overstates the damage one way and hides it the other.
2026-10-20 17:13:55 -07:00
dank 7523ca5c19 Merge pull request 'Stop the deploy tier from reverting live theme state' (#43) from fix/42-deploy-generated into main 2026-10-20 17:10:03 -07:00
Frank 3022277167 Stop the deploy tier from reverting live theme state
Three findings from verifying the #41 merge against the live box with
`deploy.sh --check`. One commit because they share the deploy tier, its
documentation, and the first test file that tier ever had.

**1. Generated palette output was tracked, and sync would have restored it over a
live theme.** `dotfiles/zz-blankpad/niri/.config/niri/noctalia.kdl` (`b3d3df4`,
2026-10-18) sat byte-identical to the live file — harmless while the palette held, and
`--check` was already proposing `ADOPT … noctalia.kdl`. The moment the theme changed, the
next sync would have quarantined the live file and reinstated 2026-10-18's colours.
`deploy.sh` now refuses the class outright (`SKIP … (generated theme output)`), `.gitignore`
carries the matching patterns so it cannot be re-added, and the file is untracked.

**2. A `zz` override older than the file it overrides.** `dotfiles/zz-blankpad/niri/…/
layout.kdl` held focus ring `width 2` / `#e01b24` (`7b7dbf5`, 2026-10-17) while base
absorbed `b3d3df4` (2026-10-18): `width 4`, `#ff8c00`. Stow order makes `zz-*` win, and a
cross-package owner change relinks *silently* — no quarantine, no warning. Live was linked
to base, the newer intent, so `--check` printed `RELINK … layout.kdl` every run and one
unattended `deploy.sh` would have reverted the amber focus ring. The duplicate is deleted;
base owns the file.

**3. Two topologies I had asserted and not measured.** The media law and the `.gitignore`
comment both said `~/.local/share/noctalia/wallpapers` symlinks into the repo, so deleting
the tracked copies would blank the desktop. Measured: it is a real directory of real files
that Noctalia owns; the repo held a duplicate, and the #41 merge deleted that duplicate
without the desktop flickering. The correct general law replaces the false one — untracking
a path deletes the file in every other checkout, `git rm --cached` on the branch
notwithstanding — and the restore procedure now points at the app's directory, which
`deploy.sh` does not manage.

The guard is `tests/deploy.bats`, black-box over exit status, stdout, the symlink shape
under `$HOME`, and the quarantine directory. Before the change: test 1 and test 2 fail,
test 2 because the live palette really is quarantined and reverted. After: 4/4, including
the control that an ordinary identical config is still adopted — an exclusion nobody can
turn off is just a broken tool.

Verification: `bats tests/` 66/66, `kb-lint.sh omp-docs` 0 violations, `kb-coverage.py` 0
issues, and live `deploy.sh --check` on blankpad reports `SKIP … (generated theme output)`
with `actions=0` instead of `ADOPT … noctalia.kdl`. The real `deploy.sh` run that clears the
remaining `RELINK` lines is deliberately left until this is in main — running it from a
worktree would repoint live symlinks into a checkout the heartbeat reaps.

Refs #42
2026-10-20 17:08:06 -07:00
dank 689ada428f Merge pull request 'Quarantine what the gate could not see; delete what the repo only pretended to own' (#41) from fix/40-dotfiles-hygiene into main 2026-10-20 17:02:53 -07:00
Frank 9ebf5fe85f Untrack a retired niri config that was committed as trash; correct two census rows
The repo still tracked `dotfiles/niri/.config/niri/cfg/.AGENTTRASH/display.kdl.superseded`
— a retired output config committed 2026-10-09, five days before `.gitignore` grew the
`.AGENTTRASH/` rule. Tracked-then-ignored: an ignore rule never reaches an already-tracked
path, so `git rm --cached` is the only repair. The working copy joins the rest of today's
tracked debt.

Found while answering "does this repo still track my noctalia and niri settings", which
also falsified two rows of the census committed yesterday: niri is 9 tracked files, not
12; noctalia is 3 authored files against 2672 files of app state and downloaded catalog,
not 2/0. The page's own classifier command reproduces the new numbers — it never
reproduced the old ones, which is the only reason they were wrong and not merely
different.

Two laws recorded alongside, both from the same probe: `config.kdl`'s
`include "noctalia.kdl"` is not `optional=true`, so a fresh box cannot start niri until
the theme pipeline has run once; and `cfg/display.kdl -> desk-1-home.kdl` is desk state a
fresh box does not receive — by design, but nowhere written down.

Refs #40
2026-10-20 17:00:20 -07:00
Frank 86280608e5 Refresh the userspace census from live measurements
`inventory/home-config-census.md` was dated 2026-08-28, tagged `unverified`,
and every path in it was `/home/dank` — it described another box, yet
`inventory/index.md` still advertised it as the input for the pruning session,
so the pruning session would have pruned against fiction.

Rebuilt from this box, purpose stated in line one. Classification is per FILE
because the tracked unit is a symlink into `dotfiles/`: a directory can look
"real" while every file inside it is tracked (`niri` 12/1, `gtk-3.0` 3/0), and
a directory can be one whole symlink (`lazyvim`). The classifier command is in
the page so tomorrow's agent reruns it instead of trusting the table.

The generated/authored split is the load-bearing part: 24 `noctalia*` palette
files across 20 apps are one generator pass, proven by the
`2026-10-14 19:05:40.330…40.813` same-second cluster and later single re-stamps
(`bat` 10-18, `niri` 10-18, `mango` 10-17). A same-second mass write is
generation, never authorship — so the source palette stays tracked and the
expansion never is. Adoption candidates are limited to programs that pass
`command -v`, with authorship read from the file; the prune list extends the
expected 14 with `pywalfox`, `qt5ct`, `qt6ct`, `turborepo`, and notes that six
of the orphan dirs contain nothing but generated palette.

`fib-watchdog.{service,timer}` is recorded as owned by the bench repo, not
adopted here: its `ExecStart` and `WorkingDirectory` are that project's
internals. It is flagged as a reinstall risk instead, which is the honest
status. Deleted: the `/home/dank` rows, `~/.pi`, Steam library sizes, and the
old offender/summary tables — numbers for software that is not installed here
fail the purpose test.

Refs #40
2026-10-20 16:48:33 -07:00
Frank 411b32ff59 Stop tracking fiction, and stop carrying 80 MiB of wallpaper per clone
Three things were in history that should not have been, all invisible to the
tool that deploys them:

- `home/dank/.omp/dotfiles/**` — 3 files re-hosting `niri/cfg/autostart.kdl`,
  `niri/cfg/keybinds.kdl` and `systemd-user/…/swayidle-lock.service` under a
  prefix `scripts/deploy.sh` never reads (it walks `dotfiles/<pkg>/`). The live
  package is deployed and symlinked, so the copy asserted nothing; it only
  misled. Moved to `.AGENTTRASH/20261020-tracked-debt/`.
- `.pre-pull-backup/**` — 2 managed-skill files, a local backup in history,
  which `.gitignore` itself declares must stay on disk only. Same destination.
- `dotfiles/noctalia/.local/share/noctalia/wallpapers/` — 23 images, 80.4 MiB.
  Untracked, files left in place: `~/.local/share/noctalia/wallpapers` symlinks
  into this directory and the theme reads it, so deleting them would have gone
  black. Every filename carries its wallhaven ID, so a fresh box restores the
  set with one `curl` per file plus `deploy.sh`; losing the set is an acceptable
  outcome, 80 MiB per clone forever is not.

New page `omp-docs/tools/dotfiles-deploy.md` owns the deploy contract (the
`--check`/sync/`--adopt` modes, `zz-$(hostname)` override semantics, the dconf
and systemd-user special cases) because nothing documented it, plus two laws:
media the pipeline consumes is content not config, and `systemctl --user
enable` snapshots an absolute path — so enabling from a worktree means the
seven-day reap silently kills the unit while `is-enabled` keeps answering
`enabled`. That second law is not theoretical:
`default.target.wants/blankpad-desk-autoswitch.service` pointed into the reaped
`wt/de-redesign-20261009` and desk auto-switching had been off since. It is
relinked to `dotfiles/zz-blankpad/` and left inactive rather than started mid-
session; starting it applies a desk profile.

Refs #40
2026-10-20 16:48:33 -07:00
Frank 04f93133fc Close the secret-gate scope hole, and make it fail closed
The gate scanned only `*.md *.yml *.yaml *.json *.toml *.sh *.ts *.js`. A
`postgres.env`, a `.container` quadlet, an `.ini`, or an extension-less dotfile
— exactly the shapes the dotfiles tier is made of — was never read at all.
Discovered while deciding what else belongs in `dotfiles/`: `~/.config`
carries `containers/systemd/postgres.env` with a real `POSTGRES_PASSWORD`,
which the ignore list did not cover either (`git check-ignore` says so: `.env`
does not match `postgres.env`).

Token SHAPES now scan the entire staged diff, every file type; bcrypt hashes
and `Authorization: Basic` headers joined the shape list. Credential
ASSIGNMENTS are scanned in non-prose files only — the auto-learned memory tier
is prose *about* credentials (7 such hits measured on the tracked tree), and a
gate that fires there gets overridden until it means nothing. Markdown keeps
shape detection, which is what it had before.

Three invariants, each pinned by a test in tests/secret-gate.bats (13 tests,
black-box: the hook is copied into a scratch repo and git itself runs it):

- Fail closed. The first version of this rewrite passed `-I` to `git diff`,
  which is not valid there; the empty file list made every secret commit sail
  through, and a blind gate looks exactly like a clean tree. If the hook cannot
  read the staged diff it now refuses the commit.
- Only ADDED lines can leak. The gate refused the redaction commit in this
  branch because the diff still carried the removed password — a gate that
  blocks the removal of a secret holds the leak hostage.
- Path-valued variables (`…_FILE=`, `…_PATH=`) and placeholder/indirected
  values are not secrets, so the gate stays worth listening to.

`.gitignore` quarantines `*.env` at any name, and gains the theme-media rule
whose untracking lands in the next commit. `AGENTS.md` states the new contract.

Refs #40
2026-10-20 16:48:33 -07:00
Frank 7da5e9dfa8 Redact a live credential that escaped into tracked memory
The auto-learned memory tier for serverAppConfigs carried, in plaintext, the
Caddy basicauth bcrypt hash for dankuser, the password behind it, and the
Pi-hole internal password. The file is tracked, so all three are in git
history on every machine that has pulled this repo, and in every agent
context window that has read the bucket.

Operational knowledge preserved in full (which auth layer fails, how to
reset, that the CLI hash is what CivetWeb validates, hot-reload through the
file watcher); only the credential values removed.

Both credentials still need rotation: redaction does not un-leak history.
That is an operator action on Dankclaw, not something this commit can do.

Refs #40
2026-10-20 16:47:17 -07:00
Frank 0d43eb0948 live: heartbeat sync 2026-10-20T16:17:41-07:00 (Refs #11) 2026-10-20 16:17:41 -07:00
dank 5110060c24 Merge pull request 'Configure the engineering skills against this repo and this KB' (#39) from feat/agent-skills-config into main 2026-10-20 16:14:43 -07:00
63 changed files with 1657 additions and 645 deletions
+87 -10
View File
@@ -1,25 +1,102 @@
#!/usr/bin/env bash
# Secret gate: block any commit whose staged diff contains live-secret shapes.
# This repo is pushed to a Gitea remote; anything committed is instantly visible
# Secret gate: block any commit whose staged diff contains live-secret shapes or
# credential assignments. This repo IS the deployment and every workstation
# pulls it, so a committed secret is instantly in every agent's context window
# on the fleet. Deliberate override: ALLOW_SECRETS=1 git commit ...
#
# Two rules, deliberately different in scope:
#
# 1. Token SHAPES are scanned across the ENTIRE staged diff, whatever the file
# type. The first version of this gate whitelisted extensions
# (*.md *.yml *.yaml *.json *.toml *.sh *.ts *.js), so a `postgres.env`, a
# `.container` quadlet, an `.ini`, or an extension-less dotfile — exactly
# the shapes the dotfiles tier is made of — was never read at all.
# 2. Credential ASSIGNMENTS (PASSWORD=…, secret_key: …) are scanned only in
# non-prose files. The auto-learned memory tier is prose ABOUT credentials,
# and a gate that fires there gets overridden until it means nothing; the
# 2026-10-20 audit measured 7 such prose hits on the tracked tree. Markdown
# keeps shape detection only, which is what it had before.
#
# Bcrypt hashes and Basic-auth headers joined the shape list on 2026-10-20
# (issue #40) after a live Caddy basicauth hash plus its plaintext password —
# and the Pi-hole internal password — were found in a tracked memory file.
set -uo pipefail
staged=$(git diff --cached --diff-filter=ACM -U0 -- '*.md' '*.yml' '*.yaml' '*.json' '*.toml' '*.sh' '*.ts' '*.js')
[ -z "$staged" ] && exit 0
shapes='sk-[A-Za-z0-9]{18,}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,}|glpat-[A-Za-z0-9_-]{20}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16}|ASIA[A-Z0-9]{16}|BEGIN [A-Z ]*PRIVATE KEY|aes_[A-Za-z0-9/+]{40}|eyJ[A-Za-z0-9_-]{20,}\.ey|\$2[aby]\$[0-9]{2}\$[./A-Za-z0-9]{20,}|Authorization:[[:space:]]*[Bb]asic[[:space:]]+[A-Za-z0-9+/=]{24,}'
patterns='sk-[A-Za-z0-9]{18,}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,}|glpat-[A-Za-z0-9_-]{20}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16}|ASIA[A-Z0-9]{16}|BEGIN [A-Z ]*PRIVATE KEY|aes_[A-Za-z0-9/+]{40}|eyJ[A-Za-z0-9_-]{20,}\.ey'
# `newpass1234`, `secret_key: "rotated-value"` — an assignment with a literal
# value. `$VAR`, `${VAULT}`, <placeholders> and empty values cannot match.
assign='[A-Za-z0-9_]*(PASSW|SECRET|TOKEN|API_?KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL)[A-Za-z0-9_]*[[:space:]]*[:=][[:space:]]*["'"'"']?[A-Za-z0-9/+._@-]{8,}'
placeholders='changeme|change[-_]|placeholder|redacted|example|sample|dummy|nokey|no[-_]key|not[-_]needed|your[-_]|localhost|xxxx|yyyy|\$\{|<[a-z]|\*\*\*|%s|null|false|true'
# Exemptions: a variable naming a location (`…_FILE=`, `…_PATH=`) or a value
# that is a filesystem path is a pointer, not a credential. Flagging those is
# how a gate gets permanently overridden.
assign_exempt='(_FILE|_PATH|_DIR|_URL|_ENDPOINT|_SOCKET)[[:space:]]*[:=]|[:=][[:space:]]*["'"'"']?(/|\.\./)'
# Fail closed: a gate that cannot read the staged diff must not report success.
if ! files=$(git diff --cached --diff-filter=ACM --name-only 2>&1); then
{
echo "SECRET GATE: cannot read the staged diff — refusing to commit."
printf ' %s\n' "$files"
echo "Override deliberately: ALLOW_SECRETS=1 git commit ..."
} >&2
[ -n "${ALLOW_SECRETS:-}" ] && exit 0
exit 1
fi
# stderr is captured so the failure branch can quote it; on success it must not
# leak a `warning:` line into the file list and have it mistaken for a path.
files=$(printf '%s\n' "$files" | grep -vE '^(warning|fatal|error):' || true)
[ -z "${files//[[:space:]]/}" ] && exit 0
report=""
exempt=""
count=0
while IFS= read -r f; do
[ -z "$f" ] && continue
# Self-exemption, deliberately an exact filename list and not `tests/`: the
# gate's fixtures must contain credential-shaped strings or there is nothing to
# test against, and this hook documents the shapes it looks for. A secret parked
# in any other test file is still caught.
case "$f" in
.githooks/pre-commit|tests/secret-gate.bats) exempt="$exempt $f"; continue ;;
esac
diff=$(git diff --cached -U0 -- "$f")
[ -z "$diff" ] && continue
# Only ADDED lines can leak. Matching removals too blocks the very commit that
# deletes a credential — which is exactly what happened on 2026-10-20, when the
# gate refused the redaction of the password it exists to catch.
added=$(printf '%s\n' "$diff" | grep -E '^\+' | grep -vE '^\+\+\+' | sed 's/^+//' || true)
[ -z "$added" ] && continue
hits=$(printf '%s\n' "$added" | grep -Ei "$shapes" || true)
if [ "${f##*.}" != "md" ]; then
assigns=$(printf '%s\n' "$added" | grep -Ei "$assign" | grep -Ev "$assign_exempt" | grep -Eiv "$placeholders" || true)
hits=$(printf '%s\n%s\n' "$hits" "$assigns" | sed '/^[[:space:]]*$/d')
fi
if [ -n "$hits" ]; then
n=$(printf '%s\n' "$hits" | wc -l)
count=$((count + n))
report+=" $f"$'\n'"$(printf '%s\n' "$hits" | cut -c1-100 | sed 's/^/ /')"
fi
done <<< "$files"
# Say so out loud: an exemption that prints nothing is how a carve-out becomes
# invisible, and invisible is the property a secret wants.
if [ -n "$exempt" ]; then
printf 'secret-gate: self-test fixtures exempt:%s\n' "$exempt" >&2
fi
hits=$(printf '%s\n' "$staged" | grep -Ei "$patterns" || true)
if [ -n "${ALLOW_SECRETS:-}" ]; then
[ -n "$hits" ] && printf 'secret-gate: ALLOW_SECRETS set, allowing %s secret-shaped string(s) through.\n' "$(printf '%s\n' "$hits" | wc -l)"
[ -n "$report" ] && printf 'secret-gate: ALLOW_SECRETS set, allowing %s secret-shaped string(s) through.\n' "$count"
exit 0
fi
if [ -n "$hits" ]; then
if [ -n "$report" ]; then
{
echo "SECRET GATE: staged diff contains $(printf '%s\n' "$hits" | wc -l) secret-shaped string(s):"
printf '%s\n' "$hits" | sed 's/^/ /'
echo "SECRET GATE: staged diff contains $count secret-shaped string(s):"
printf '%s\n' "$report"
echo "Fix it, or override deliberately: ALLOW_SECRETS=1 git commit ..."
echo "A credential that reaches a tracked file is already in history on every"
echo "machine that pulls this repo — deleting it later does not un-leak it."
} >&2
exit 1
fi
+24
View File
@@ -26,6 +26,10 @@ auth.json
**/auth.json
.env
**/.env
*.env
# `*.env` is the rule that matters: `.env` alone does not match `postgres.env`
# or `frank-job-hunt.env`, and those are the files that actually carry
# POSTGRES_PASSWORD / API keys on this fleet (issue #40).
trust.json
**/trust.json
*.pem
@@ -74,3 +78,23 @@ wt/
# A session run inside a git worktree mints a bucket named after the worktree
# path; the worktree is deleted, the bucket is orphan state, not config.
agent/memories/--home-blank-.omp-wt-*
# --- theme media: content the pipeline consumes, not configuration ---------
# 23 wallpapers, 80 MiB. Tracked until 2026-10-20: every clone of a config repo was
# carrying a library no diff can review. The live library at
# ~/.local/share/noctalia/wallpapers is a real directory of real files (measured
# 2026-10-20), so this path in the repo was a duplicate of it, not its source — and
# note that untracking deletes the copy in every other checkout regardless of
# `git rm --cached` on the branch. A fresh box starts with an empty library; every
# filename carries its wallhaven ID, so `curl -L -o <name> https://wallhaven.cc/w/<id>`
# puts it back. Law: omp-docs/tools/dotfiles-deploy.md
dotfiles/noctalia/.local/share/noctalia/wallpapers/
# --- generated theme output: machine-written, never reviewable, never deployed ---
# noctalia/matugen rewrite these in place; a tracked copy is a stale snapshot that
# deploy.sh would push back over a live palette. Patterns match the exclusion list in
# scripts/deploy.sh. Law: omp-docs/inventory/home-config-census.md, Refs #42
dotfiles/**/themes/noctalia*
dotfiles/**/colors/noctalia*
dotfiles/**/noctalia.*
dotfiles/**/noctalia-*
@@ -1,28 +0,0 @@
---
name: command-center-dotfiles-import
description: Runbook for importing client env-layer configs into the command-center repo and deploying them via stow on new fleet machines; decisions agreed with Frank 2026-09-09 (pre-execution — verify execution state before assuming done).
---
Design agreed with Frank for importing client application/environment configs into the `~/.omp` command-center repo and deploying them on fresh machines. **Status: plan agreed, not yet executed** — check repo state (does `dotfiles/` or `scripts/deploy.sh` exist?) before re-deriving; if already implemented, this becomes the operating model, not a to-do.
## Agreed decisions (each was an explicit user choice)
1. **Target**: unknown/TBD fresh machine → design stays host-agnostic; deploy derives overrides from `$(hostname)` at run time. Nothing hardcodes a target.
2. **Scope**: environment layer only — niri (+cfg includes), gtk-3.0/4.0 settings, ghostty, alacritty, shell rc files (`.zshrc`, `.bashrc`, `.profile`, `.p10k.zsh`), fish, systemd user units, autostart, dconf. App *data* (browser profiles, vaults, DBs) never imported.
3. **Layout**: stow-style package dirs `dotfiles/<pkg>/<home-relative path>` inside the repo; repo clones to `~/.omp` on every host that runs the harness (full command-center clone, not env-layer only).
4. **Deploy mechanism**: symlink farm — `stow -t ~` from `~/.omp/dotfiles`; relative symlinks within `$HOME` work because repo root lives inside `$HOME`.
5. **Dogfood order**: convert the workstation itself (real dirs → symlinks via adopt) BEFORE any target import; iterate where the compositor reload is observable.
6. **Secrets policy**: never commit secret values. Split rc files into committed logic + untracked sourced-if-exists stub (`~/.config/environment.d/secrets.env` or `.zshrc.local`); real values provisioned out-of-band only on machines that need them. Pre-commit secret scanner stays as safety net, not gate of first resort.
7. **Divergence**: `zz-<hostname>` override package stowed LAST with `--override`; only per-host diffs live there. (Stow descends into conflicting symlinked dirs — per-file overrides only work via this last-stowed `--override` pattern.)
8. **Deploy trigger**: explicit `scripts/deploy.sh` invocation, human- or agent-invoked. NO auto-deploy on pull — pull and deploy are separate decisions; `--check` = drift report mode.
9. **Ownership**: Frank is sole push authority; exactly one editing agent at a time → no multi-writer race machinery needed. Each host's agent edits only its own `zz-<hostname>` package.
10. **Conflict policy**: pre-existing regular file that differs from repo truth → move to timestamped `.quarantine/` dir on the target (never `rm`, same convention as this repo's trash rule), symlink anyway, print every replacement in deploy output and `--check` report. Repo truth always wins; divergence recoverable from quarantine.
11. **First-clone auth**: human types credential once console-side (URL-with-token or prompt); the new machine's secrets exist only there afterward.
## Implementation gotchas baked into the design
- `core.hooksPath` is a per-clone local setting — does NOT survive clone; bootstrap/deploy must set it on every fresh clone or the secret-gate pre-commit hook silently doesn't run there.
- dconf/gsettings are databases, not files: import as `dconf dump /` text into repo; deploy runs `dconf load` instead of symlinking.
- p10k installer and toolchain hooks append to rc files at install time — expect conflicts on `.zshrc`-class files at first adopt; quarantine-and-replace handles it, but diff live-vs-repo before adopting so pre-existing drift is seen, not silently adopted.
- Known secret at import time: `GITEA_TOKEN` export lived in `.zshrc` (line ~22) on the workstation — quarantine BEFORE importing rc files; consider token rotation at cutover since it had been exposed in plaintext rc + remote URL.
## Deploy script spec agreed
`scripts/deploy.sh`: sets hooksPath → stows all base packages → stows `zz-$(hostname)` with `--override` → first run uses `--adopt` for pre-existing identical files → divergent files quarantined-and-reported; `--check` mode emits drift report (modified tracked files vs deployed symlinks) without mutating.
@@ -1,33 +0,0 @@
---
name: stash-supersession-audit-before-drop
description: "Before dropping a stale git stash, prove it's superseded by HEAD via per-file line-set diff of git show stash@{0}:file vs HEAD; archive as an annotated tag and drop only after auditing unique facts — zero-loss alternative to blind drop."
---
# Stash Supersession Audit Before Drop
Use when a repo (especially a live-deployment checkout where the working tree IS the deployment, e.g. `~/.omp` command-center) has an old stash whose content may have been superseded by later commits, and dropping blindly risks losing unique facts.
## Procedure
1. **Inventory the stash** — see which files it touches:
```bash
git stash show --stat stash@{0}
```
2. **Probe for unique content per file.** For each touched file, diff stash version against HEAD and count lines that exist only in the stash version:
```bash
git show "stash@{0}:<path>" > /tmp/stash_copy
# compare normalized line sets; report lines present in stash but absent from HEAD
```
A file with `unique-vs-HEAD = 0` is fully superseded — safe to drop for that file.
3. **Classify each unique-content hit**:
- *Stale fact* (superseded by a later refactor, e.g. old model/provider naming) → drop is correct.
- *Live durable fact* (e.g. an operational invariant never re-recorded) → extract it into the live memory/docs file or flag to the user before dropping.
4. **Archive before dropping** — zero-loss: tag the stash commit, then drop from the stash list:
```bash
git tag archive/stash-<YYYY-MM-DD>-<base-branch>-wip "stash@{0}"
git stash drop "stash@{0}"
```
The tag pins the full stash object (including its index parent); recoverable later via `git show archive/stash-...` or `git checkout archive/stash-...^3 -- <path>` for the untracked-files third parent if present.
## Why
Stashes are invisible in normal history and expire from reflogs (~90 days). A stash that *looks* superseded can still hold the only copy of a durable fact; tagging converts "drop" into a reversible archive with zero workflow cost.
+15 -5
View File
@@ -111,12 +111,22 @@ Single-context, inside the existing knowledge base: `omp-docs/glossary.md` and
## Secret gate (HARD rule)
The pre-commit hook (`.githooks/pre-commit`, enabled via
`git config core.hooksPath .githooks`) scans every staged diff for live-secret
shapes (`sk-…`, `ghp_…`, `github_pat_…`, `glpat-…`, `xox…`, `AKIA…`, PEM headers,
JWTs) and blocks the commit. Quarantine list — never tracked, ever: `auth.json`,
`.env`, `trust.json`, `*.pem`, `*.key`, `identity*`. Deliberate override:
`git config core.hooksPath .githooks`) blocks the commit in two ways. Token
**shapes** (`sk-…`, `ghp_…`, `github_pat_…`, `glpat-…`, `xox…`, `AKIA…`, PEM
headers, JWTs, bcrypt hashes, `Authorization: Basic` headers) are scanned across
the **entire** staged diff — every file type, because the first version of the
gate whitelisted extensions and never read a `.env`, `.container`, `.ini`, or
extension-less dotfile at all. Credential **assignments** (`PASSWORD=…`,
`secret_key: …`) are scanned in non-prose files only; markdown keeps shape
detection, because the auto-learned memory tier is prose *about* credentials and a
gate that fires there gets overridden until it means nothing. The gate **fails
closed**: if it cannot read the staged diff it refuses the commit rather than
passing it. Quarantine list — never tracked, ever: `auth.json`, `.env` and
`*.env` (`postgres.env`, `frank-job-hunt.env` are the ones that carry real
values), `trust.json`, `*.pem`, `*.key`, `identity*`. Deliberate override:
`ALLOW_SECRETS=1 git commit …`. Remember why: a secret in a tracked file is a
secret in every agent's context window on every machine that pulls this repo.
secret in every agent's context window on every machine that pulls this repo, and
redacting it later does not un-leak the history.
## Machine facts (verify before trusting)
@@ -0,0 +1,67 @@
---
name: command-center-bundle-lint-gate
description: "Before committing docs in dank/command-center, lint the real omp-docs bundle directly — the bats suite only lints fixtures, so a green suite is not proof."
---
# The bundle lint gate (`dank/command-center`)
Adding or editing pages under `omp-docs/`? `bats tests/` going green proves
nothing about the pages you just wrote.
## Why this exists
`tests/kb-lint.bats` exercises `scripts/kb-lint.sh` against `mktemp -d` fixtures
(`"conformant bundle passes"`, `"missing frontmatter is reported"`, …). For years
nothing in the suite pointed the linter at the shipped tree, so a page with no
frontmatter, an index that stopped listing a file, or a broken relative link
committed under a green suite. (Two tests appended 2026-10-20 now run both
checkers against `omp-docs/`; verify they are still present before assuming the
suite covers it — `grep -n omp-docs tests/kb-lint.bats`.)
The general law: **a lint suite that tests only the linter does not lint your
content.** Check which one a suite runs before calling green a gate.
## Gate a docs commit
```bash
cd <worktree>
bats tests/ ; grep -E '^1\.\.' /tmp/… # suite (count, failures)
./scripts/kb-lint.sh omp-docs # want: kb-lint: 0 violation(s)
python3 scripts/kb-coverage.py omp-docs # want: index-coverage: 0 issue(s)
```
`kb-lint.sh <kb-dir>` (default `omp-docs`) = OKF v0.1 conformance **plus link
integrity**, so it is the check that absolute bundle links like
`/tools/gitea/client-access.md` resolve. `kb-coverage.py` = every page listed by
its directory `index.md` (repo doctrine: nothing may appear in a directory its
`index.md` does not list).
OKF shape that passes: every concept page carries frontmatter with a `type`
(`type: reference` + `title`/`description`/`tags`/`timestamp`/`verified`);
`index.md` files carry **no** frontmatter; new pages get an entry in their
directory index in the same commit, plus a dated entry in `omp-docs/log.md`
(one entry = what changed + the durable lesson, a few lines, never a narrative).
## Commit-message trap in this harness
A multi-line message written as a heredoc inside a chained `bash` command
(`git commit -q -F - <<'MSG' … && …`) fails with
`pi-natives:command: syntax error at end of input`. Write the message to
`/tmp/msg-<topic>.txt` with the write tool and `git commit -q -F /tmp/msg-<topic>.txt`.
Keep the `&&` chain so a failed gate stops the commit — hooks (secret gate) stay armed,
never `--no-verify`.
## Land and reap
Merge is form-encoded `do=merge&merge_style=no-ff` and answers `200` with an
**empty body**; never read that as ambiguous — verify by landing. Then
`git -C ~/.omp merge --ff-only origin/main` (the checkout *is* the deployment;
dirty `agent/memories/`, `agent/config.yml`, `APPEND_SYSTEM.md` are machine-tier
live state and do not block an ff whose diff misses them), fast-forward the
session's `wt/*` scratch branch the same way, then `git worktree remove <build-wt>`
and `git branch -d <type>/<issue>-<slug>` (`-d` succeeding is itself proof of
merge).
Stray files whose names start with `-` (e.g. `-c`, `-es` — vim takes a
`--startuptime` flag name as the output filename) show as untracked dirt:
`mv ./-c .AGENTTRASH/<date>-<meaning>.log`, never `rm`.
@@ -1,21 +1,27 @@
---
name: gitea-api-pr-operations
description: "\"Open, list, merge, and diagnose issues/PRs on the Dankclaw Gitea API (192.168.0.143:3000, repo dank/command-center) with the token stored in ~/.config/secrets/gitea — incl. the required merge payload, where review comments actually live on 1.27.3, and how to recover the session id for the mandatory body footer.\""
description: "\"Open, list, merge, and diagnose issues/PRs and manage labels on the Dankclaw Gitea API (192.168.0.143:3000) with the token stored in ~/.config/secrets/gitea — incl. the token-only write auth (Basic reads but 422s on POST/PATCH), the required merge payload, where review comments actually live on 1.27.3, and how to recover the session id for the mandatory body footer.\""
---
# Gitea API PR operations (Dankclaw `dank/command-center`)
# Gitea API operations (Dankclaw `dank/command-center`, `dank/serverAppConfigs`)
## Where
- Token: `~/.config/secrets/gitea` (`http://user:TOKEN@192.168.0.143` single line).
- Token: `~/.config/secrets/gitea` (`http://user:TOKEN@192.168.0.143` single line — git-credential URL form, NOT `username=`/`password=` lines).
Extract WITHOUT echoing:
`tok=$(sed -nE 's#^http://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)`
- Build a 600-perm temp curl config and use `curl -K`:
`cfg=$(mktemp) && chmod 600 "$cfg" && printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"`
Delete `$cfg` after use. NEVER pass `-H "Authorization: token $tok"` (the literal
header lands in transcript + logs). NEVER echo the token.
- Base URL: `http://192.168.0.143:3000/api/v1/repos/dank/command-center`.
- Base URL: `http://192.168.0.143:3000/api/v1/repos/dank/<repo>`.
## Recipes (all verified live 2026-10-18)
## Auth: reads tolerate Basic, writes DO NOT (measured 2026-10-20)
GETs succeed with an HTTP Basic header built from the same credential, but every
POST/PATCH/DELETE with Basic returns `407/422 {"message":"token is required"}`.
Always use the `Authorization: token <tok>` header for everything — never debug
"permissions" when the header shape is the fault.
## Recipes (all verified live)
- **Open PR** (token only needed for private repos; this one reads publicly but
POST needs auth): `POST /pulls` JSON `{title, head, base:"main", body}`.
- **PATCH PR title/body**: `PATCH /pulls/<n>` with JSON body `{title}` and/or
@@ -36,6 +42,12 @@ description: "\"Open, list, merge, and diagnose issues/PRs on the Dankclaw Gitea
merge --ff-only origin/main` propagates the live deployment.
- **Issues**: same shape at `/issues` (`POST {title, body}`), `/issues/<n>/comments`
(`POST {body}`, `PATCH .../comments/<id>`) — both work with the temp-cfg auth.
- **Labels**: `GET /labels` (list incl. numeric `id`); `POST /labels`
`{"name":"<name>","color":"#rrggbb"}` to create; attach by ID with
`POST /issues/<n>/labels {"labels":[<id>,...]}` (names are rejected);
detach with `DELETE /issues/<n>/labels/<id>`. serverAppConfigs gained
needs-triage / needs-info / ready-for-agent / ready-for-human / wontfix
(2026-10-20) alongside the older `human-only`.
## Review comments: the API lies on 1.27.3 (learned 2026-10-18)
- `GET /pulls/<n>/comments` (inline review comments) → **404 `not found` on every
@@ -0,0 +1,80 @@
---
name: repo-agent-handoff-doc
description: "Use when asked to write or refresh a standing handoff/status doc so agents landing in a repo can continue work isolated from other directories; sections that code cannot reveal, plus moving untracked docs onto a branch without dirtying a shared checkout."
---
# Standing agent handoff doc for a repo
"Put a complete handoff on project X in its directory so agents there can work
isolated" is not a changelog request. The value is the knowledge the code cannot
reveal. Discover the repo's own conventions first, then write one **living** file.
## 0. Fit the repo, do not invent a layout
`ls -a` + `grep -n '^##' README.md` + `find docs -maxdepth 2` before writing:
- existing `docs/` conventions (dated `docs/decisions/*.md` records vs flat pages),
- whether an `AGENTS.md`/`CLAUDE.md` auto-loads (if not, the README is the only
entry point → the handoff MUST be linked from it, same commit),
- the exact numbers you will quote: per-file `grep -c '^@test'`, `package.json`
`scripts`, module list from `ls src/`. Never write a test count or command from
memory.
## 1. Section checklist (point of use — each answers a question the next agent hits)
1. **State line**: the commit it describes (`main` at `<sha>`, "after PRs #N") +
the rule that whoever leaves work updates it in the same commit. A stale
handoff is worse than none: it gets believed.
2. **Commands** — build/typecheck/test with real runtime, plus concurrency
hazards (e.g. a suite that rebuilds `dist/` in `setup()` → two `bats` runs
corrupt each other's byte-identical pin).
3. **Test seam and how to add a test** — where the harness mock lives, scenario
names, how scripted UI answers are consumed, env dials that are *ignored*
(a `setup()` that clobbers your override), and the red-first rule with the
one exception (a characterization test that says so in a comment).
4. **The laws, each with the test that holds it** — table form. Distinguish
load-bearing invariants from incidental behaviour; this is the section that
stops a confident agent "fixing" a chosen design.
5. **Worktree/dev-dir recipe** — symlinked deps, gitignore rule that keeps the
symlink untracked, why the shared checkout stays clean.
6. **Ship path** — branch → tests → rebuild committed artifact → PR → merge on the
owner's OK → land with `--ff-only` → deploy (the gotcha that the upgrade target
is the PACKAGE name, not the directory alias) → `cmp` the live artifact against
the merged build → prune. Include the auth traps of the forge (token via a
600 curl `-K` file, empty token answering 404 not 401, form-encoded merge).
7. **Live verification** — the instrument that produces evidence (stub player,
log line counts, pty submit semantics), and explicitly what is NOT a check
(print-mode invocation, screen reads of transient toasts, single-take audio
comparisons).
8. **Environment reality not visible from this code** — the live endpoints,
measured limits, refusals; crosslink the README if it owns the table.
9. **Open items** — numbered, each actionable alone, each saying who decides if a
human must; name the repo a fix belongs in when it is NOT this one.
10. **History** — one line per issue/PR. No tables of metrics.
One owner per fact: link the README/decision records instead of restating knob
tables or rationale.
## 2. Tree hygiene (the costly step)
Writing straight into a shared checkout leaves an untracked file on `main` — a
half-deployed state where the checkout is the deployment, and a dirty tree for
everyone else. Move it onto a branch without switching anyone's HEAD:
```bash
mv <repo>/docs/handoff.md /tmp/handoff.md
git -C <repo> fetch -q origin
git -C <repo> worktree add -q -b docs/<slug> ~/Projects/wt-<topic> origin/main
ln -s <repo>/node_modules ~/Projects/wt-<topic>/node_modules # if deps are needed
mv /tmp/handoff.md ~/Projects/wt-<topic>/docs/handoff.md
git -C <repo> status --porcelain # shared checkout clean again
```
README pointer edit happens **in the worktree** (an edit there is a separate
checkout; editing the shared one dirties a tracked file). Then commit, run the
suite, push, open the PR, and merge only on the owner's explicit OK.
## 3. Voice rules
Prose in the repo's own style (match an existing decision record). State reasons
and consequences, not bookkeeping. No measured numbers for things that drift;
keep the law, drop the figure.
@@ -7,9 +7,9 @@
- Hermes (Dankclaw) model routing as of 2026-09-05: main default = frank-large (Dankclaw 192.168.0.143:7770/v1), small/aux routes = frank-small (Dankserver 192.168.0.235:7770/v1), vision + title_generation route to the exact FLM registry tag gemma4-it:e4b (192.168.0.143:7771/v1). The pre-rename aliases frank-default-large/-small/-sightsound are dead since the 2026-09-03 rename cutover — never route on them. Live config lives at /home/dank/Containers/hermes/data/config.yaml (owned by uid 109999; needs sudo to read/edit); the repo's dankclaw/hermes/config.yaml.example is a sanitized backup and can lag live, so diff live first before editing either. Signal adapter is currently disabled in live config (platforms.signal.enabled: false).
- On the guarded franksdank.cloud vhosts (Caddy → Anubis :8923 → Authentik outpost), every unauthenticated request — roots and deep paths alike — is answered with HTTP **200** and an HTML challenge page (titleMaking sure you're not a bot!/title), not a 4xx. Verifying guard health by status code alone is meaningless; assert on leakage markers instead (copyparty UI title, homepage id="grid", services.json "category" JSON shape). Non-JS clients (curl/wget/WebDAV) can never solve the PoW challenge, so they are permanently parked at the challenge page — this is the entire security boundary for copyparty, which serves authenticated traffic with anonymous read perms (-v /w::r:ra,dank). Pinned by tests/e2e/specs/15-unauth-fetch.spec.ts (guest project, empty storage state): any guard-route regression that lets app bytes answer fails loudly; any gate implementation passes.
- On Dankclaw's Pi-hole (FTL v6.7, quadlet at /home/dank/Containers/pihole), /etc/pihole/hosts/custom.list (host path /home/dank/Containers/pihole/etc-pihole/hosts/custom.list) is a LIVE-ONLY Custom-DNS source NOT tracked in the repo — it held ~22 A records pointing every *.franksdank.cloud vhost at 192.168.0.143, including names with no public Porkbun record. gravity.db's domainlist table has zero custom-DNS rows on this build, and the pihole CLI's sqlite path (pihole-FTL sqlite3) works but the web API auth was failing at discovery time, so retiring a name from LAN DNS requires editing custom.list directly (sudo sed -i '/name/d') followed by podman exec pihole pihole restartdns reload; deleting only the repo-managed addn-hosts record is not sufficient. Verify with dig +short name @192.168.0.143 → empty answer.
- Pi-hole admin panel at pihole.franksdank.cloud has two auth layers: Caddy basicauth (edge, username dankuser + hash) and Pi-hole internal password (CivetWeb, set via pihole setpassword). The Pi-hole internal password was the actual failure point, not the Caddy hash. Pi-hole resolves to 192.168.0.143 via addn-hosts (all 28 names point here), making it a LAN service behind Caddy's public TLS edge. Options to remove public exposure: (1) remove the Caddy vhost entirely — LAN clients hit Pi-hole quadlet directly on :8083; (2) keep Caddy vhost but remove the Porkbun A record so external DNS won't resolve pihole.franksdank.cloud. Current state: Caddy basicauth hash = $2b$14$cJm.hbvDi1dEh7x0XRMnseaIdvIrRyH.AcTL56jGJf3wd54qvEb9K (dankuser, password pihole2026); Pi-hole internal password = pihole.2026 (set via pihole setpassword, survives restart).
- Pi-hole admin panel at pihole.franksdank.cloud has two auth layers: Caddy basicauth (edge, username dankuser + hash) and Pi-hole internal password (CivetWeb, set via pihole setpassword). The Pi-hole internal password was the actual failure point, not the Caddy hash. Pi-hole resolves to 192.168.0.143 via addn-hosts (all 28 names point here), making it a LAN service behind Caddy's public TLS edge. Options to remove public exposure: (1) remove the Caddy vhost entirely — LAN clients hit Pi-hole quadlet directly on :8083; (2) keep Caddy vhost but remove the Porkbun A record so external DNS won't resolve pihole.franksdank.cloud. Current state: Caddy basicauth hash and the Pi-hole internal password are BOTH redacted (2026-10-20, issue #40) — they sat here in plaintext, which means they are in git history on every machine that ever pulled this repo, so both need rotation before this note is trustworthy; the live values are the Dankclaw host `.env` (`CADDY_BASICAUTH_HASH`) and `pihole setpassword`, never this file.
- Pi-hole admin at pihole.franksdank.cloud has two auth layers: (1) Caddy basicauth edge (username dankuser, hash in rendered Caddyfile from CADDY_BASICAUTH_HASH in .env), (2) Pi-hole internal CivetWeb password (stored in cli_pw as bcrypt hash, set via pihole setpassword CLI). The env file WEBPASSWORD is used at container start but the CLI hash is what the running web server validates. When "I can't get into Pi-hole admin" returns 403 even after Caddy basicauth passes, the Pi-hole internal password is the likely culprit. Fix: podman exec pihole pihole setpassword newpw to update the CLI hash, then verify podman exec pihole cat /etc/pihole/cli_pw shows updated value. Verify by curling with basicauth: curl -sk -H 'Authorization: Basic ZGFua3VzZXI6...' https://pihole.franksdank.cloud/admin/ should return 302 (not 401 or 403).
- Pi-hole admin panel at pihole.franksdank.cloud uses Caddy basicauth (not Pi-hole's own CivetWeb login). The hash is shared across services (RomM, Copyparty, Pi-hole) via CADDY_BASICAUTH_HASH in the host .env. The rendered Caddyfile at /home/dank/Containers/caddy/Caddyfile is mounted into the Caddy container — updates are picked up by Caddy's file watcher without restart. Password reset: generate bcrypt hash with python3 + bcrypt (hashpw + gensalt), update the rendered Caddyfile line directly or update .env + deploy. Caddy accepts both $2a$ and $2b$ bcrypt variants. _(context: Pi-hole admin password reset session: discovered Caddy basicauth (not Anubis), could not find matching password in common candidates, reset to pihole2026, updated rendered Caddyfile directly, synced repo .env files, Caddy file watcher handled hot-reload.)_
- Pi-hole admin panel at pihole.franksdank.cloud uses Caddy basicauth (not Pi-hole's own CivetWeb login). The hash is shared across services (RomM, Copyparty, Pi-hole) via CADDY_BASICAUTH_HASH in the host .env. The rendered Caddyfile at /home/dank/Containers/caddy/Caddyfile is mounted into the Caddy container — updates are picked up by Caddy's file watcher without restart. Password reset: generate bcrypt hash with python3 + bcrypt (hashpw + gensalt), update the rendered Caddyfile line directly or update .env + deploy. Caddy accepts both $2a$ and $2b$ bcrypt variants. _(context: Pi-hole admin password reset session: discovered Caddy basicauth (not Anubis), could not find matching password in common candidates, reset to the current password [redacted 2026-10-20, issue #40], updated rendered Caddyfile directly, synced repo .env files, Caddy file watcher handled hot-reload.)_
- On the FrankBook-Pro *arr fleet (current lsio images: Sonarr v4/Radarr v5 API v3, Lidarr v2/Readarr API v1), the download-client validation endpoints are NOT usable via API: POST /api/{v}/downloadclient/test/{id} and .../test?forceTest=true both return 405, and POST .../downloadclient/action/getTest exists but 400s without a bespoke body. To prove download-client health after changing a qB host/port, skip the API test route and prove behaviorally instead: podman exec arr curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/ (expect 200) plus a real qB login from inside the container (curl --data-urlencode username=admin --data-urlencode password=$QBIT_PW .../api/v2/auth/login, expect 200), then confirm /api/{v}/health shows no DownloadClient-*/wbrrelated warnings. Also: arr APIs redact stored client passwords as ******** (sonarr/radarr/lidarr) — PUT the object back with the sentinel untouched to preserve the stored value; readarr (v1) returns the plaintext. When editing via the edit tool, body rows must be prefixed with +, not : — : prefixes get written literally into the file. _(context: Capture turn after the arr→qB loopback repoint (2026-09-04): app-side download-client test endpoints were unreachable, so client health was proven behaviorally instead.)_
- The *arr fleet (Prowlarr, Sonarr, Radarr, Lidarr, Readarr, qBittorrent) runs ONLY on FrankBook-Pro (192.168.0.112, Mac podman machine) since 2026-09-04 — the dankserver compose copy was removed (config roots in ~/.AGENTTRASH/20260904-dankserver-arr-teardown, 22 MB, awaiting purge approval). Fleet invariant, enforced by tests/frankbook_quadlet.bats: ALL six containers share gluetun's netns (Network=gluetun.container, Wants/After gluetun.service) and publish NO ports; the gluetun unit publishes the union (8080, 6881 tcp+udp, 9696, 8989, 7878, 8686, 8787), so all egress exits ProtonVPN by construction (AllowedIPs=0.0.0.0/0; proven: netns egress 51.179.177.14 vs residential 71.227.227.172). deploy-plan.py couples gluetun → all six joiners (a gluetun recreate kills the whole family). Consequences: (1) the old arr→qB addressing hack 10.88.0.1:8080 (podman-bridge gateway) routes INTO the tunnel from inside the netns — download clients must use 127.0.0.1:8080 (done via arr APIs, verified by real qB login from inside sonarr's container); same rule for any future Prowlarr Application rows (baseUrl 127.0.0.1:arr port). (2) Caddy arr vhosts and homepage probes target 192.168.0.112:port (host-side published ports unaffected by netns changes). (3) Known defects as of 2026-09-04: Prowlarr's Applications table is EMPTY (the four rows from 2026-08-30 were lost — prowlarr→arr sync broken until rebuilt); readarr's qB proxy auth defect needs retest post-move; EZTV/1337x "CloudFlare-blocked" parking is stale (egress now VPN — retest before enabling). (4) E2E lesson: playwright specs 11-admin/12-crawl now derive expected open-link counts from services.json (showLink) instead of hardcoded counts — keep new registry entries consistent. Also: deploy.sh --service X dispatches ONLY X on frankbook (path-coupling applies to changed-path planning); gluetun restart self-heals all netns joiners via Restart=always so a single gluetun deploy converges the family. _(context: 2026-09-04 operator decision session: dankserver arr compose copy (booted that morning) removed after Frank chose FrankBook-Pro as the sole arr deployment; netns consolidation + test seam + loopback repoint executed same session. Commits 4c7465d, df4339a.)_
- Rootless podman on Dankserver (5.7.0, verified 2026-09-04 via /proc/pid/uid_map across 7 containers): the DEFAULT user namespace maps container uid 0 → host uid 1000 (dank) and container uid N → host 100000+N-1 (e.g. calibre-web PUID=1000 writes as 100999). Consequences: (1) PUID=0 + data roots owned 1000:1000 is the complete fleet recipe — NO UserNS/userns_mode key is needed on quadlets or compose services; the plex quadlet works without one. (2) NEVER add userns_mode: host to a podman-compose service: podman-compose 1.5.0 runs every compose service inside a pod and podman hard-rejects the combo (--userns and --pod cannot be set together), leaving the stack down with no recreate path. (3) Diagnostics: podman exec c id always shows uid=0 inside any mapping — meaningless; ground truth is /proc/container-pid/uid_map, host-side ps -o user= -p pid, or ownership of freshly-written files. (4) *arr boot-crash signature: UnauthorizedAccessException: Access to the path '/config/...' at logger init = data root not writable (e.g. root-owned dir created by sudo mkdir); the app exits before binding its port, so the port probe reads "dead" rather than "crashed" — check podman logs tail for "Press enter to exit" before assuming an outage has an infra cause. (5) NEVER pkill -f rootlessport on these hosts: it kills the port-forward helper of EVERY published container simultaneously (2026-09-03 outage); restart the owning systemd units instead. _(context: Dankserver *arr stack restore session, 2026-09-04: stack committed 2026-08-29 never booted (Sentry crash, dead ports read as outage); userns_mode: host fix was a red herring — rejected by podman-compose pods; real fix was chown 1000:1000 of /opt config dirs. Commit e454159 (revert), 8a63624 (docs).)_
@@ -0,0 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792536631688
}
@@ -3,4 +3,8 @@
- Typed intro: "Update, [randomly generated epithet]!"
- Heckle mode option
- Typed outro: "More later, over and out!"
- Generation frequency control
- Generation frequency control
## Documentation Flow
- Put any created docs in the existing knowledgebase instead of creating a new one
@@ -1,10 +1,10 @@
{
"v": 1,
"lastConsolidatedAt": 1792524641573,
"lastConsolidatedAt": 1792538189166,
"lastResult": {
"at": 1792452243238,
"at": 1792537233005,
"sessions": 1,
"deltas": 4,
"deltas": 1,
"model": "frank-zealot"
}
}
@@ -1,2 +1,2 @@
## Handoff Location
- put it at ~/Projects
- put it at ~/Projects
@@ -1,8 +1,8 @@
{
"v": 1,
"lastConsolidatedAt": 1792534456791,
"lastConsolidatedAt": 1792538116812,
"lastResult": {
"at": 1792532921161,
"at": 1792537164207,
"sessions": 1,
"deltas": 1,
"model": "frank-zealot"
@@ -1,4 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792534385325
"lastConsolidatedAt": 1792537985341
}
@@ -0,0 +1,3 @@
## Documentation structure
- Store agent-related docs in `dankvoice-docs`
@@ -0,0 +1,10 @@
{
"v": 1,
"lastConsolidatedAt": 1792538005214,
"lastResult": {
"at": 1792537052571,
"sessions": 1,
"deltas": 2,
"model": "frank-zealot"
}
}
@@ -0,0 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792536921764
}
-10
View File
@@ -9,8 +9,6 @@ window-size=(360, -1)
[org/gnome/desktop/background]
picture-options='zoom'
picture-uri='file:///home/dank/.config/background'
picture-uri-dark='file:///home/dank/.config/background'
[org/gnome/desktop/interface]
color-scheme='prefer-dark'
@@ -26,14 +24,6 @@ default-visible-columns=['name', 'size', 'type', 'date_modified']
default-folder-viewer='list-view'
migrated-gtk-settings=true
[org/gnome/nautilus/window-state]
initial-size=(890, 550)
initial-size-file-chooser=(890, 550)
maximized=false
[org/gnome/portal/filechooser/firefox]
last-folder-path='/home/dank/Downloads'
[org/gtk/gtk4/settings/file-chooser]
show-hidden=false
sort-directories-first=false
@@ -1,21 +0,0 @@
// ────────────── Output Configuration ──────────────
output "Samsung Electric Company Odyssey G75F HNTY601056" {
mode "3840x2160@60"
scale 1
transform "normal"
position x=0 y=0
}
output "DP-1" {
transform "90"
position x=0 y=0
}
output "DP-3" {
transform "270"
position x=1200 y=0
}
output "eDP-1" {
// no position — auto-placed to the right of DP-* outputs
}
@@ -275,13 +275,13 @@ transition = [ "fade" ]
interval_seconds = 1200
[wallpaper.default]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v93dk3.jpg"
[wallpaper.last]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v93dk3.jpg"
[wallpaper.monitors.DP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v93dk3.jpg"
[wallpaper.monitors.DP-2]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg"
@@ -290,7 +290,7 @@ transition = [ "fade" ]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v9e2w3.jpg"
[wallpaper.monitors.eDP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v93dk3.jpg"
[weather]
unit = "imperial"
@@ -2,6 +2,13 @@
Description=swayidle idle lock (locks via noctalia-shell IPC, suspends on lid)
Documentation=https://github.com/swaywm/swayidle
After=graphical-session.target
# Restart together with the session, not merely at boot: this unit is wanted by
# default.target, so a compositor restart used to leave it orphaned while the target came
# back without it. Refs #47.
PartOf=graphical-session.target
# With Restart=always below, a fast failure loop must never wedge the watcher into `failed`:
# a lock that keeps trying beats a lock that stopped trying. Refs #47.
StartLimitIntervalSec=0
[Service]
# Locks via the noctalia v5 shell; `noctalia msg` resolves the running instance
@@ -11,7 +18,10 @@ ExecStart=/usr/bin/swayidle -w \
timeout 300 '/usr/bin/noctalia msg session lock' \
before-sleep '/usr/bin/noctalia msg session lock' \
after-resume '/usr/bin/noctalia msg session lock'
Restart=on-failure
# `swayidle -w` exits 0 on SIGTERM, and that is how this watcher died on 2026-10-12: a clean
# exit that `Restart=on-failure` cannot see, leaving the box unlocked for a week. A supervisor
# restarts always. Refs #47.
Restart=always
RestartSec=5s
[Install]
@@ -1,29 +0,0 @@
layout {
gaps 16 // Gap between windows
center-focused-column "never" // Don’t auto-center focused column
background-color "transparent" // <- needed for noctalia-shell to set wallpaper
preset-column-widths {
proportion 0.25 // 1/4
proportion 0.3333 // 1/3
proportion 0.5 // 1/2
}
preset-window-heights {
proportion 0.25 // 1/4
proportion 0.5 // 1/2
}
struts {
}
shadow
focus-ring {
width 2
active-color "#e01b24"
}
border {
off
}
}
@@ -1,32 +0,0 @@
layout {
focus-ring {
active-color "#ff8c00"
inactive-color "#181818"
urgent-color "#ff8c00"
}
border {
active-color "#f43841"
inactive-color "#181818"
urgent-color "#f43841"
}
tab-indicator {
active-color "#f43841"
inactive-color "#74050b"
urgent-color "#f43841"
}
insert-hint {
color "#f4384180"
}
}
recent-windows {
highlight {
active-color "#f43841"
urgent-color "#f43841"
}
}
@@ -1,4 +0,0 @@
// ────────────── Startup Applications ──────────────
// https://github.com/YaLTeR/niri/wiki/Configuration:-Miscellaneous#spawn-sh-at-startup
spawn-at-startup "noctalia"
@@ -1,142 +0,0 @@
binds {
// ────────────── Keybindings ──────────────
// https://github.com/YaLTeR/niri/wiki/Configuration:-Key-Bindings
// ─── noctalia-shell keybinds ───
// https://docs.noctalia.dev/getting-started/keybinds/
Mod+Shift+Escape { show-hotkey-overlay; }
// ─── Applications ───
Mod+Return hotkey-overlay-title="Open Terminal: Ghostty" { spawn "ghostty"; }
Mod+CTRL+Return hotkey-overlay-title="Open App Launcher: noctalia launcher" { spawn-sh "noctalia msg panel-toggle launcher"; }
Mod+B hotkey-overlay-title="Open Browser: firefox" { spawn "firefox"; }
Mod+ALT+L hotkey-overlay-title="Lock Screen: noctalia lock" { spawn-sh "noctalia msg session lock"; }
Mod+Shift+Q hotkey-overlay-title="Session Menu: noctalia session" { spawn-sh "noctalia msg panel-toggle session"; }
// Please choose your own file manager.
Mod+E hotkey-overlay-title="File Manager: Nautilus" { spawn "nautilus"; }
// ─── Media Controls ───
XF86AudioRaiseVolume allow-when-locked=true { spawn-sh "noctalia msg volume-up"; }
XF86AudioLowerVolume allow-when-locked=true { spawn-sh "noctalia msg volume-down"; }
XF86AudioMute allow-when-locked=true { spawn-sh "noctalia msg volume-mute"; }
XF86AudioMicMute allow-when-locked=true { spawn-sh "noctalia msg mic-mute"; }
XF86AudioNext allow-when-locked=true { spawn-sh "noctalia msg media next"; }
XF86AudioPrev allow-when-locked=true { spawn-sh "noctalia msg media previous"; }
XF86AudioPlay allow-when-locked=true { spawn-sh "noctalia msg media playPause"; }
XF86AudioPause allow-when-locked=true { spawn-sh "noctalia msg media playPause"; }
// ─── Brightness Controls ───
XF86MonBrightnessUp allow-when-locked=true { spawn-sh "noctalia msg brightness-up"; }
XF86MonBrightnessDown allow-when-locked=true { spawn-sh "noctalia msg brightness-down"; }
// ─── Window Movement and Focus ───
Mod+Q { close-window; }
Mod+Left { focus-column-left; }
Mod+H { focus-column-left; }
Mod+Right { focus-column-right; }
Mod+L { focus-column-right; }
Mod+Up { focus-window-up; }
Mod+K { focus-window-up; }
Mod+Down { focus-window-down; }
Mod+J { focus-window-down; }
// Win+Ctrl consume/expel windows to/from columns
Mod+CTRL+Left { consume-or-expel-window-left; }
Mod+CTRL+H { consume-or-expel-window-left; }
Mod+CTRL+Right { consume-or-expel-window-right; }
Mod+CTRL+L { consume-or-expel-window-right; }
Mod+CTRL+UP { expel-window-from-column; }
Mod+CTRL+K { expel-window-from-column; }
Mod+CTRL+Down { expel-window-from-column; }
Mod+CTRL+J { expel-window-from-column; }
Mod+Home { focus-column-first; }
Mod+End { focus-column-last; }
Mod+CTRL+Home { move-column-to-first; }
Mod+CTRL+End { move-column-to-last; }
Mod+Shift+Left { focus-monitor-left; }
Mod+Shift+Right { focus-monitor-right; }
Mod+Shift+UP { focus-monitor-up; }
Mod+Shift+Down { focus-monitor-down; }
Mod+Shift+CTRL+Left { move-column-to-monitor-left; }
Mod+Shift+CTRL+Right { move-column-to-monitor-right; }
Mod+Shift+CTRL+UP { move-column-to-monitor-up; }
Mod+Shift+CTRL+Down { move-column-to-monitor-down; }
// ─── Workspace Switching ───
Mod+WheelScrollDown cooldown-ms=150 { focus-workspace-down; }
Mod+WheelScrollUp cooldown-ms=150 { focus-workspace-up; }
Mod+CTRL+WheelScrollDown cooldown-ms=150 { move-column-to-workspace-down; }
Mod+CTRL+WheelScrollUp cooldown-ms=150 { move-column-to-workspace-up; }
Mod+WheelScrollRight { focus-column-right; }
Mod+WheelScrollLeft { focus-column-left; }
Mod+CTRL+WheelScrollRight { move-column-right; }
Mod+CTRL+WheelScrollLeft { move-column-left; }
Mod+Shift+WheelScrollDown { focus-column-right; }
Mod+Shift+WheelScrollUp { focus-column-left; }
Mod+CTRL+Shift+WheelScrollDown { move-column-right; }
Mod+CTRL+Shift+WheelScrollUp { move-column-left; }
Mod+1 { focus-workspace 1; }
Mod+2 { focus-workspace 2; }
Mod+3 { focus-workspace 3; }
Mod+4 { focus-workspace 4; }
Mod+5 { focus-workspace 5; }
Mod+6 { focus-workspace 6; }
Mod+7 { focus-workspace 7; }
Mod+8 { focus-workspace 8; }
Mod+9 { focus-workspace 9; }
Mod+CTRL+1 { move-column-to-workspace 1; }
Mod+CTRL+2 { move-column-to-workspace 2; }
Mod+CTRL+3 { move-column-to-workspace 3; }
Mod+CTRL+4 { move-column-to-workspace 4; }
Mod+CTRL+5 { move-column-to-workspace 5; }
Mod+CTRL+6 { move-column-to-workspace 6; }
Mod+CTRL+7 { move-column-to-workspace 7; }
Mod+CTRL+8 { move-column-to-workspace 8; }
Mod+CTRL+9 { move-column-to-workspace 9; }
Mod+TAB { focus-workspace-previous; }
// ─── Layout Controls ───
Mod+CTRL+F { expand-column-to-available-width; }
Mod+C hotkey-overlay-title="Calculator" { spawn "gnome-calculator"; }
Mod+CTRL+C { center-visible-columns; }
Mod+Minus { set-column-width "-10%"; }
Mod+Equal { set-column-width "+10%"; }
Mod+Shift+Minus { set-window-height "-10%"; }
Mod+Shift+Equal { set-window-height "+10%"; }
// ─── Split / Consume / Expel ───
Mod+Shift+H { consume-or-expel-window-left; }
Mod+Shift+L { consume-or-expel-window-right; }
Mod+Shift+J { expel-window-from-column; }
// ─── Modes ───
Mod+T { toggle-window-floating; }
Mod+F { fullscreen-window; }
Mod+W { toggle-column-tabbed-display; }
// ─── Screenshots ───
CTRL+Shift+1 hotkey-overlay-title="Screenshot: flameshot full" { spawn-sh "flameshot full"; }
CTRL+Shift+2 hotkey-overlay-title="Screenshot: flameshot screen" { spawn-sh "flameshot screen"; }
CTRL+Shift+3 hotkey-overlay-title="Screenshot: flameshot region" { spawn-sh "flameshot gui"; }
// ─── Emergency Escape Key ───
// Use this when a fullscreen app blocks your keybinds.
// It disables any active keyboard shortcut inhibitor, restoring control.
Mod+ESCAPE allow-inhibiting=false { toggle-keyboard-shortcuts-inhibit; }
// ─── Exit / Power ───
CTRL+ALT+Delete { quit; } // Also quits Niri
Mod+Shift+P { power-off-monitors; } // Turn off screens (useful for OLED or privacy)
Mod+O repeat=false { toggle-overview; }
}
@@ -1,18 +0,0 @@
[Unit]
Description=swayidle idle lock (locks via noctalia-shell IPC, suspends on lid)
Documentation=https://github.com/swaywm/swayidle
After=graphical-session.target
[Service]
# Locks via the noctalia v5 shell; `noctalia msg` resolves the running instance
# through the D-Bus session bus, so no WAYLAND_DISPLAY pinning is needed in unit context.
# deploy.sh enables it only where the ExecStart binary resolves, so it stays inert until swayidle is installed.
ExecStart=/usr/bin/swayidle -w \
timeout 300 '/usr/bin/noctalia msg session lock' \
before-sleep '/usr/bin/noctalia msg session lock' \
after-resume '/usr/bin/noctalia msg session lock'
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=default.target
+171 -222
View File
@@ -1,241 +1,190 @@
---
type: reference
title: Home Directory Configuration Census
description: Complete inventory of userspace program configurations on the workstation — top-level dotfiles, ~/.config, ~/.local/share state, and special directories (~/.pi, ~/.omp).
tags: [inventory, unverified, census, dotfiles, config]
title: Home Configuration Census (blankpad)
description: Per-file tracked-vs-box-only census of ~/.config on blankpad — the input for the dotfiles pruning/adoption session.
tags: [inventory, census, dotfiles, config]
created: 2026-08-28
census-date: 2026-08-28
source: Workstation CachyOS laptop
census-date: 2026-10-20
verified: 2026-10-20
source: blankpad (CachyOS, /home/blank)
---
## Scope & Methodology
This page is the input for the dotfiles pruning/adoption session: for every
`~/.config` entry it separates what the repo already tracks from what lives only
on this box, and sorts the box-only half into adopt / never-track / prune.
Census covers four areas: (1) top-level dotfiles/dotdirs in `/home/dank` (excluding `.omp` which is documented separately here), (2) every entry under `~/.config`, (3) non-cache state dirs under `~/.local/share`, (4) special directories `~/.pi` and `~/.omp`. Each entry assessed for installed status (`command -v`), size (`du -sh`), and age (mtime). Uninstalled programs flagged as `prune-candidate`.
## Method
All measurements are live — no invented numbers.
Classification is per **FILE**, not per directory: the tracked unit is a symlink
into `~/.omp/dotfiles/`. A directory can look "real" (a plain folder, not itself
a symlink) while every file inside it is a tracked symlink — `niri` and `gtk-3.0`
do exactly that. Conversely a directory can be a whole symlink into `dotfiles`
(`lazyvim`) and be entirely tracked. So a directory-level verdict lies; only the
leaf symlink target is ground truth.
---
- **tracked** = a file whose fully-resolved target (`readlink -f`) is under
`/home/blank/.omp/dotfiles/`.
- **box-only** = a regular file, or a symlink that resolves elsewhere.
## 1. Top-Level Dotfiles / Dotdirs
Rerun this classifier rather than trusting the table below:
> 38 entries in `/home/dank` beginning with `.` (excluding `.omp`).
```bash
DOT=/home/blank/.omp/dotfiles; CFG=/home/blank/.config
for d in "$CFG"/*; do
[ -L "$d" ] && case "$(readlink -f "$d")" in "$DOT"/*) echo "$(basename "$d"): tracked=ALL box-only=0"; continue;; esac
[ -d "$d" ] || { echo "$(basename "$d"): FILE (top-level)"; continue; }
tr=0; bo=0; names=""
while IFS= read -r -d '' f; do
if [ -L "$f" ]; then case "$(readlink -f "$f")" in "$DOT"/*) tr=$((tr+1)); continue;; esac; fi
bo=$((bo+1)); names+=" ${f#"$CFG"/}"
done < <(find "$d" -mindepth 1 \( -type f -o -type l \) -print0 2>/dev/null)
printf '%s: tracked=%d box-only=%d%s\n' "$(basename "$d")" "$tr" "$bo" "$names"
done
```
| Path | What it is | Installed? | Size | Age (mtime) | Prune? |
|---|---|---|---|---|---|
| `.bash_history` | Bash command history | yes (bash) | 12K | 2026-08-27 | — |
| `.bash_logout` | Bash logout script | yes (bash) | 4.0K | 2026-07-03 | — |
| `.bash_profile` | Bash login script | yes (bash) | 4.0K | 2026-07-03 | — |
| `.bashrc` | Bash config | yes (bash) | 4.0K | 2026-07-12 | — |
| `.bun` | Bun runtime install | yes | 2.3G | 2026-07-05 | — |
| `.cache` | XDG cache directory | system | 6.4G | 2026-08-28 | — |
| `.claude` | Claude Code config | **no** | 6.4M | 2026-07-12 | **prune-candidate** |
| `.config` | XDG config directory | system | 574M | 2026-08-25 | — |
| `.env` | Environment variables | system | 4.0K | 2026-08-21 | — |
| `.git-credentials` | Git auth cache | yes (git) | 4.0K | 2026-08-28 | — |
| `.gitconfig` | Git global config | yes (git) | 4.0K | 2026-08-05 | — |
| `.local` | XDG local directory | system | 552G | 2026-07-24 | — |
| `.npm` | Node package manager cache | yes (node) | 1.7G | 2026-07-23 | — |
| `.nv` | nvim (Neovim) config | **no** | 8.0K | 2026-07-03 | **prune-candidate** |
| `.nvidia-settings-rc` | NVIDIA Settings config | yes | 4.0K | 2026-07-04 | — |
| `.nvm` | Node Version Manager | **no** | 256M | 2026-07-06 | **prune-candidate** |
| `.openpeon` | OMP Peon session state | yes (omp) | 4.4M | 2026-07-16 | — |
| `.p10k.zsh` | Powerlevel10k zsh theme | yes (zsh) | 96K | 2026-07-03 | — |
| `.paradoxlauncher` | Paradox Launcher state | **no** | 1.1G | 2026-07-31 | **prune-candidate** |
| `.pi` | Legacy PI agent (git repo) | — | 2.5G | 2026-08-17 | see §4 |
| `.pki` | System PKI certs | system | 120K | 2026-07-03 | — |
| `.pulse-cookie` | PulseAudio session | system | 4.0K | 2026-07-03 | — |
| `.python_history` | Python REPL history | yes (python3) | 4.0K | 2026-08-06 | — |
| `.ssh` | SSH keys & config | yes | 28K | 2026-08-24 | — |
| `.steam` | Steam runtime + config | yes | 44K | 2026-08-26 | — |
| `.steampath` | Steam executable path | yes (steam) | 4.0K | 2026-07-31 | — |
| `.steampid` | Steam PID file | yes (steam) | 4.0K | 2026-07-31 | — |
| `.tmux-resurrect` | Tmux resurrect state | yes (tmux) | 0 | 2026-08-25 | — |
| `.tmux.conf` | Tmux configuration | yes (tmux) | 4.0K | 2026-07-18 | — |
| `.wget-hsts` | Wget HSTS database | yes (wget) | 4.0K | 2026-07-06 | — |
| `.zcompdump-<host>-5.9.1` | Zsh completions v5.9.1 | yes (zsh) | 56K | 2026-07-12 | — |
| `.zcompdump-<host>-5.9.1.zwc` | Zsh compiled completions v5.9.1 | yes (zsh) | 120K | 2026-07-12 | — |
| `.zcompdump-<host>-5.9.2` | Zsh completions v5.9.2 | yes (zsh) | 56K | 2026-08-19 | — |
| `.zcompdump-<host>-5.9.2.lock` | Zsh completions lock | yes (zsh) | 0 | 2026-08-25 | — |
| `.zcompdump-<host>-5.9.2.zwc` | Zsh compiled completions v5.9.2 | yes (zsh) | 120K | 2026-08-19 | — |
| `.zsh_history` | Zsh command history | yes (zsh) | 68K | 2026-08-28 | — |
| `.zshrc` | Zsh configuration | yes (zsh) | 4.0K | 2026-08-25 | — |
## ~/.config — tracked vs box-only
---
Profile/browser dirs (`mozilla` 5471 files, `obsidian` 99, `FreeTube` 56,
`nirimod` 116, `google-chrome-for-testing` 5) are app state, never tracked, and
are omitted as rows. Box-only bulk that is regenerated-by-the-program is named
by category, not file-by-file.
## 2. ~/.config — Program Configuration Directory
| `~/.config/` dir | tracked | box-only | box-only files |
|---|--:|--:|---|
| alacritty | 1 | 1 | `themes/noctalia.toml` (generated) |
| autostart | 0 | 3 | `Flameshot.desktop`, `cachyos-hello.desktop`, `.AGENTTRASH/Handy.desktop` |
| bat | 0 | 2 | `config`, `themes/noctalia.tmTheme` (generated) |
| btop | 0 | 2 | `btop.conf`, `themes/noctalia.theme` (generated) |
| cachyos | 0 | 1 | `cachyos-hello/cachyos-hello.log` (state) |
| cava | 0 | 1 | `themes/noctalia` (generated) — program absent, prune |
| crush | 0 | 37 | `skills/<37>` — skills-CLI install |
| containers | 0 | 2 | `systemd/franksign-db.container`, `systemd/postgres.env` (see §Owned-elsewhere / secret tier) |
| dconf | 0 | 1 | `user` (compiled dconf db, state) |
| devin | 0 | 37 | `skills/<37>` — skills-CLI install |
| fastfetch | 0 | 1 | `themes/noctalia.jsonc` (generated) |
| fish | 2 | 1 | `fish_variables` (fish var store, state) |
| flameshot | 0 | 1 | `flameshot.ini` |
| foot | 0 | 2 | `foot.ini`, `themes/noctalia` (generated) — program absent, prune |
| fzf | 0 | 2 | `themes/noctalia.sh`, `themes/noctalia.fish` (generated) |
| ghostty | 2 | 1 | `themes/noctalia` (generated) |
| glow | 0 | 2 | `glow.yml`, `noctalia.json` (generated) |
| goose | 0 | 37 | `skills/<37>` — skills-CLI install |
| gtk-3.0 | 3 | 0 | — |
| gtk-4.0 | 2 | 1 | `servers` (KDE-generated) |
| helix | 0 | 1 | `themes/noctalia.toml` (generated) — `hx` absent, prune |
| herdr | 0 | 1 | `noctalia-colors.toml` (generated) |
| hypr | 0 | 2 | `hyprland.conf`, `noctalia.conf` (generated) — `hyprland` absent, prune |
| kimchi | 0 | 37 | `harness/skills/<37>` — skills-CLI install |
| kitty | 0 | 2 | `kitty.conf`, `themes/noctalia.conf` (generated) — program absent, prune |
| labwc | 0 | 2 | `rc.xml`, `noctalia.conf` (generated) — program absent, prune |
| lazygit | 0 | 2 | `config.yml`, `themes/noctalia.yml` (generated) |
| lazyvim | ALL | 0 | — (whole dir symlink → `dotfiles/lazyvim`) |
| mango | 0 | 2 | `config.conf`, `noctalia.conf` (generated) — program absent, prune |
| micro | 0 | 154 | adopt: `settings.json`, `bindings.json`; generated: `colorschemes/noctalia.micro`; bundled/state: `syntax/*` (146), `buffers/history` |
| mpv | 0 | 0 | — (empty) |
| nautilus | 0 | 0 | — (empty) |
| niri | 8 | 2 | 8 live KDL includes (5 from `dotfiles/niri`, 3 overridden by the `zz-blankpad` tier, whose own 7 niri files are counted in that tier — the stale `layout.kdl` duplicate and generated `noctalia.kdl` were removed by #43) + see *Never track* for the relic. Box-only: `noctalia.kdl` (generated include, refused by `deploy.sh`) and the retired copy |
| noctalia | 3 | 2672 | Tracked and live-linked: `config.toml`, `palettes/Gruber Red.json`, `state/noctalia/settings.toml`. Everything else is app state plus noctalia's downloaded catalog: `community-palettes/` 66, `community-templates/` 80, `wallhaven/thumbs`, `clipboard/`, `plugins/`, `state.toml`, `usage_counts.json`. The app's wallpaper library is `~/.local/share/noctalia/wallpapers` — real files it owns; the repo's duplicate copy of 23 JPEGs was untracked by #41 and deleted from this checkout by the merge, which is what untracking does in every checkout |
| nvim | 0 | 0 | — (empty) |
| pywalfox | 0 | 1 | `config.json` — program absent, prune |
| qt5ct | 0 | 1 | `colors/noctalia.conf` (generated) — program absent, prune |
| qt6ct | 0 | 1 | `colors/noctalia.conf` (generated) — program absent, prune |
| rofi | 0 | 1 | `noctalia.rasi` (generated) — program absent, prune |
| scroll | 0 | 2 | `config`, `noctalia` (generated) — program absent, prune |
| secrets | 0 | 4 | `gitea`, `hf_token`, `hf_token_write`, `kagi_api_key` (names only — secret tier) |
| shelly | 0 | 2 | `config.json`, `settings.json` |
| sway | 0 | 2 | `config`, `noctalia` (generated) — program absent, prune |
| systemd | 9 | 3 | `user/fib-watchdog.service`, `user/fib-watchdog.timer`, `user/timers.target.wants/fib-watchdog.timer` |
| tmux | 0 | 1 | `themes/noctalia.conf` (generated) |
| turborepo | 0 | 1 | `telemetry.json` — `turbo` absent, prune |
| umbriel | 0 | 2 | `config.toml`, `noctalia.toml` (generated) — program absent, prune |
| unity3d | 0 | 235 | RimWorld state: `Saves` (201), `Config` (21), other (13) — never track |
| wezterm | 0 | 1 | `colors/Noctalia.toml` (generated) — program absent, prune |
| yay | 0 | 0 | — (empty) |
| ytm-player | 0 | 1 | `theme.toml` — program absent, prune |
> 38 entries (30 directories + 8 standalone files) under `~/.config`. Files like `mimeapps.list`, `user-dirs.dirs`, `background`, etc. are XDG desktop integration files, not program configs.
### Top-level `~/.config` files (not directories)
### Programs (directories with config files)
All regular files, none symlinked into `dotfiles`:
`starship.toml` (prune — `starship` absent), `frank-job-hunt.env` (secret tier),
`kdeglobals`, `partitionmanagerrc`, `pavucontrol.ini`, `cachyos-hello.json`,
`user-dirs.dirs`, `user-dirs.locale` (XDG/desktop state).
| Path | What it is | Installed? | Size | Files | Age | Prune? |
|---|---|---|---|---|---|---|
| `.config/alacritty` | Alacritty terminal emulator | yes | 4.0K | 1 | 2026-07-24 | — |
| `.config/btop` | btop system monitor | yes | 12K | 1 | 2026-08-11 | — |
| `.config/cachyos` | CachyOS distro config | system | 8.0K | 3 | 2026-07-03 | — |
| `.config/chromium` | Chromium browser | yes | 158M | 423 | 2026-07-15 | — |
| `.config/chromium-headless` | Chromium headless | **no** | 2.6M | 165 | 2026-07-20 | **prune-candidate** |
| `.config/dconf` | Dconf database | yes | 4.0K | 1 | 2026-07-03 | — |
| `.config/fish` | Fish shell config | yes | 24K | 3 | 2026-07-06 | — |
| `.config/gh` | GitHub CLI config | yes | 8.0K | 2 | 2026-07-12 | — |
| `.config/ghostty` | Ghostty terminal emulator | yes | 24K | 3 | 2026-08-24 | — |
| `.config/glow` | Glow markdown renderer | yes | 4.0K | 1 | 2026-07-03 | — |
| `.config/google-chrome-for-testing` | Google Chrome (testing) | **no** | 4.0K | 1 | 2026-07-03 | **prune-candidate** |
| `.config/gtk-3.0` | GTK 3.0 theme settings | system | 12K | 3 | 2026-07-03 | — |
| `.config/gtk-4.0` | GTK 4.0 theme settings | system | 12K | 3 | 2026-07-03 | — |
| `.config/micro` | Micro text editor | yes | 696K | 154 | 2026-08-17 | — |
| `.config/mozilla` | Firefox preferences | yes | 334M | 2,918 | 2026-08-24 | — |
| `.config/nautilus` | Nautilus file manager | yes | 4.0K | 0 | 2026-07-03 | — |
| `.config/niri` | Niri Wayland WM | yes | 44K | 9 | 2026-07-15 | — |
| `.config/noctalia` | Noctalia game config | yes | 212K | 28 | 2026-07-05 | — |
| `.config/nvim` | Neovim config | yes | 64K | 13 | 2026-07-23 | — |
| `.config/obsidian` | Obsidian note-taking app | yes | 79M | 139 | 2026-08-24 | — |
| `.config/pulse` | PulseAudio config | system | 4.0K | 1 | 2026-07-03 | — |
| `.config/qBittorrent` | qBittorrent torrent client | yes | 24K | 5 | 2026-08-05 | — |
| `.config/qmd` | QMD (bun-based CLI) | yes | 4.0K | 1 | 2026-07-03 | — |
| `.config/rpiv-web-tools` | RPi web tools config | yes | 4.0K | 1 | 2026-07-03 | — |
| `.config/shelly` | Shelly home automation | yes | 8.0K | 2 | 2026-07-03 | — |
| `.config/unity3d` | Unity game engine config | yes | 188K | 3 | 2026-07-05 | — |
| `.config/vlc` | VLC media player | yes | 96K | 2 | 2026-07-03 | — |
## Adoption candidates (program passes `command -v`)
### Desktop / XDG Integration (no owning program)
Pre-vetted box-only files worth moving into `dotfiles`. Authorship is from the
file contents, read directly.
| Path | What it is | Installed? | Size | Age |
|---|---|---|---|---|
| `.config/autostart` | XDG autostart entries | system | 0 | 2026-07-03 |
| `.config/background` | Desktop background setting | system | 0 | 2026-07-03 |
| `.config/cachyos-hello.json` | CachyOS welcome JSON | system | 0 | 2026-07-03 |
| `.config/menus` | XDG menu spec | system | 0 | 2026-07-03 |
| `.config/mimeapps.list` | MIME handler associations | system | 0 | 2026-07-03 |
| `.config/paradox-launcher-v2` | Paradox Launcher v2 config | **no** | 0 | 2026-08-05 | **prune-candidate** |
| `.config/pavucontrol.ini` | PulseAudio Volume Control config | system | 0 | 2026-07-03 |
| `.config/spectaclerc` | Spectacle screenshot tool config | system | 0 | 2026-07-03 |
| `.config/systemd` | User systemd config | system | 16K | 2026-07-06 | — |
| `.config/user-dirs.dirs` | XDG user directory dirs | system | 0 | 2026-07-03 |
| `.config/user-dirs.locale` | XDG locale | system | 0 | 2026-07-03 |
---
## 3. ~/.local/share — Non-Cache State & Config
> 26 entries. Excluded pure caches; included game data dirs, launcher configs, and application state.
| Path | What it is | Installed? | Size | Age | Prune? |
|---|---|---|---|---|---|
| `.local/share/Colossal Order` | Cities: Skylines 2 game data | via steam | 27M | 2026-08-26 | — |
| `.local/share/Larian Studios` | Baldur's Gate 3 game data | via steam | 1.5G | 2026-08-25 | — |
| `.local/share/Paradox Interactive` | Paradox game saves/config | via steam/flatpak | 34M | 2026-08-26 | — |
| `.local/share/Shelly` | Shelly home automation data | yes | 4.0K | 2026-07-03 | — |
| `.local/share/Steam` | Steam library + runtime | yes | 550G | 2026-08-28 | — |
| `.local/share/Trash` | Desktop trash | system | 424M | 2026-08-27 | — |
| `.local/share/Tropico6` | Tropico 6 game data | via steam | 3.1M | 2026-08-26 | — |
| `.local/share/applications` | Desktop entry files | system | 56K | 2026-07-03 | — |
| `.local/share/com.pais.handy` | Handy app (likely flatpak) | unknown | 140K | 2026-08-25 | — |
| `.local/share/desktop-directories` | XDG desktop dirs | system | 0 | 2026-07-03 | — |
| `.local/share/fish` | Fish shell data | yes | 4.0K | 2026-07-03 | — |
| `.local/share/flatpak` | Flatpak runtime/pkgs | yes | 8.0K | 2026-07-03 | — |
| `.local/share/gvfs-metadata` | GVFS metadata | system | 156K | 2026-08-25 | — |
| `.local/share/icons` | XDG icon theme data | system | 1.1M | 2026-07-03 | — |
| `.local/share/inxi` | Inxi system info data | yes | 0 | 2026-07-03 | — |
| `.local/share/keyrings` | System keyring data | system | 8.0K | 2026-07-03 | — |
| `.local/share/nautilus` | Nautilus state | yes | 164K | 2026-07-03 | — |
| `.local/share/noctalia` | Noctalia game state | yes | 48K | 2026-08-25 | — |
| `.local/share/nvim` | Neovim local data | yes | 186M | 2026-08-25 | — |
| `.local/share/qBittorrent` | qBittorrent download state | yes | 8.5M | 2026-08-05 | — |
| `.local/share/sddm` | SDDM display manager state | system | 4.0K | 2026-07-03 | — |
| `.local/share/shelly-icons` | Shelly icon assets | yes | 67M | 2026-07-03 | — |
| `.local/share/uv` | UV (Python) package data | yes | 59M | 2026-07-03 | — |
| `.local/share/vlc` | VLC cache/state | yes | 4.0K | 2026-07-03 | — |
| `.local/share/vulkan` | Vulkan shader cache | system | 16K | 2026-07-03 | — |
---
## 4. Special Directories
### ~/.pi — Legacy PI Agent (git repo)
> 31 entries. **2.5G total.** Remote: `dankclaw:3000/dank/agent.git`. Last commit: `eda2edb docs: update frank-default-large model to Qwen3.8-Flash-Next Uncensored ROCmFP4 STRIX_LEAN`.
**Top-level contents:**
| Path | What it is | Size | Prune? |
|---|---|---|---|
| `agent/` | PI agent core — git-tracked code, sessions, node_modules | 2.2G | — |
| `browser-profile/` | Chromium browser profile (artifacts) | 200M | **prune-candidate** |
| `trash/` | Agent session trash | 1.8M | **prune-candidate** |
| `docs/` | Documentation | 684K | — |
| `npm/` | NPM global packages | 368K | — |
| `knowledge/` | Knowledge base | 120K | — |
| `skills/` | Agent skills | 112K | — |
| `scripts/` | Agent scripts | 92K | — |
| `extensions/` | Agent extensions | 60K | — |
| `system-agent-prompts-guide.md` | Agent prompt guide | 56K | — |
| `research/` | Research artifacts | 44K | **prune-candidate** |
| `okf-*.md` | OKF project documents (6 files) | ~96K total | — |
| `web-search.json` | Web search config | 4.0K | — |
| `trust.json` | Trust registry | 4.0K | — |
| `skills-lock.json` | Skills lock file | 4.0K | — |
| `settings.json` / `settings.json.save` | Agent settings | 8.0K total | — |
| `peon` | Peon integration | 4.0K | — |
| `oldAGENTS.md` | Legacy AGENTS.md | 4.0K | — |
| `keybindings.json` | Key bindings | 4.0K | — |
| `PROMPTS` | Prompt templates | 4.0K | — |
| `AGENTS.md` | Agent config | 4.0K | — |
| `web-search-cache` | Search cache (empty) | 0 | **prune-candidate** |
**Size offenders inside `agent/` (≥10M):**
| File | Size |
|---|---|
| `agent/npm/node_modules/onnxruntime-node/bin/.../libonnxruntime_providers_cuda.so` | 302M |
| `agent/npm/node_modules/@lancedb/lancedb-linux-x64-gnu/lancedb.linux-x64-gnu.node` | 137M |
| `.git/objects/pack/pack-ff7cc5418a978a5839d587472bec385f4c9ce72d.pack` | 81M |
| `agent/npm/node_modules/onnxruntime-node/bin/napi-v6/...` (multi-platform binaries) | 56M |
| `agent/npm/node_modules/webgpu/dist/...` (multi-platform .node) | 55M |
| `agent/npm/node_modules/pdf-oxide-wasm/...` (Wasm bundles) | 51M |
| `agent/npm/node_modules/onnxruntime-web/dist/` (Wasm runtime) | 43M |
| `agent/sessions/--home-dank-Projects-*/...` (session JSONL files) | 80M+ |
| `agent/npm/node_modules/playwright-core/lib/` (browser bundle) | 6.1M |
**Prune candidates flagged by task scope:**
- `research/` (44K) — research artifacts, likely stale
- `browser-profile/` (200M) — browser profile, not needed for agent
- `trash/` (1.8M) — agent session trash, purgeable
### ~/.omp — Command Center
> **411M.** Git repo, 7,726,074 commits. Remote: `192.168.0.143:3000/dank/command-center.git`. Last commit: `7726074 chore: birth — state-out ignore policy + secret-gate pre-commit hook`. Active configuration center for the OMP harness — **do not prune.**
---
## Summary
| Metric | Value |
|---|---|
| **Total config items** | 133 (38 top-level + 38 ~/.config + 26 ~/.local/share + 27 ~/.pi top-level entries + 1 ~/.omp) |
| **Orphaned configs** (owning program uninstalled) | **7** (.claude, .nv, .nvm, .paradoxlauncher at top-level; chromium-headless, google-chrome-for-testing, paradox-launcher-v2 in ~/.config) |
| **Total storage for configs** | ~567M (dotfiles) + 574M (~/.config) + 765M (~/.local/share excl. Steam) + 2.5G (~/.pi) = **~4.4G** |
| **Steam library** | 550G (not counted in config total — it's game data) |
### Top 10 Largest Offenders by du -sh
| Rank | Path | Size | Notes |
|---|---|---|---|
| 1 | `~/.local/share/Steam/` | 550G | Game library (not config) |
| 2 | `~/.cache/` | 6.4G | System cache |
| 3 | `~/.pi/` | 2.5G | Legacy agent (git repo) |
| 4 | `~/.bun/` | 2.3G | Bun runtime |
| 5 | `~/.npm/` | 1.7G | Node modules cache |
| 6 | `~/.local/share/Larian Studios/` | 1.5G | Baldur's Gate 3 (via Steam) |
| 7 | `~/.omp/` | 411M | Command center (keep) |
| 8 | `~/.local/share/Trash/` | 424M | Desktop trash |
| 9 | `~/.config/mozilla/` | 334M | Firefox profile |
| 10 | `~/.local/share/nvim/` | 186M | Neovim data/cache |
### Prune Summary
| Category | Count | Entries |
| File | Program (path) | Authored or default |
|---|---|---|
| Full program uninstalled | 7 | .claude, .nv, .nvm, .paradoxlauncher, chromium-headless, google-chrome-for-testing, paradox-launcher-v2 |
| Stale session/trash data | 3 | .pi/trash, .pi/browser-profile, .pi/research |
| **Total recommended for review** | **10** | — |
| `~/.gitconfig` | git (`/usr/bin/git`) | Authored — single setting line: `credential.helper = store --file ~/.config/secrets/gitea` (points at the secret tier; adopt the line, never the file it names) |
| `~/.config/bat/config` | bat (`/usr/bin/bat`) | Authored — one line `--theme=noctalia` |
| `~/.config/lazygit/config.yml` | lazygit (`/usr/bin/lazygit`) | Authored — 28-line custom noctalia color theme |
| `~/.config/micro/settings.json` | micro (`/usr/bin/micro`) | Authored — `{"colorscheme":"noctalia"}` |
| `~/.config/micro/bindings.json` | micro (`/usr/bin/micro`) | Default — empty `{}`, no custom bindings |
| `~/.config/flameshot/flameshot.ini` | flameshot (`/usr/bin/flameshot`) | Authored — `contrastOpacity=188`, `startupLaunch=true` |
| `~/.config/glow/glow.yml` | glow (`/usr/bin/glow`) | Default — shipped template, all default values (noctalia look comes from the generated `noctalia.json`, not this file) |
| `~/.config/autostart/Flameshot.desktop` | flameshot (`/usr/bin/flameshot`) | Authored — `Exec=flameshot`, `X-GNOME-Autostart-enabled=true` |
Every `~/.config` box-only row above lists each file, so the session can extend
beyond these seven (e.g. `btop/btop.conf`, `shelly/*` are also installed-program
configs).
## Never track (box-only)
| Reason | Contents |
|---|---|
| **Generated palette expansion** | Every `themes/noctalia*`, `*/noctalia.*`, `*/colors/noctalia*` / `Noctalia*` (24 files outside app-profile dirs). Machine-written by matugen/noctalia, not hand-authored: the `stat -c '%y %n'` cluster `2026-10-14 19:05:40.330…40.813 -0700` writes 24 files in one second (`alacritty/themes/noctalia.toml` at `.330212397`, `btop/themes/noctalia.theme` at `.334212456`, `tmux/themes/noctalia.conf` at `.813219586`, …); later re-runs re-stamp singles — `bat/themes/noctalia.tmTheme` `2026-10-18 17:11:22`, `niri/noctalia.kdl` `2026-10-18 17:36:27`, `mango/noctalia.conf` `2026-10-17 18:23:37`. A same-second mass-write is generation, never an edit. |
| **App state / profiles** | `mozilla` (5471), `unity3d` RimWorld state (235), `nirimod` (116), `obsidian` (99), `FreeTube` (56), `google-chrome-for-testing` (5); plus per-program state inside tracked dirs: `dconf/user`, `fish/fish_variables`, `micro/syntax/*`+`micro/buffers/history`, `gtk-4.0/servers`, `cachyos/cachyos-hello.log`. State hides inside a tracked file too: `dconf/dump.txt` carried `last-folder-path`, a nautilus `window-state` section and `picture-uri`/`picture-uri-dark` — all `/home/dank` values, and the wallpaper URIs were live on blankpad, i.e. already deployed here — dropped by #44, see [tools/dotfiles-deploy](/tools/dotfiles-deploy.md). |
| **Skills-CLI installs** | `crush` (37), `devin` (37), `goose` (37), `kimchi/harness` (37) skill trees — provisioned by the skills CLI, owned by the store, not this repo. See [skills/store-wiring](/skills/store-wiring.md). |
| **Secret tier** | `~/.config/secrets/` (`gitea`, `hf_token`, `hf_token_write`, `kagi_api_key` — names only), top-level `frank-job-hunt.env`, `containers/systemd/postgres.env`. Never tracked, values never printed. |
| **Trash committed inside the tree** | `dotfiles/niri/.config/niri/cfg/.AGENTTRASH/display.kdl.superseded` — a retired output config committed 2026-10-09, five days before `.gitignore:54` gained `.AGENTTRASH/` (2026-10-14). Tracked-then-ignored: an ignore rule never reaches an already-tracked path, so the only repair is `git rm --cached`, done by #41. The rule does match at depth — `git check-ignore --no-index -v dotfiles/niri/.config/niri/cfg/.AGENTTRASH/x` resolves to `.gitignore:54` — so a fresh in-tree trash dir is ignored from birth and this does not recur on its own. |
## Owned-elsewhere (do not adopt here)
- `~/.config/containers/systemd/franksign-db.container` — header:
*"Installed by `infra/setup-dev.sh` into `~/.config/containers/systemd/`."*
Owned by the franksign repo; its quadlet, not this box's config.
- `~/.config/systemd/user/fib-watchdog.service` + `.timer` — box-only.
`ExecStart=/usr/bin/bun src/cli.ts worker` with
`WorkingDirectory=/home/blank/Projects/frank-intelligence-bench`.
`systemctl --user is-active fib-watchdog.timer` → **inactive**. This box holds
the only copy. Do **not** adopt it into `dotfiles`: `ExecStart` and
`WorkingDirectory` are the bench project's internals, so that repo owns the
unit — land it there and this row becomes "deployed by the bench repo". Until
then it is a reinstall risk recorded here, not a dotfiles gap.
- **Hygiene finding, fixed 2026-10-20:** the enable-symlink
`~/.config/systemd/user/default.target.wants/blankpad-desk-autoswitch.service`
pointed into `~/.omp/wt/de-redesign-20261009/…`, a worktree the heartbeat had
already reaped — so desk auto-switching was silently off while
`systemctl --user is-enabled` still answered `enabled`. Relinked to
`dotfiles/zz-blankpad/.config/systemd/user/blankpad-desk-autoswitch.service`
and `daemon-reload`ed; left `inactive` on purpose, because starting it mid-
session applies a desk profile and the box is in use. Law and diagnosis:
[dotfiles-deploy](/tools/dotfiles-deploy.md).
## Prune list (owning program absent from `command -v`)
All verified with `command -v <prog>` (git resolves to `/usr/bin/git`; a session
alias can mask it, so probe in a clean shell).
| Config | Program checked |
|---|---|
| `sway`, `hypr`, `labwc`, `mango`, `scroll`, `umbriel` | `sway` `hyprland` `labwc` `mango` `scroll` `umbriel` — all ABSENT |
| `wezterm`, `kitty`, `foot` | `wezterm` `kitty` `foot` — all ABSENT |
| `rofi`, `helix`, `cava`, `ytm-player`, `starship.toml` | `rofi` `hx` `cava` `ytm-player` `starship` — all ABSENT |
| `pywalfox`, `qt5ct`, `qt6ct`, `turborepo` | `pywalfox` `qt5ct` `qt6ct` `turbo` — all ABSENT |
The first three rows are the expected set (all 14 confirmed absent). **My check
disagrees with the expected set by extending it**: `pywalfox`, `qt5ct`, `qt6ct`,
`turborepo` are also installed-nowhere config dirs and belong in the prune list.
Note the orphan dirs `cava`, `rofi`, `helix`, `wezterm`, `qt5ct`, `qt6ct` contain
*only* a generated noctalia palette file — pruning them removes no authored
config. `crush`/`devin`/`goose`/`kimchi` also fail `command -v` but are the
skills-CLI tier above, not this session's prune targets.
## Links
- [skills/store-wiring](/skills/store-wiring.md) — owns the skills-CLI tier
claim (`crush`/`devin`/`goose`/`kimchi`).
- [KB index](/index.md) — knowledge-base entry point.
+26
View File
@@ -16,6 +16,32 @@ lesson, a few lines. Full incident narratives are recoverable from git
(`git log --follow omp-docs/log.md`); knowledge that outlives an entry lives
on its concept page, linked here. Never re-expand an entry in place.
## 2026-10-20 — the secret gate was reading the wrong files, and one credential had already escaped
Found while deciding what else belongs in `dotfiles/`. (1) The gate whitelisted
extensions (`*.md *.yml *.json *.toml *.sh *.ts *.js`), so a `.env`, `.container`,
`.ini` or extension-less dotfile was never read — exactly the shapes the dotfiles
tier is made of. It now scans the whole staged diff for token shapes and, in
non-prose files, for credential assignments, and it fails closed when `git diff`
itself errors. That last clause earned its place: the rewrite's first attempt
passed `-I` to `git diff`, which is not valid there, got an empty file list, and
reported success on every commit — that is what a blind gate looks like, and it
looks identical to a clean tree. (2) A live credential had already escaped into
tracked content: a Caddy basicauth bcrypt hash, its plaintext password, and the
Pi-hole internal password, in an auto-learned memory file. Redacted — but both
credentials need **rotation**, because redaction does not un-leak history that
every machine has already pulled. (3) `home/dank/.omp/dotfiles/**` and
`.pre-pull-backup/**` were tracked fiction invisible to `deploy.sh`, and 23
wallpaper JPEGs (80 MiB) rode along in every clone. Laws: a gate must fail closed,
since a silently-passing gate is worse than none — it buys the belief of safety;
auto-learned memory is written by a process that has read your terminal, so it is
a leak surface and gets gated like any config; `systemctl --user enable`
snapshots an absolute path, so enabling from a worktree means the seven-day reap
silently kills the unit while `is-enabled` goes on saying `enabled`
(`blankpad-desk-autoswitch.service` was dangling that way). Details:
[dotfiles-deploy](tools/dotfiles-deploy.md),
[home-config-census](inventory/home-config-census.md).
## 2026-10-20 — engineering skills configured against this KB
`setup-matt-pocock-skills` ran, and its output moved out of the skill's default
+255
View File
@@ -0,0 +1,255 @@
---
type: reference
title: Dotfiles deployment tier
description: How dotfiles/ packages become live configuration — stow-equivalent symlinks, host override packages, and the two laws about theme media and systemd enable symlinks.
tags:
- dotfiles
- deployment
- systemd
- noctalia
timestamp: 2026-10-20
verified: 2026-10-20
---
# Dotfiles deployment tier
`dotfiles/` holds home-relative trees and `scripts/deploy.sh` is a stow-equivalent: the
repo checkout **is** the deployment, and deploying replaces live files with symlinks into
the repo, so `git pull` + `deploy.sh` is the whole propagation story. One directory per
tool (`niri`, `ghostty`, `gtk-3.0`, `systemd-user`, …); a package named `zz-$(hostname)`
is stowed last and its files replace symlinks coming from base packages.
| Mode | Effect |
|---|---|
| `deploy.sh --check` | dry run — adopts identicals, reports what would be linked, lists divergents, writes nothing. Run this first, always |
| `deploy.sh` (sync) | adopt identicals, link new, quarantine divergents into `.quarantine/` and replace them |
| `deploy.sh --adopt` | like sync, but divergents are reported and skipped rather than quarantined |
Two package shapes are not symlinks. `dconf/dump.txt` is loaded with `dconf load /`, and
`systemd-user` units get `daemon-reload` plus `enable`; a unit is **started** only when
systemd reports it as nothing-yet (`""`/`disabled`), and never when its `ExecStart` binary
is absent here, so a unit belonging to another box stays inert instead of crash-looping.
Starting something already installed is what makes `deploy.sh` unsafe to run beside a live
session — see *Never start a unit a session may already be running*.
**The tracked unit is the file, not the directory.** A live directory can be a real
directory and still be fully tracked, because every file inside it is a symlink into the
repo (`niri` is 8 tracked / 2 box-only; `gtk-3.0` is 3/0). Any audit that classifies by
directory mislabels the tree — classify per file with `readlink -f`. The live census and
its classifier command are in [home-config-census](/inventory/home-config-census.md).
## Law: media the theme pipeline consumes is content, not config
Wallpapers live at `dotfiles/noctalia/.local/share/noctalia/wallpapers/` and are
gitignored. They were tracked until 2026-10-20 — 23 files, 80 MiB — which meant every
clone of a *configuration* repo carried a media library no diff can review, and the
payload only ever grows because deleting a tracked blob from the tree does not delete it
from history. The topology is not what one would guess: [Noctalia](noctalia-shell.md)
keeps its own library at `~/.local/share/noctalia/wallpapers/` as a **real directory of
real files**, so the tracked tree was a duplicate of it and never its source — measured
2026-10-20, when the merge of #41 deleted the repo copies and the desktop did not
flicker. Two consequences to know before untracking media: the copy that survives belongs
to the application, not to the repo; and untracking deletes the file in every other
checkout, `git rm --cached` on the branch notwithstanding.
Restore on a fresh box: the library lives in `~/.local/share/noctalia/wallpapers/`, a path
`deploy.sh` does not manage. Every filename is `wallhaven_<id>.<ext>` and the ID is the
whole address — `curl -L -o <name> "https://wallhaven.cc/w/<id>"` into that directory puts
the set back. Losing it is an acceptable outcome; 80 MB per clone forever is not.
## Law: never `systemctl --user enable` a unit from a worktree
`enable` writes `~/.config/systemd/user/default.target.wants/<unit>` as a symlink to the
unit path it was handed, and that path is absolute. The heartbeat reaps `wt/*` worktrees
older than seven days ([live-sync](live-sync.md)), so a unit enabled from a worktree turns
into a dangling symlink while `systemctl --user is-enabled` still answers `enabled` — the
unit is dead and the status lies about it. Found on 2026-10-20:
`blankpad-desk-autoswitch.service` pointed into the reaped `wt/de-redesign-20261009`, so
desk auto-switching had been silently off since the reap.
Check the link target before the unit file:
```bash
ls -l ~/.config/systemd/user/default.target.wants/
# every target must resolve; a dangling one is a reaped worktree, not a config bug
```
Fix is to relink into the deployment checkout (`~/.omp/dotfiles/…`), `systemctl --user
daemon-reload`, then confirm with `is-active` — `is-enabled` proves nothing.
## Law: a generated file must never be a required include
`~/.config/niri/config.kdl` ends with `include "noctalia.kdl"` — no `optional=true`.
That file is noctalia's palette output (`#ff8c00`, `#f43841`, one re-stamp
`2026-10-18 17:36:27`), so it is machine state and correctly untracked. But on a fresh
box `deploy.sh` links `config.kdl` first, and niri then refuses to start until noctalia
has run once and written the file — the compositor is bricked by the order of operations,
not by a bad config. Close it at the include, which survives someone skipping the theme
pipeline: mark generated includes `optional=true`, as the `display.kdl` and
`switch-events.kdl` lines in the same file already are.
## Desk choice is live state, never config
`~/.config/niri/cfg/display.kdl` is a relative symlink to whichever profile is active
(`desk-1-home.kdl`, `desk-2-portable.kdl`), written by `desk.sh`. Both profiles are
tracked in `dotfiles/zz-blankpad/`; the pointer is not, because which desk the box sits on
is not a fact about the repo. A fresh box therefore starts with no output configuration —
`config.kdl` includes `display.kdl` with `optional=true`, so niri comes up usable but with
default monitors until `desk.sh` is run once.
## Law: generated theme output is never deployed
noctalia rewrites palette output in place — `noctalia.*`, `themes/noctalia*`,
`colors/noctalia*`, `noctalia-*` (the classes the [census](/inventory/home-config-census.md)
proves by same-millisecond mtime). A tracked copy of one is a stale snapshot of a colour
scheme, and the failure is quiet: `dotfiles/zz-blankpad/niri/.config/niri/noctalia.kdl`
(commit `b3d3df4`, 2026-10-18) sat byte-identical to the live file for two days, harmless,
while `--check` cheerfully proposed `ADOPT … noctalia.kdl`. The moment the palette changed,
the next sync would have quarantined the live file and restored 2026-10-18's colours —
a tool silently reverting a user-visible theme choice.
`deploy.sh` now refuses the class (`SKIP … (generated theme output)`), `.gitignore` carries
the matching patterns so it cannot be re-added, and `tests/deploy.bats` pins both
directions: generated output is never adopted, and an ordinary identical config still is.
Untracked in #43, Refs #42.
## Law: a `zz` override older than the file it overrides is a time bomb
Stow order means `zz-$HOST` wins, and when both packages carry the same path, sync relinks
it *silently* — the script's own comment: "managed file changing owners between packages:
silent relink, nothing to quarantine". `dotfiles/zz-blankpad/niri/.config/niri/cfg/layout.kdl`
held focus ring `width 2` / `active-color "#e01b24"` from `7b7dbf5` (2026-10-17) while the
base package absorbed `b3d3df4` (2026-10-18): `width 4`, `"#ff8c00"`. Live was linked to
base, the newer intent, so `--check` printed `RELINK .config/niri/cfg/layout.kdl` on every
run and one unattended `deploy.sh` would have reverted the amber focus ring with no
quarantine and no warning. The duplicate is deleted in #43.
Rule: a `zz` file exists only to differ. When base absorbs the change, delete the `zz` copy
in the same commit.
How to read `--check`, measured on blankpad: it prints `RELINK` for **every** path the `zz`
tier carries, even when the live link already points at the override — `rules.kdl`,
`keybinds.kdl`, `misc.kdl`, `autostart.kdl` and `.zshrc` all did — because the walk
processes the base package first and reports the intermediate owner change. A `RELINK` line
therefore proves a pending change only when `readlink` shows the live target is *not*
already the `zz-$HOST` file. `actions=0` is the only honest summary.
## Law: never start a unit a session may already be running
`sync` used to end with `systemctl --user enable --now` for every unit in `systemd-user/`
whose `ExecStart` resolves. Measured on blankpad before one such run:
|unit|`UnitFileState`|`ActiveState`|
|---|---|---|
|`live-sync.service`|`linked`|`inactive`|
|`shelly-notifications.service`|`enabled`|`active`|
|`swayidle-lock.service`|`enabled`|`inactive`|
`swayidle-lock.service` was installed and **not running** — nothing started it, no swayidle
process existed (`pgrep -c swayidle` = 0), and `autostart.kdl` does not launch one. A
`deploy.sh` run would have armed it: monitors blanking and locking on the idle timer while
the operator was mid-sentence. `live-sync.service` is `linked`, driven by its timer;
`--now` forces a heartbeat commit at an arbitrary moment.
Two rules follow. Install state must be read from the **printed state**, not the exit
status — `is-enabled` exits 1 for `linked` exactly as for `disabled`, so exit status cannot
tell "installed, driven by a timer" apart from "never installed". And a unit is started only
in the latter case: a machine with a session on it has already decided what runs, while a
fresh box — where everything reports `disabled` — still gets its units up. Both directions
are pinned in `tests/deploy.bats` over a stub `systemctl`, because the real one on this box
is exactly what must not be invoked from a test. Refs #44.
## Law: expand systemd specifiers before calling a unit foreign
`ExecStart=%h/.omp/scripts/live-sync.sh` is valid systemd and meaningless to a shell. The
resolution check handed that literal string to `command -v`, which fails everywhere, so the
deployment concluded the unit belonged to another machine and skipped it — silently, through
the same code path that is supposed to keep a *foreign* unit inert. The unit it kept skipping
was this repo's own heartbeat, and `%h` is precisely where this checkout lives, so the false
verdict was the same on every box that deployed it. Measured on blankpad: the first real
`deploy.sh` run printed `SKIP live-sync.service (ExecStart '%h/.omp/scripts/live-sync.sh'
absent on this host)` while `/home/blank/.omp/scripts/live-sync.sh` sits right there.
Expand the specifiers a user unit can carry — `%h`, `%u`, `%U` — before resolving, and let an
unknown specifier fail rather than guess at it. The probe needs no privileges and writes
nothing:
```bash
bin=$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' \
~/.omp/dotfiles/systemd-user/.config/systemd/user/live-sync.service | head -1)
command -v "${bin//%h/$HOME}" # resolves → the unit is this box's; the SKIP was the lie
```
## Law: the dconf dump holds preferences, not per-box state
`dconf load /` writes every key in `dotfiles/dconf/dump.txt`, so the dump carries whatever
box it was taken from. Comparing key by key against live state on blankpad found **six of
24** keys that were not this box's, and not one of them was a preference:
[org/gnome/portal/filechooser/firefox] last-folder-path
repo: '/home/dank/Downloads' live: '/home/blank/Downloads'
[org/gnome/nautilus/window-state] initial-size, initial-size-file-chooser, maximized
[org/gnome/desktop/background] picture-uri, picture-uri-dark
both: 'file:///home/dank/.config/background'
A file-chooser memory, a window geometry, a wallpaper URI: application state that arrived
with the dotfiles import. The last five did not appear as conflicts at all, because
blankpad's **live** tree already held the same `/home/dank` values — an earlier deploy had
written them here. A foreign value that agrees with live is invisible to any diff and still
wrong. All six come out in #44; the two wallpaper URIs are also cleared on this box
(`gsettings set org.gnome.desktop.background picture-uri ''`, and its `-dark` twin), because
niri draws the wallpaper, noctalia owns it, and nothing reads a dead path.
Compare **key by key**, not with a plain `diff`: `dconf load` writes only the keys present,
so live-only keys survive and a diff overstates the damage in one direction and understates
it in the other. Re-dump on the box whose settings are canonical (`dconf dump / >
dotfiles/dconf/dump.txt`), then delete the state sections before committing — the census's
*Never track: app state* class applies inside a tracked file, not just to whole directories.
Refs #44.
## Law: a watcher must restart on a clean exit too
`swayidle-lock.service` was `enabled`, present in `default.target.wants/`, and **dead for eight
days**: `Result=success`, `NRestarts=0`, gone since 2026-10-12 16:22 after 2d 21h of uptime.
`Restart=on-failure` cannot see a clean exit, and `swayidle -w` exits 0 on SIGTERM. The trigger is
attributable to eight seconds: **`niri` and `noctalia` both started `Mon Oct 12 16:22:31 2026`**
(`ps -o lstart=`) while swayidle's last state change was `16:22:23` — the compositor session
restarted, the watcher went with it, and because it was wanted by `default.target` rather than tied
to the session target, nothing brought it back. The user journal keeps no entries for that window,
so the attribution rests on process ages rather than logs, which is its own lesson: diagnose a
lapse within the retention you actually have.
**The severity claim in this page's first draft was wrong, and is corrected here.** It said closing
the lid suspended the machine unlocked, because the `before-sleep` hook lived in the dead unit. But
`systemd-inhibit --list` shows `noctalia … sleep "Lock before sleep" delay` — the shell holds its
own sleep inhibitor and locks on suspend independently of swayidle, so the suspend path was covered
the whole time. What was actually dead for eight days is the **idle** lock: five minutes of
inactivity locked nothing, and `after-resume` never fired. `pgrep -c -u blankpad
'swayidle|swaylock|hyprlock|gtklock'` = 0 remains true; it simply did not mean *no lock path at
all*, because a locker can be a hook inside a running shell rather than a name on the process list.
Read the inhibitor table, not only `pgrep`.
Three settings, each with a job: `Restart=always`, because a supervisor must not assume it can
only die badly; `StartLimitIntervalSec=0`, because a fast loop must never wedge it into `failed` —
a lock that keeps trying beats one that stopped trying; and `PartOf=graphical-session.target`,
because the unit is wanted by `default.target`, so the session coming back did not bring *it*
back.
Verify by behaviour rather than by reading the file — the point is surviving the event that killed
it in October:
```bash
systemctl --user start swayidle-lock.service
kill -TERM "$(systemctl --user show swayidle-lock.service -p MainPID --value)"
sleep 8 && systemctl --user show swayidle-lock.service -p NRestarts -p MainPID -p ActiveState
```
An idle inhibitor also disarms the timeout outright, which surprises people: while an `omp` agent
session is running, swayidle logs `Not enabling timeouts: idle inhibitor found` and arms no idle
timeout at all — `systemd-inhibit --list` shows `omp … idle "omp agent session" block`. A box with
an agent working therefore does not lock on inactivity, by harness design, while `before-sleep` and
`after-resume` keep working. Anyone asking "why did the screen never lock" should read the inhibitor
table before suspecting the unit.
The general shape: `deploy.sh` only ever *enables*. Nothing on any box re-checks that a unit the
system depends on is still running, so a security posture can lapse silently and stay lapsed for a
week. A liveness check for this tier belongs in the heartbeat, not in a human's memory. Refs #47.
+2
View File
@@ -9,6 +9,7 @@ Configuration guides and reference material for installed software. Each tool ha
* [applications](applications.md) — Default userspace applications on CachyOS with Niri — browsers, editor, terminal, file manager, audio, screenshots, gaming.
* [cachyos](cachyos.md) — Arch-based rolling release with performance-optimized kernels, repositories, and gaming stack. Running on the workstation.
* [comfyui](comfyui/index.md) — Node-based AI image generation engine on dankclaw. FLUX.2 klein 4B, CPU backend, systemd managed.
* [dotfiles-deploy](dotfiles-deploy.md) — How `dotfiles/` packages become live config: `deploy.sh` modes, host override packages, and why theme media and worktree-enabled units are traps.
* [ghostty](ghostty/index.md) — GPU-accelerated terminal emulator with extensive configuration, keybindings, and shaders.
* [gitea](gitea/index.md) — Private Git hosting on dankclaw. Quadlet-managed containers: Gitea, PostgreSQL, Caddy.
* [handy](handy.md) — Offline speech-to-text transcription with WebKitGTK backend and Tauri v2 UI.
@@ -18,6 +19,7 @@ Configuration guides and reference material for installed software. Each tool ha
* [neovim](neovim.md) — Two editors in one binary: vanilla `nvim` for quick edits and the `lazyvim` NVIM_APPNAME profile, with the base16 wallpaper-palette hook and fresh-box install.
* [niri](niri/index.md) — Scrollable-tiling Wayland compositor — configuration, keybindings, and architecture for the workstation.
* [noctalia-shell](noctalia-shell.md) — Native desktop environment for Niri — launcher, control center, settings, lock screen, session menu, hotkey overlay, media controls, and system management.
* [packages](packages.md) — `packages/*.pkgs` desired state and the two tools that keep a machine and its manifest agreeing: `install.sh` installs the delta, `pkg-inventory.sh` reports what the machine has outgrown.
* [tmux](tmux.md) — Terminal multiplexer configuration, extended-keys setup, keyboard input best practices, and reload/restart procedures.
## Adding a New Tool
+106
View File
@@ -0,0 +1,106 @@
---
type: reference
title: package desired state
description: packages/*.pkgs plus the two tools that keep a machine and its manifest agreeing — install.sh installs desired-minus-installed, pkg-inventory.sh reports installed-minus-declared.
tags:
- packages
- pacman
- provisioning
- fleet
- blankpad
verified: 2026-10-20
timestamp: 2026-10-20
---
# Package desired state
Two tools, one invariant: **the manifests must describe the machine.** A rebuild is only faithful
if both directions are checked.
|tool|direction|writes|
|---|---|---|
|`scripts/install.sh`|desired → installed|installs `desired − installed` with `--apply`; never removes|
|`scripts/pkg-inventory.sh`|installed → declared|nothing — read-only report|
## Manifests
`packages/common.pkgs` (every host) + `packages/<host>.pkgs` (this host) + `packages/aur.pkgs`
(AUR-only, installed through `yay`). The parser is `sed 's/#.*//'` then the first field, so `#`
comments trail freely and one token per line is the whole grammar. An unknown `--host` gets the
common manifest only: a wrong-host run is a no-op, not a hazard.
## Rebuild a workstation
1. Install the OS (CachyOS, niri/noctalia profile). The installer's own package set is *not* in the
manifests and must not be — see the baseline law below.
2. Clone this repo to `~/.omp`, then `scripts/install.sh --apply` — packages first, because the
enablement step needs `sshd` and `firewalld` present ([dotfiles-deploy](dotfiles-deploy.md)).
3. `scripts/deploy.sh sync` for the config tier ([dotfiles-deploy](dotfiles-deploy.md)).
4. `scripts/pkg-inventory.sh --emit` on a *known-good* machine and fold the output into
`packages/<host>.pkgs` / `packages/aur.pkgs` before step 2 — that is how a hand install stops
being invisible to the next rebuild.
## pkg-inventory report
```
undeclared additions (declare these): N ← exit 1 when N > 0
repo (sync dbs): <names> → belongs in packages/<host>.pkgs
aur (needs a helper): <names> → belongs in packages/aur.pkgs
installer-provided, undeclared: N + names → a reinstall supplies these; do not declare
declared, not installed: <names> → install.sh installs these
orphans ignored: <names> → pacman -Qtdq
flatpak: <app ids> → reported only, install.sh installs no flatpaks
```
Exit codes: `0` clean · `1` drift · `2` usage · `3` classification impossible. Overrides
`PKGINV_REPO` / `PKGINV_HOST` / `PKGINV_LOG` / `PKGINV_BASELINE` exist for tests.
## Law: the installer's own day is the baseline
Whether a package is *yours* is decided by the pacman log, not by opinion: the baseline date is the
oldest `[ALPM] installed` entry — Calamares' pacstrap — and everything installed **on or before** it
is installer furniture. Without that cut, a base install reports several hundred "additions" and the
list nobody can act on is worse than no list.
- **Read the rotated logs too** (`pacman.log.N`, `.N.gz`). On a machine older than the rotation
window the install day exists only there; skip them and the whole base system is reported as
hand-installed.
- Calamares keeps installing the desktop for minutes after the base transaction, which is why the
date cut is inclusive.
## Law: refuse rather than guess the AUR split
`aur.pkgs` vs `<host>.pkgs` is decided by whether any sync database can serve the name — Chaotic-AUR
counts as a sync database, so only true AUR belongs in `aur.pkgs`. If the sync databases are
unavailable the tool exits `3` instead of assuming: guessing sends every package to `yay`, which is
how a manifest becomes a pile of source builds.
Classify with `pacman -Sl` (world-readable). **`pacman -Sqq` requires root** — a tool that only
reads the machine must not need sudo to answer a question about it.
**Known gap:** `aur.pkgs` is read for *every* host, so a desktop-only AUR package (a niri module, a
GUI client) cannot be declared there without asking the headless servers to build it. Such a name is
still reported as an AUR addition; leave it undeclared until `install.sh` grows a per-host AUR
manifest.
## Law: orphans never reach a manifest
`pacman -Qtdq` names are packages nothing depends on; emitting them would teach a fresh machine to
install the same dead weight. They are printed as `orphans ignored` and excluded from `--emit`.
## Two traps that produced this tool
- **`set -o pipefail` plus `head -1`**: `head` closes the pipe, `sort` dies of `SIGPIPE`, the
substitution returns `141`, and `set -e` aborts the script. It survives a small test fixture and
fails on a real 1000-line install-day log — take a minimum in one pass (`awk 'END'`-style), never
sort-then-first. The same applies to `awk 'NR==1{exit}'`, which closes the pipe exactly as `head`
does.
- **`tr '\n' ' '` swallows the final newline** and `fold` faithfully preserves that absence, so the
next report label lands on the same line as the last name. `tr '\n' ' '; echo` instead.
## Related
- [tools/index](index.md) · [dotfiles-deploy](dotfiles-deploy.md) (the config tier that follows) ·
[live-sync](live-sync.md) (the heartbeat that commits machine state)
- Tests: `tests/pkg-inventory.bats` (fixtures + stubbed `pacman`, no real package database touched).
- Refs #51.
+32
View File
@@ -3,3 +3,35 @@
# pkgbase amd-ucode (split pkgname amd-ucode). No module options needed.
amd-ucode
# Hand-installed additions, measured by scripts/pkg-inventory.sh on 2026-10-20 against the
# Calamares install day (baseline 2026-09-07). These stay out of common.pkgs: none of them belong
# on the headless servers. install.sh installs them only where they are missing, so this list
# changes nothing on the running box — it is what makes the next rebuild faithful.
bun
cachyos-gaming-applications
curlftpfs
flatpak
fuse2
fuseiso
ghostty-nautilus
glow
gnome-calculator
gnome-disk-utility
nodejs
npm
obsidian
partitionmanager
plezy
proton-mail-bin
python-yaml
quickshell
shelly-flatpak-backend
sshfs
tmux
ttf-proggyclean-nerd
ttf-terminus-nerd
wine-cachyos
# nirimod-git is an AUR addition deliberately left undeclared: aur.pkgs is read by every host, and
# a niri module must not be built on the servers. See the known gap in omp-docs/tools/packages.md.
+30 -2
View File
@@ -44,6 +44,14 @@ actions=0; quarantined=0; skipped=0
deploy_file() { # $1 = repo file path, $2 = home-relative target path
local src="$1" tgt="$HOME/$2"
# Generated theme output is never deployment state: noctalia rewrites these files in
# place, so a tracked copy is a stale snapshot and syncing one reverts a live palette to
# the colours of the day it was committed. Classes are those the census measures by
# mtime — see omp-docs/inventory/home-config-census.md, "Generated palette expansion".
case "$2" in
*/themes/noctalia*|*/colors/noctalia*|*/noctalia.*|*/noctalia-*|*/Noctalia*)
echo "SKIP $2 (generated theme output)"; skipped=$((skipped+1)); return 0 ;;
esac
if [ -L "$tgt" ]; then
if [ "$(realpath "$tgt")" = "$(realpath "$src")" ]; then return 0; fi # already deployed
case "$MODE" in
@@ -115,9 +123,29 @@ if [ "$MODE" != "check" ] && [ -d "$PKGS_DIR/systemd-user/.config/systemd/user"
for unit in "$PKGS_DIR"/systemd-user/.config/systemd/user/*.service; do
[ -e "$unit" ] || continue
bin="$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' "$unit" | head -1)"
# systemd expands specifiers inside ExecStart; this shell does not. Matching the literal
# `%h/…` makes a unit of THIS box look foreign and skips it in silence — which is exactly
# how live-sync.service, the repo's own heartbeat, has been reported "absent on this
# host" on every machine that deployed it. Expand what a user unit can carry; leave any
# other specifier unmatched rather than guess at it. Refs #44.
bin="${bin//%h/$HOME}"
bin="${bin//%u/$(id -un)}"
bin="${bin//%U/$(id -u)}"
if [ -n "$bin" ] && command -v "$bin" >/dev/null 2>&1; then
systemctl --user enable --now "$(basename "$unit")" || true
echo "ENABLE $(basename "$unit") (ExecStart resolves here)"
name="$(basename "$unit")"
# Read the state, not the exit status: `is-enabled` exits 1 for a `linked` unit exactly
# as for a `disabled` one. Starting something already installed is how a deploy locks a
# live session — swayidle-lock sits enabled/inactive on blankpad and `--now` arms it
# mid-conversation. Only a unit nothing has installed yet gets started. Refs #44.
state="$(systemctl --user is-enabled "$name" 2>/dev/null || true)"
case "$state" in
"" | disabled)
systemctl --user enable --now "$name" || true
echo "ENABLE $name (ExecStart resolves here; nothing running it yet)" ;;
*)
systemctl --user enable "$name" || true
echo "ENABLE $name (already $state; not started — a live session decides what runs)" ;;
esac
else
echo "SKIP $(basename "$unit") (ExecStart '$bin' absent on this host)"
fi
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# scripts/pkg-inventory.sh — what does this machine have that packages/*.pkgs does not declare?
#
# install.sh walks desired → installed. This walks installed → declared, and the pair is what
# makes a rebuild honest: a manifest the machine has quietly outgrown reproduces a machine that
# is missing the tools you installed by hand six weeks ago, and nothing notices until the new box
# has no tmux. Refs #51.
#
# The baseline is what separates "you installed this" from "the installer installed this". It is
# read from the pacman log — the install-day transactions, dated at the oldest `[ALPM] installed`
# line — because declaring Calamares furniture in a manifest is bookkeeping for something a
# reinstall provides for free. Rotated logs (pacman.log.N, .N.gz) are read too: on a machine older
# than the rotation window the install day lives only there, and skipping them would report the
# entire base system as hand-installed.
#
# Report (stdout):
# undeclared additions (declare these): N hand-installed, absent from the manifests
# repo (sync dbs): <names> → belongs in packages/<host>.pkgs
# aur (needs a helper): <names> → belongs in packages/aur.pkgs (yay)
# installer-provided, undeclared: N a reinstall supplies these; do not declare
# declared, not installed: N install.sh installs these
# orphans ignored: <names> pacman -Qtdq — never emitted
# flatpak: <app ids> reported only, install.sh installs no flatpaks
#
# --emit prints the additions as manifest lines, ready to fold into packages/. Names are bare
# tokens so install.sh's `sed 's/#.*//'` + first-field parser reads them unchanged.
#
# Exit status: 0 clean · 1 drift (undeclared additions exist) · 2 usage · 3 cannot classify
# (pacman log unreadable or sync databases unavailable — worse to guess than to say so, because
# guessing would dump every package into aur.pkgs).
#
# Environment overrides (tests):
# PKGINV_REPO repo root holding packages/ (default: git toplevel of this script)
# PKGINV_HOST host name (default: $(hostname))
# PKGINV_LOG space-separated pacman logs (default: /var/log/pacman.log + rotated)
# PKGINV_BASELINE YYYY-MM-DD, inclusive (default: oldest install date in the logs)
set -euo pipefail
export LC_ALL=C # comm/sort agreement: pacman's order is not this order
MODE="report"
while [[ $# -gt 0 ]]; do
case "$1" in
--emit) MODE="emit"; shift ;;
-h | --help) sed -n '2,40p' "$0"; exit 0 ;;
*) echo "usage: pkg-inventory.sh [--emit]" >&2; exit 2 ;;
esac
done
REPO="${PKGINV_REPO:-}"
if [[ -z "$REPO" ]]; then
REPO="$(git -C "$(dirname "$(realpath "$0")")" rev-parse --show-toplevel 2>/dev/null || true)"
fi
[[ -n "$REPO" && -d "$REPO/packages" ]] || { echo "pkg-inventory: no packages/ under ${REPO:-<repo>}" >&2; exit 2; }
HOST="${PKGINV_HOST:-$(hostname)}"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
read_manifest() { # same parser install.sh uses, so a name means the same thing to both
sed 's/#.*//' "$1" | tr -d '\r' | awk '{if ($1) print $1}'
}
# ---- installed state ---------------------------------------------------------
# Four queries are the tool's whole interface to pacman. -Sl is the unprivileged sync listing:
# -Sqq asks for root, and a read of the machine must not need sudo. Field 2 of -Sl is the package
# name, and one name can appear in several databases (core/extra/cachyos), hence the sort -u.
pacman -Qqe > "$TMP/explicit"
pacman -Qq > "$TMP/installed"
pacman -Qtdq > "$TMP/orphans" 2>/dev/null || true # exit 1 when the list is empty: no orphans
if ! pacman -Sl > "$TMP/sync.raw" 2>/dev/null || [[ ! -s "$TMP/sync.raw" ]]; then
echo "pkg-inventory: no sync database available (run: sudo pacman -Sy) — repo vs AUR cannot be classified" >&2
exit 3
fi
awk '{print $2}' "$TMP/sync.raw" | sort -u > "$TMP/sync"
# ---- declared desired state --------------------------------------------------
: > "$TMP/declared"
for m in common.pkgs "$HOST.pkgs" aur.pkgs; do
[[ -f "$REPO/packages/$m" ]] || continue
read_manifest "$REPO/packages/$m" >> "$TMP/declared"
done
# ---- baseline: what the installer put here -----------------------------------
logs=()
if [[ -n "${PKGINV_LOG:-}" ]]; then
read -ra logs <<< "$PKGINV_LOG"
else
logs=(/var/log/pacman.log)
for f in /var/log/pacman.log.[0-9]*; do [[ -e "$f" ]] && logs+=("$f"); done
fi
read_logs() {
local f
for f in "${logs[@]}"; do
[[ -r "$f" ]] || continue
case "$f" in *.gz) zcat "$f" ;; *) cat "$f" ;; esac
done
}
# "YYYY-MM-DD name" for every install event the log remembers.
read_logs | sed -nE 's/^\[([0-9-]{10})T[^]]*\] \[ALPM\] installed ([^ ]+) \(.*/\1 \2/p' > "$TMP/events"
[[ -s "$TMP/events" ]] || { echo "pkg-inventory: no install events readable in ${logs[*]} — baseline unknown" >&2; exit 3; }
# Minimum date in a single awk pass. Sorting and then taking the first line — whether with `head`
# or with an awk that exits early — closes the pipe, and pipefail turns sort's SIGPIPE into a dead
# script; a real install-day log is long enough for that to bite (it killed the first real run).
BASELINE="${PKGINV_BASELINE:-$(awk '{ if (m == "" || $1 < m) m = $1 } END { print m }' "$TMP/events")}"
[[ "$BASELINE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "pkg-inventory: bad PKGINV_BASELINE '$BASELINE' (want YYYY-MM-DD)" >&2; exit 2; }
# Inclusive: packages installed ON the baseline date came with the install too — Calamares keeps
# installing the desktop for minutes after the base transaction.
awk -v d="$BASELINE" '$1 <= d {print $2}' "$TMP/events" | sort -u > "$TMP/base"
for f in explicit installed sync declared base orphans; do sort -u -o "$TMP/$f" "$TMP/$f"; done
# ---- the sets ----------------------------------------------------------------
# additions: explicit, not declared, and not something the installer handed us.
comm -23 "$TMP/explicit" "$TMP/declared" > "$TMP/undeclared"
comm -23 "$TMP/undeclared" "$TMP/base" > "$TMP/additions"
comm -12 "$TMP/undeclared" "$TMP/base" > "$TMP/installside"
comm -12 "$TMP/additions" "$TMP/sync" > "$TMP/add.repo"
comm -23 "$TMP/additions" "$TMP/sync" > "$TMP/add.aur"
comm -23 "$TMP/declared" "$TMP/installed" > "$TMP/missing"
n() { wc -l < "$1" | tr -d ' '; }
if [[ "$MODE" == emit ]]; then
if [[ -s "$TMP/add.repo" ]]; then
printf '# packages/%s.pkgs — additions measured %s (pkg-inventory.sh, baseline %s)\n' \
"$HOST" "$(date +%F)" "$BASELINE"
cat "$TMP/add.repo"
fi
if [[ -s "$TMP/add.aur" ]]; then
printf '# packages/aur.pkgs — additions measured %s (AUR: not in any sync db, install via yay)\n' \
"$(date +%F)"
cat "$TMP/add.aur"
fi
exit 0
fi
printf 'pkg-inventory host=%s baseline=%s repo=%s\n' "$HOST" "$BASELINE" "$REPO"
printf 'counts declared=%s explicit=%s baseline=%s installed=%s\n' \
"$(n "$TMP/declared")" "$(n "$TMP/explicit")" "$(n "$TMP/base")" "$(n "$TMP/installed")"
printf 'undeclared additions (declare these): %s\n' "$(n "$TMP/additions")"
printf ' repo (sync dbs): %s\n' "$(tr '\n' ' ' < "$TMP/add.repo")"
printf ' aur (needs a helper): %s\n' "$(tr '\n' ' ' < "$TMP/add.aur")"
printf 'installer-provided, undeclared (a reinstall provides these): %s\n' "$(n "$TMP/installside")"
# The names, folded: this is the answer to "what did the installer mark explicit that I never
# declared", and it is long — a count alone would force a second run to see it.
# The echo matters: tr eats the final newline, fold preserves that absence, and the next label
# would otherwise be glued to the last folded line.
{ tr '\n' ' ' < "$TMP/installside"; echo; } | fold -s -w 88 | sed 's/^/ /'
printf 'declared, not installed (install.sh installs these): %s\n' "$(tr '\n' ' ' < "$TMP/missing")"
printf 'orphans ignored (pacman -Qtdq): %s\n' "$(tr '\n' ' ' < "$TMP/orphans")"
if command -v flatpak > /dev/null 2>&1 && flatpak list --app --columns=application > "$TMP/flatpak" 2>/dev/null; then
printf 'flatpak (reported only; install.sh installs no flatpaks): %s\n' "$(tr '\n' ' ' < "$TMP/flatpak")"
else
printf 'flatpak: not installed on this host\n'
fi
[[ -s "$TMP/additions" ]] && exit 1
exit 0
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bats
# tests/deploy.bats — black-box tests for scripts/deploy.sh (Refs #42)
#
# Contract (see script header): the repo checkout IS the deployment; sync adopts
# identicals, links new files, quarantines-and-replaces divergents, and stows the
# zz-$(hostname) package last. One thing it must NEVER do is deploy generated theme
# output: noctalia rewrites `noctalia.*` / `themes/noctalia*` in place, so a tracked
# copy is a stale snapshot, and a sync would revert a live palette to whatever colour
# scheme was current on the day it was committed.
#
# Nothing is observed except exit status, stdout, the file/symlink shape under $HOME,
# and the quarantine directory.
setup() {
TEST_TMP="$(mktemp -d)"
REPO="$TEST_TMP/repo"
DEPLOY="$BATS_TEST_DIRNAME/../scripts/deploy.sh"
mkdir -p "$REPO/scripts" "$REPO/dotfiles"
# A git repo, because the deployment checkout IS one and deploy.sh arms hooksPath.
git init -q -b main "$REPO"
cp "$DEPLOY" "$REPO/scripts/deploy.sh"
chmod +x "$REPO/scripts/deploy.sh"
export HOME="$TEST_TMP/home"
mkdir -p "$HOME"
export DEPLOY_REPO="$REPO" DEPLOY_HOST=box
# systemctl stub: records every call and answers is-enabled from a file. The real
# systemctl must never run here — on this box it would start a screen lock.
mkdir -p "$TEST_TMP/bin"
export STUB_LOG="$TEST_TMP/systemctl.log" UNIT_STATE="$TEST_TMP/unit-state"
: > "$STUB_LOG"
cat > "$TEST_TMP/bin/systemctl" <<'STUB'
#!/usr/bin/env bash
echo "systemctl $*" >> "$STUB_LOG"
if [ "$2" = is-enabled ]; then
state="$(cat "$UNIT_STATE" 2>/dev/null || echo disabled)"
printf '%s\n' "$state"
case "$state" in disabled | "") exit 1 ;; *) exit 0 ;; esac
fi
exit 0
STUB
chmod +x "$TEST_TMP/bin/systemctl"
export PATH="$TEST_TMP/bin:$PATH"
}
teardown() { rm -rf "$TEST_TMP"; }
# repo_file <pkg-relative path> <content>
repo_file() { mkdir -p "$(dirname "$REPO/dotfiles/$1")"; printf '%s\n' "$2" > "$REPO/dotfiles/$1"; }
# live_file <absolute path under $HOME> <content>
live_file() { mkdir -p "$(dirname "$1")"; printf '%s\n' "$2" > "$1"; }
# systemd_unit <name> <ExecStart> — a unit in the systemd-user package
systemd_unit() {
mkdir -p "$REPO/dotfiles/systemd-user/.config/systemd/user"
printf '[Unit]\nDescription=%s\n[Service]\nExecStart=%s\n[Install]\nWantedBy=default.target\n' "$1" "$2" \
> "$REPO/dotfiles/systemd-user/.config/systemd/user/$1"
}
@test "generated theme output is never adopted into a symlink" {
repo_file "ghostty/.config/ghostty/themes/noctalia" 'background = "#f43841"'
live_file "$HOME/.config/ghostty/themes/noctalia" 'background = "#f43841"'
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"SKIP"*".config/ghostty/themes/noctalia"* ]]
[ -f "$HOME/.config/ghostty/themes/noctalia" ]
[ ! -L "$HOME/.config/ghostty/themes/noctalia" ]
}
@test "a diverged live palette is never quarantined and reverted to a stale copy" {
# The dangerous shape: the tracked file matches today's palette, the theme changes
# tomorrow, and sync would quarantine the live file and restore the stale one.
repo_file "niri/.config/niri/noctalia.kdl" 'active-color "#e01b24"'
live_file "$HOME/.config/niri/noctalia.kdl" 'active-color "#ff8c00"'
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
grep -q 'active-color "#ff8c00"' "$HOME/.config/niri/noctalia.kdl"
[ ! -L "$HOME/.config/niri/noctalia.kdl" ]
[ ! -d "$REPO/.quarantine" ]
}
@test "an ordinary identical config is still adopted" {
# Control against an exclusion so broad the tool stops deploying anything.
repo_file "ghostty/.config/ghostty/config.ghostty" 'theme = "gruber"'
live_file "$HOME/.config/ghostty/config.ghostty" 'theme = "gruber"'
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
[ -L "$HOME/.config/ghostty/config.ghostty" ]
[ "$(realpath "$HOME/.config/ghostty/config.ghostty")" = "$(realpath "$REPO/dotfiles/ghostty/.config/ghostty/config.ghostty")" ]
}
@test "--check reports and writes nothing" {
repo_file "ghostty/.config/ghostty/config.ghostty" 'theme = "gruber"'
live_file "$HOME/.config/ghostty/config.ghostty" 'theme = "tokyo-night"'
run "$REPO/scripts/deploy.sh" --check
[ "$status" -eq 0 ]
[[ "$output" == *"DRIFT"* || "$output" == *"QUAR"* ]]
[ ! -L "$HOME/.config/ghostty/config.ghostty" ]
grep -q 'theme = "tokyo-night"' "$HOME/.config/ghostty/config.ghostty"
[ ! -d "$REPO/.quarantine" ]
}
@test "a unit already enabled is enabled but not started" {
systemd_unit lock.service /bin/sh
printf 'enabled\n' > "$UNIT_STATE"
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"not started"* ]]
grep -q 'systemctl --user enable lock.service' "$STUB_LOG"
! grep -q -- '--now' "$STUB_LOG"
}
@test "a linked unit is not started either" {
# `is-enabled` exits 1 for a linked unit, exactly as it does for a disabled one, so the
# printed state is the signal — exit status cannot tell them apart.
systemd_unit timer-driven.service /bin/sh
printf 'linked\n' > "$UNIT_STATE"
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
! grep -q -- '--now' "$STUB_LOG"
}
@test "a disabled unit is enabled and started, so a fresh box still comes up" {
systemd_unit fresh.service /bin/sh
printf 'disabled\n' > "$UNIT_STATE"
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
grep -q 'systemctl --user enable --now fresh.service' "$STUB_LOG"
}
@test "a unit whose ExecStart is absent here stays inert" {
systemd_unit otherbox.service /nonexistent/daemon
printf 'disabled\n' > "$UNIT_STATE"
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"SKIP"*otherbox.service* ]]
! grep -q enable "$STUB_LOG"
}
@test "a unit whose ExecStart uses a systemd specifier still resolves here" {
# `%h` is expanded by systemd, not by the shell, so matching the literal string makes a
# unit belonging to THIS box look foreign and skip it in silence. The victim is this repo's
# own heartbeat: `ExecStart=%h/.omp/scripts/live-sync.sh`, skipped on every box. Refs #44.
mkdir -p "$HOME/.local/bin"
printf '#!/bin/sh\ntrue\n' > "$HOME/.local/bin/helper"
chmod +x "$HOME/.local/bin/helper"
systemd_unit heartbeat.service '%h/.local/bin/helper'
printf 'linked\n' > "$UNIT_STATE"
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"heartbeat.service (already linked"* ]]
grep -q 'systemctl --user enable heartbeat.service' "$STUB_LOG"
! grep -q -- '--now' "$STUB_LOG"
}
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env bats
# tests/pkg-inventory.bats — black-box tests for scripts/pkg-inventory.sh (Refs #51)
#
# Contract: `install.sh` installs desired-minus-installed from packages/*.pkgs. This tool walks
# the other direction — installed-minus-declared — because a manifest the machine has quietly
# outgrown cannot reproduce the machine on a rebuild. Only stdout, stderr and the exit status are
# observed: 0 no drift, 1 drift (hand-installed packages undeclared), 2 usage, 3 sync databases
# unavailable so repo/AUR classification is impossible.
#
# Every pacman/flatpak call hits a stub: nothing here may query the real package database, and a
# real pacman on PATH would make the fixture answers depend on the workstation.
setup() {
TEST_TMP="$(mktemp -d)"
SCRIPT="$BATS_TEST_DIRNAME/../scripts/pkg-inventory.sh"
REPO="$TEST_TMP/repo"
mkdir -p "$REPO/packages" "$TEST_TMP/bin"
# --- manifests: common + host(box) + aur ------------------------------------
cat > "$REPO/packages/common.pkgs" <<'EOF'
# common desired state
ghostty
zoxide
vlc # declared, deliberately NOT installed below
EOF
cat > "$REPO/packages/box.pkgs" <<'EOF'
neovim
EOF
cat > "$REPO/packages/aur.pkgs" <<'EOF'
typora # AUR-only desired state
EOF
# --- pacman log: install day 2026-01-01, hand installs from 2026-03 ----------
cat > "$TEST_TMP/pacman.log" <<'EOF'
[2026-01-01T10:00:00-0800] [PACMAN] Running 'pacman --sysroot /tmp/calamares-root-x -S base'
[2026-01-01T10:00:01-0800] [ALPM] installed filesystem (2026.01.01-1)
[2026-01-01T10:00:02-0800] [ALPM] installed base (3-1)
[2026-01-01T10:00:03-0800] [ALPM] installed libfoo (1.0-1)
[2026-03-04T09:00:00-0800] [ALPM] installed bun (1.2-1)
[2026-03-04T09:30:00-0800] [ALPM] upgraded filesystem (2026.01.01-1 -> 2026.03.01-1)
[2026-03-05T09:00:00-0800] [ALPM] installed nirimod-git (r7-1)
[2026-03-06T09:00:00-0800] [ALPM] removed base (3-1)
EOF
# --- installed state --------------------------------------------------------
# ghostty/zoxide/neovim/typora declared. libfoo: explicit, arrived with the installer.
# bun/nirimod-git: installed by hand afterwards, undeclared. python-hatchling: orphan.
printf '%s\n' base bun filesystem ghostty libfoo neovim nirimod-git typora zoxide > "$TEST_TMP/local-all"
printf '%s\n' bun ghostty libfoo neovim nirimod-git typora zoxide > "$TEST_TMP/local-explicit"
printf '%s\n' python-hatchling > "$TEST_TMP/orphans"
# nirimod-git is deliberately ABSENT here: no sync db can serve it, which is the fact that
# routes it to aur.pkgs in the test below.
printf '%s\n' bun ghostty neovim typora zoxide libfoo vlc python-hatchling base filesystem > "$TEST_TMP/sync-all"
# Four flag combinations are the script's whole interface to the package manager; the stub
# answers those and refuses anything else, so an accidental new query fails loudly.
cat > "$TEST_TMP/bin/pacman" <<'STUB'
#!/usr/bin/env bash
case "$1" in
-Sl) [ "${STUB_SYNC_EMPTY:-0}" = 1 ] && exit 1
sed 's/^/syncdb /' "$SYNC_ALL" ;;
-Qqe) cat "$LOCAL_EXPLICIT" ;;
-Qtdq) cat "$ORPHANS"; [ -s "$ORPHANS" ] ;;
-Qq) cat "$LOCAL_ALL" ;;
*) echo "stub pacman: unhandled args: $*" >&2; exit 64 ;;
esac
STUB
cat > "$TEST_TMP/bin/flatpak" <<'STUB'
#!/usr/bin/env bash
[ "${STUB_NO_FLATPAK:-0}" = 1 ] && exit 127
printf '%s\n' com.discordapp.Discord
STUB
chmod +x "$TEST_TMP/bin/pacman" "$TEST_TMP/bin/flatpak"
export PATH="$TEST_TMP/bin:$PATH"
export PKGINV_REPO="$REPO" PKGINV_HOST=box PKGINV_LOG="$TEST_TMP/pacman.log"
export LOCAL_ALL="$TEST_TMP/local-all" LOCAL_EXPLICIT="$TEST_TMP/local-explicit"
export ORPHANS="$TEST_TMP/orphans" SYNC_ALL="$TEST_TMP/sync-all"
}
teardown() { rm -rf "$TEST_TMP"; }
# section RE_START RE_END — the report from RE_START's line up to (not including) RE_END, so an
# assertion proves a name landed in a specific section instead of merely appearing somewhere in the
# report. Start inclusive (the count and the names sit on the label line), terminator exclusive (a
# plain sed range would leak the next section's first line). Anchoring matters: an unanchored start
# runs the range to end-of-output, silently turning every absence assertion into a vacuous pass —
# exactly how four of these tests were passing before this helper was fixed.
section() { printf '%s\n' "$output" | awk -v s="$1" -v e="$2" '$0 ~ e {on=0} $0 ~ s {on=1} on'; }
ADDITIONS() { section "^undeclared additions" "^installer-provided"; }
@test "header names the host and takes the baseline from the oldest install line" {
run "$SCRIPT"
[ "$status" -eq 1 ] # the fixture has drift by design
[[ "$output" == *"pkg-inventory host=box"* ]]
[[ "$output" == *"baseline=2026-01-01"* ]]
}
@test "hand-installed packages are reported as additions" {
run "$SCRIPT"
[[ "$output" == *"undeclared additions (declare these): 2"* ]]
s="$(ADDITIONS)"
[[ "$s" == *bun* ]]
[[ "$s" == *nirimod-git* ]]
}
@test "a package the installer provided is not reported as an addition" {
run "$SCRIPT"
additions="$(ADDITIONS)"
[[ "$additions" != *libfoo* ]]
[[ "$(section "^installer-provided" "^declared,")" == *libfoo* ]]
# The folded block must end in a newline. Without one, the following label is glued onto the
# last folded line and every section after it vanishes from a sliced report.
[[ "$(section "^installer-provided" "^declared,")" != *declared,* ]]
}
@test "AUR-only packages are separated from sync-repo packages" {
run "$SCRIPT"
# nirimod-git is missing from the sync-DB fixture, so only a helper can install it: it must be
# routed to aur.pkgs, not to a pacman line that would fail on a fresh box. Bash `*` crosses
# newlines, so the absence check runs on one line rather than on the whole section.
repo_line="$(section '^ repo ' '^ aur ')"
[[ "$repo_line" == *"repo (sync dbs): bun"* ]]
[[ "$repo_line" != *nirimod-git* ]]
[[ "$(section '^ aur ' '^installer-provided')" == *"aur (needs a helper): nirimod-git"* ]]
}
@test "declared-but-missing packages are listed for install.sh" {
run "$SCRIPT"
[[ "$output" == *"declared, not installed"* ]]
[[ "$(section "^declared," "^orphans")" == *vlc* ]]
}
@test "orphans are reported as ignored and never emitted" {
run "$SCRIPT"
[[ "$output" == *orphans*python-hatchling* ]]
run "$SCRIPT" --emit
[[ "$output" != *python-hatchling* ]]
}
@test "--baseline moves packages installed on that date into the installer set" {
# Inclusive date semantics: pushing the baseline to bun's install date makes bun installer
# furniture, while nirimod-git (the next day) is still a hand install.
run env PKGINV_BASELINE=2026-03-04 "$SCRIPT"
[[ "$output" == *"baseline=2026-03-04"* ]]
s="$(ADDITIONS)"
[[ "$s" != *bun* ]]
[[ "$s" == *nirimod-git* ]]
}
@test "--emit prints lines that survive the manifest parser" {
run "$SCRIPT" --emit
[ "$status" -eq 0 ]
# install.sh reads manifests as `sed 's/#.*//'` then the first field, so an emitted name must
# be a bare token: a stray path or trailing note would be handed to pacman as a package name.
bad="$(printf '%s\n' "$output" | grep -v '^#' | grep -vE '^[A-Za-z0-9@._+-]+$' || true)"
[ -z "$bad" ]
printf '%s\n' "$output" > "$REPO/packages/emitted.pkgs"
names="$(sed 's/#.*//' "$REPO/packages/emitted.pkgs" | awk '{if ($1) print $1}' | sort | tr '\n' ' ')"
[[ "$names" == *bun* ]]
[[ "$names" == *nirimod-git* ]]
[[ "$names" != *ghostty* ]]
[[ "$names" != *libfoo* ]]
[[ "$names" != *vlc* ]]
}
@test "an unknown host gets the common manifest only, so host packages look undeclared" {
# Mirrors install.sh: wrong-host runs are a no-op, not a hazard. Here that means neovim,
# declared only in box.pkgs, counts as an addition when the host is not box.
run env PKGINV_HOST=otherbox "$SCRIPT"
[[ "$(ADDITIONS)" == *neovim* ]]
}
@test "no drift exits 0" {
printf '%s\n' ghostty libfoo zoxide > "$TEST_TMP/local-explicit"
run "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" == *"undeclared additions (declare these): 0"* ]]
}
@test "missing sync databases fail loudly rather than misclassifying everything as AUR" {
run env STUB_SYNC_EMPTY=1 "$SCRIPT"
[ "$status" -eq 3 ]
[[ "$output" == *"sync"* ]]
}
@test "flatpak apps are reported separately from pacman packages" {
run "$SCRIPT"
[[ "$output" == *"flatpak"*com.discordapp.Discord* ]]
}
@test "a box without flatpak still reports the pacman inventory" {
run env STUB_NO_FLATPAK=1 "$SCRIPT"
[ "$status" -eq 1 ]
[[ "$output" == *"flatpak"* ]]
[[ "$(ADDITIONS)" == *bun* ]]
}
@test "the baseline is found in a rotated log when the install day has aged out of the live one" {
# pacman rotates its log; without reading the rotated file the whole base system would be
# reported as hand-installed additions on any machine older than the rotation window.
mv "$TEST_TMP/pacman.log" "$TEST_TMP/pacman.log.1"
printf '[2026-10-01T09:00:00-0700] [ALPM] upgraded zoxide (0.9-1 -> 0.10-1)\n' > "$TEST_TMP/pacman.log"
run env PKGINV_LOG="$TEST_TMP/pacman.log $TEST_TMP/pacman.log.1" "$SCRIPT"
[[ "$output" == *"baseline=2026-01-01"* ]]
[[ "$(ADDITIONS)" != *libfoo* ]]
}
@test "a log larger than the pipe buffer still yields the baseline" {
# A real install day writes thousands of install lines. `sort | head -1` under pipefail dies of
# SIGPIPE once that output exceeds the pipe buffer, and no small fixture can show it — the first
# run against a real log is where it surfaced. seq, not yes: yes would SIGPIPE in the fixture.
seq 1 20000 | awk '{print "[2025-05-05T10:00:00-0700] [ALPM] installed pkgfiller (1-1)"}' > "$TEST_TMP/big.log"
cat "$TEST_TMP/big.log" "$TEST_TMP/pacman.log" > "$TEST_TMP/combined.log"
run env PKGINV_LOG="$TEST_TMP/combined.log" "$SCRIPT"
[ "$status" -eq 1 ]
[[ "$output" == *"baseline=2025-05-05"* ]]
}
@test "usage is a distinct exit status from drift" {
run "$SCRIPT" --nope
[ "$status" -eq 2 ]
}
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bats
# tests/secret-gate.bats — black-box tests for .githooks/pre-commit
#
# Contract: a commit whose staged content contains a credential is blocked
# (non-zero exit, stderr names the file); a commit whose content is benign
# configuration or prose about credentials is allowed. The gate is copied into a
# scratch repo so git itself invokes it — no sourcing, no internal state.
setup() {
HOOK="$BATS_TEST_DIRNAME/../.githooks/pre-commit"
REPO="$(mktemp -d)/g"
mkdir -p "$REPO/.git/hooks"
git init -q "$REPO"
git -C "$REPO" config user.email gate@test
git -C "$REPO" config user.name gate
cp "$HOOK" "$REPO/.git/hooks/pre-commit"
chmod +x "$REPO/.git/hooks/pre-commit"
}
teardown() { rm -rf "$(dirname "$REPO")"; }
# try_commit <file> <content> -> sets status/output from the real commit attempt
try_commit() {
local file="$1" content="$2"
mkdir -p "$REPO/$(dirname "$file")"
printf '%s\n' "$content" > "$REPO/$file"
run git -C "$REPO" add -A
run git -C "$REPO" commit -q -m "attempt: $file"
}
@test "blocks a low-entropy password in a .env file" {
try_commit "postgres.env" 'POSTGRES_PASSWORD=hunter2corp2026'
[ "$status" -ne 0 ]
[[ "$output" == *"SECRET GATE"* ]]
[[ "$output" == *"postgres.env"* ]]
}
@test "blocks a credential in a file type the extension whitelist used to skip" {
try_commit "app.conf" '[app]
secret_key = "rotatedvalue99"'
[ "$status" -ne 0 ]
[[ "$output" == *"app.conf"* ]]
}
@test "blocks a token shape in a plain text file" {
try_commit "notes.txt" "backup remote: ghp_$(printf 'a%.0s' {1..36})"
[ "$status" -ne 0 ]
[[ "$output" == *"notes.txt"* ]]
}
@test "blocks a bcrypt hash" {
try_commit "dump.txt" 'basicauth: $2b$14$'"$(printf 'a%.0s' {1..53})"
[ "$status" -ne 0 ]
[[ "$output" == *"dump.txt"* ]]
}
@test "blocks a credential in a quadlet unit" {
try_commit "db.container" '[Container]
Environment=POSTGRES_PASSWORD=devpostgres2026'
[ "$status" -ne 0 ]
[[ "$output" == *"db.container"* ]]
}
@test "prose about credentials in markdown is still allowed" {
try_commit "docs/runbook.md" 'The GITEA_TOKEN moved out of .zshrc into secrets.env.
Set password: user.set_password(value) and api_key = "sk-no-key-required".'
[ "$status" -eq 0 ]
}
@test "placeholder and indirected values in config files are allowed" {
try_commit "app.conf" '[db]
PASSWORD = CHANGEME
SECRET_KEY = ${VAULT_SECRET}
api_key = "sk-no-key-required"
token_file = <operator_token>'
[ "$status" -eq 0 ]
}
@test "environment variable indirection is not a secret" {
try_commit "start.sh" '#!/bin/sh
export GITEA_TOKEN="$GITEA_TOKEN"
export API_KEY_FILE=/run/secrets/api_key'
[ "$status" -eq 0 ]
}
@test "binary content never trips the gate" {
head -c 4096 /dev/urandom > "$REPO/wall.jpg"
run git -C "$REPO" add -A
run git -C "$REPO" commit -q -m "binary"
[ "$status" -eq 0 ]
}
@test "ALLOW_SECRETS still overrides, and says so" {
try_commit "postgres.env" 'POSTGRES_PASSWORD=hunter2corp2026'
[ "$status" -ne 0 ]
mkdir -p "$REPO/.git/hooks"
run env ALLOW_SECRETS=1 git -C "$REPO" commit -q -m "override"
[ "$status" -eq 0 ]
[[ "$output" == *"ALLOW_SECRETS set"* ]]
}
@test "this repo's ignore policy quarantines env files at any name" {
repo="$BATS_TEST_DIRNAME/.."
run git -C "$repo" check-ignore -q --no-index dotfiles/db/postgres.env
[ "$status" -eq 0 ]
run git -C "$repo" check-ignore -q --no-index dotfiles/app/frank-job-hunt.env
[ "$status" -eq 0 ]
run git -C "$repo" check-ignore -q --no-index dotfiles/bat/config
[ "$status" -ne 0 ]
}
@test "the gate refuses to pass when it cannot read the staged diff" {
# A gate that swallows a git error reports success on every commit. This is not
# hypothetical: the 2026-10-20 rewrite passed `-I` to git diff, which is not a
# valid flag there, and the empty file list made every secret commit sail through.
nodir="$(mktemp -d)"
run bash -c "cd '$nodir' && '$HOOK'"
rm -rf "$nodir"
[ "$status" -ne 0 ]
[[ "$output" == *"cannot read the staged diff"* ]]
}
@test "deleting a credential is never blocked" {
# The commit that removes a secret must always be possible, or the gate holds a
# leak hostage. It did exactly that on 2026-10-20: matching removed lines made
# the redaction commit look like the leak. Seed with the documented override,
# then remove with an ordinary commit.
printf 'POSTGRES_PASSWORD=hunter2corp2026\n' > "$REPO/db.conf"
run git -C "$REPO" add -A
run env ALLOW_SECRETS=1 git -C "$REPO" commit -q -m "seed a leak"
[ "$status" -eq 0 ]
printf '[db]\nPASSWORD = ${VAULT_SECRET}\n' > "$REPO/db.conf"
run git -C "$REPO" add -A
run git -C "$REPO" commit -q -m "redact the leaked password"
[ "$status" -eq 0 ]
}
@test "the gate can commit itself" {
# Fixtures have to look like credentials, so a gate that scans its own test file
# without exemption can never be committed — it holds its own maintenance
# hostage. The exemption is two exact paths, and it must announce itself.
mkdir -p "$REPO/.githooks" "$REPO/tests"
cp "$HOOK" "$REPO/.githooks/pre-commit"
printf 'POSTGRES_PASSWORD=hunter2corp2026\n' > "$REPO/tests/secret-gate.bats"
run git -C "$REPO" add -A
run git -C "$REPO" commit -q -m "gate and its fixtures"
[ "$status" -eq 0 ]
[[ "$output" == *"self-test fixtures exempt"* ]]
}
@test "a credential hidden in some other test file is still caught" {
# The exemption is a filename list, not the tests/ directory.
mkdir -p "$REPO/tests"
printf 'POSTGRES_PASSWORD=hunter2corp2026\n' > "$REPO/tests/other.bats"
run git -C "$REPO" add -A
run git -C "$REPO" commit -q -m "smuggle"
[ "$status" -ne 0 ]
[[ "$output" == *"tests/other.bats"* ]]
}