Commit Graph
270 Commits
Author SHA1 Message Date
Frank a280c29d2c chore(tier): close the ignore/deploy drift, and stop justifying git-lfs by dead config
`deploy_file` excludes `*/Noctalia*` (noctalia's capital-N palette naming) but
`.gitignore` had only the lowercase classes, so a generated palette file could become
tracked-but-never-deployed — the fiction class #41 removed, reintroduced by the
ignore list falling out of step with the deployer. The patterns are one contract in
two files; both halves are now stated and the comment says so.

`natives/` (2.4 MB of tree-sitter grammars the harness unpacks into the repo root on
first use, measured on blankpad) sat unnamed in the root, one `git add -A` away from
being committed as configuration. It is runtime state by every clause of AGENTS.md's
doctrine, so it is ignored. The wallpaper re-fetch note pointed at
`https://wallhaven.cc/w/<id>`, which answers text/html — it now names the API
resolution the deploy page carries.

`git-lfs` was installed because `.gitattributes` routes image types through the LFS
filter — for wallpapers this week's diff untracked: `git lfs ls-files` is zero, so
those four rules match nothing. The manifest now states what the tool is actually for
(cloning LFS-backed upstreams such as Hugging Face model/dataset repos) and the file
records its own inert state. Deleting `.gitattributes` is a pruning call for a session
with Frank, not a cleanup an agent runs alone.

Verified with `git check-ignore -v` for both new rules.

Refs #60
2026-10-21 01:06:47 -07:00
Frank 8393dc1090 fix(remote-job): a job name is data, never a shell pattern
`status` built its job list with `for n in $names`, which word-splits AND globs. The
second half is the bug: a job named `x*` was expanded against the caller's working
directory, so the report described unrelated files there as jobs and never mentioned
the one that ran — on a harness whose whole purpose is a report you can trust after
the session that started the job is gone. Splitting stays (several names in one
argument is the documented call shape); expansion goes.

`tail` was honest-by-accident in the wrong direction: with no jobs it handed a
literal `$JOBS/*.out` to tail, which reports a missing *file* — indistinguishable
from a broken tool — and a missing name produced tail's errno rather than "no job
called that". Both now say what is missing and exit 1.

Red first: the name test reads the report from a directory containing an unrelated
file, so the old glob is visible as a phantom job; both new tests fail against the
previous code and pass against this one.

Refs #60
2026-10-21 01:06:11 -07:00
Frank d0587a5bc5 docs(noctalia): the dependency table described packages this box does not have
Three rows claimed `wlr-randr`, `qt6-multimedia` and `brightnessctl` were
dependencies. `pacman -Q` on blankpad says none is installed, and the two features
they were cited for do not come from them at all: display information comes from
`niri msg outputs` (the niri page already said so), and brightness is driven through
the kernel — `/sys/class/backlight/amdgpu_bl1` exists with no helper installed — so
no package is doing that work.

A dependency table is a claim about a machine, and every row of it is now measured
against `pacman -Q` on blankpad (2026-10-21). Verified against the package database:
check `pacman -Q <name>` before adding a row.

Refs #60
2026-10-21 01:06:08 -07:00
Frank 334e5d0ee0 docs(niri): the ultrawide's refresh ceiling was a typo, not a limit
The page asserted the Sceptre M34's EDID tops out at 180 Hz. Measured on blankpad
after the merge, `niri msg outputs` lists 3440x1440@239.998 — the claim was false
in main, and it was load-bearing: it is the reason nobody questioned the profile
while the panel ran at 60 Hz for weeks. The real defect was one character: the
profile asked for 239.999, the panel offers 239.998, and niri matches mode strings
verbatim, falling back to *preferred* without failing.

Corrected to the measured mode list, and the deployed 180 is now documented as a
deliberate choice (the link is trained at x8 32.0 GT/s against a max of x16 and the
display engine throws EINVAL), so the next reader neither "restores 240" blind nor
re-pins 180 as a hardware ceiling. The kdl profile comment carries the same facts at
the point of edit, because that is where the next change to this mode gets typed.

Refs #60
2026-10-21 01:06:04 -07:00
Frank 66fc40de65 fix(kb-lint): a wikilink must not resolve into the quarantine
The resolver copied Obsidian's filename-anywhere semantics with a raw rglob, which
walked .AGENTTRASH/ as well. [[log]] therefore stayed "valid" by landing on the
quarantined core/log.md — a page that is out of circulation until a pruning session
and, being gitignored, does not exist on any other machine. The gate reported a
green bundle while pointing readers at a file git does not carry: exactly the #57
failure, hidden by the check that exists to catch it.

Resolution now uses the same filtered set linting uses (pages()), so the two can
never disagree about what exists. A companion test pins that filename-anywhere
resolution for live pages still works, so the narrowing costs nothing; mutating
the resolver back to rglob turns the new test red.

Refs #60
2026-10-21 00:59:51 -07:00
Frank 71601f72a8 fix(secret-gate): a documented fake id must not stop the machine tier
The live-sync heartbeat failed three hourly ticks (22:25, 23:27, 00:28) because a
managed skill that documents how to write a secret gate quotes the vendor's
all-zeros placeholder access-key id, and the shape rule fired on the
documentation. The machine tier is prose ABOUT credentials — the gate's own header
says a gate that fires there gets overridden until it means nothing — and a
heartbeat that cannot commit is a fleet that silently stops syncing config.

- shape hits are judged on the matched token, not the line: a documented fake is
  inert, one live token sharing the file still blocks. The match is judged with
  its following context, because a fixed quantifier truncates `EXAMPLE` to `EXA`
  and the placeholder would vanish from what is examined.
- assignment values keep the stricter rule from the previous commit: a comment on
  the same line cannot vouch for the token beside it.
- `ALLOW_SECRETS` remains the only override; nothing here widens what is scanned,
  it only stops flagging text that was never a credential.

Verified against the payload that is stuck right now: the skill page that the
heartbeat cannot commit passes the fixed hook and was refused by the live one.

Refs #60
2026-10-21 00:59:18 -07:00
Frank f3ce42eb80 fix(deploy): report what enable actually did, and arm the tier's timers
Two lies in the unit loop, both reported as success:

- `systemctl --user enable live-sync.service` fails on every deploy — the file has
  no [Install] section, because live-sync.timer is what arms it. The result was
  swallowed by `|| true` and the tool printed ENABLE anyway, so the line an
  operator reads after a rebuild was false on every box, on the repo's own
  heartbeat. The [Install] section is readable from the file being deployed, so
  the tool now checks it and prints INERT with the reason instead of making a
  call that cannot succeed; a real enable failure prints INERT too.
- The loop walked *.service only. live-sync.timer was never enabled by anything,
  so a rebuilt box got the heartbeat and no schedule for it. Timers are walked
  now; a timer has no ExecStart, so the "does this unit belong on this box" test
  runs against the service it activates.

The systemctl stub models systemd's refusal (no [Install] section -> non-zero),
which is what makes the new tests red against the old loop; both new tests were
run red first, and the five existing unit tests still pin #44's semantics.

Refs #60
2026-10-21 00:59:13 -07:00
Frank 812be6a0a6 fix(secret-gate): a path git quotes, a placeholder comment, a glued report line
Three ways the fail-closed gate failed open, all reproduced by new tests first:

- `git diff --name-only` quotes any path it cannot print as UTF-8
  (core.quotePath, on by default). The quoted string was handed back to
  `git diff -- <path>`, which matches nothing, and an empty diff is `continue`d —
  so a credential under `café/` committed cleanly. The list is now NUL-delimited
  and read from a temp file, because command substitution deletes NUL bytes and
  the names must survive to the loop that reads them.
- The placeholder filter matched the whole added line, so `API_TOKEN=<live>  #
  example, placeholder, changeme` was exempt on the strength of its own comment.
  The filter now reads the assigned value only — the token up to whitespace or
  `#`. Truncation errs toward blocking; ALLOW_SECRETS remains the way out.
- `report+=` ended on a command substitution, so the next file's name was glued
  onto the previous file's last hit line and the report blamed the wrong file.

Three tests, all red before the change: a quoted path, a placeholder comment
beside a live value, and two leaking files each named on their own line.

Refs #60
2026-10-21 00:55:47 -07:00
Frank 06f45e83c1 refactor(packages): one manifest parser, so a name means one thing to both tools
install.sh and pkg-inventory.sh carried byte-identical copies of read_manifest.
That is the duplicated-code half of the problem; the worse half is untested —
neither script had any test for the grammar that decides what gets installed.

- scripts/lib/manifest.sh is the parser; both scripts source it
- tests/manifest-parser.bats writes the contract first (comments, CRLF, first
  field only, unreadable file fails loudly instead of yielding "nothing desired")
- an unreadable manifest now fails inside the helper rather than depending on
  whichever `set` options the caller happens to run under
- install.sh check mode verified end to end against a stubbed pacman: a CRLF
  manifest with trailing comments resolves to bare names, installed names drop

Refs #60
2026-10-21 00:54:31 -07:00
Frank 40b9725433 fix(pkg-inventory): state the orphan law, test the boundary it rests on, stop --help mid-code
Three things from the review (#60), one of them a finding of my own that did not
survive measurement:

- The reported defect — additions never subtract `pacman -Qtdq`, so an orphan can
  be emitted as a manifest line — is wrong. A package carries one install reason:
  `-Qtdq` selects installed-as-dependency, additions come from `-Qqe`, so the sets
  are disjoint (blankpad: 206 explicit, 3 orphans, none in common). A subtraction
  would be dead code that looks like a guard, so the header now says why not to
  add one. The packages page law was rewritten to the same effect.
- What the old test should have pinned: an implicit dependency installed AFTER
  the baseline. Only `-Qqe` keeps it out of drift — mutate that query to `-Qq` and
  the new test goes red (verified), where the old assertion passed no matter what.
- `--help` printed `set -euo pipefail` and the LC_ALL line as documentation: the
  hardcoded sed range ran past the comment block. It now stops at the first line
  of code, so the header can keep growing.

Refs #60
2026-10-21 00:54:14 -07:00
Frank 671222b881 docs(60): point-of-use corrections from the 2026-10-20 review
Measured fixes, one line each, from the review of everything that landed on
2026-10-20 (issue #60). Each page keeps its own facts; nothing was moved.

- inventory/index, index: the census blurb says what the page actually covers
  (tracked-vs-box-only per file), not "all userspace configuration"
- dotfiles-deploy: the wallpaper re-fetch recipe resolves the CDN URL through
  the wallhaven API — /w/<id> answers text/html, so curl -L never yields a
  JPEG; description widened to the whole page, tools/index blurb matched
- okf-maintenance: the link example and the mistake bullet now agree with
  okf-spec, which RECOMMENDS absolute bundle paths (they said the opposite)
- decisions/index: the unlisted-page gate is kb-coverage.py, not kb-lint
- AGENTS.md: the three skill-config subsections are pointers again; the pages
  own issue tracker, labels and domain docs
- swayidle-lock.service: Description names what the unit does (arms the idle
  lock over noctalia-shell IPC); suspend belongs to noctalia's inhibitor
- common.pkgs: git-lfs re-justified by the tracked .gitattributes filter
  contract; the swayidle comment drops its lid-policy claim
2026-10-21 00:54:06 -07:00
Frank 71a7e5cb9a fix(pcie-probe): select a card by ordinal only, honour probe failures, refuse blind
Per-card measurement was unreachable for every card but ordinal 0: the driver
set HIP_VISIBLE_DEVICES=N and still passed N as the device argument, so the
probe called hipSetDevice(N) on a runtime where the mask left only ordinal 0.
The bats stub reported the env var rather than the argument it received, so
the suite asserted the mask was set and never noticed the collision.

- one selector: the ordinal is passed as argv, the mask is never set
- a probe that fails is now exit 5 and names probe + device; the sweep still
  measures the remaining cards instead of swallowing the failure
- the guard fails closed when `fuser` is missing (gpu_busy_percent reads 0 for
  a resident-but-idle service), with PCIE_PROBE_FUSER as the test seam
- stub probes now model the HIP mask rule; the pre-existing per-device test
  goes red against the old driver, which is the regression proof

Refs #60
2026-10-21 00:50:45 -07:00
dank 97b65067b1 Merge pull request 'fix(niri): ultrawide pinned to a 240 Hz mode the EDID never offered -> silent 60 Hz fallback' (#59) from fix/58-niri-edid-mode-fallback into main 2026-10-21 00:07:12 -07:00
Frank 23372e96f5 fix(niri): pin the ultrawide to the 180 Hz mode its EDID actually offers
desk-1-home.kdl asked the Sceptre M34 for 3440x1440@239.999, a mode the panel
has never offered. niri does not reject an unavailable mode: it logs "configured
mode ... could not be found, falling back to preferred" and drives the EDID
preferred mode - 60 Hz - on every connector event, which is why the monitor
looked like it was dying rather than misconfigured. Read live with
`niri msg outputs`, the mode list tops out at 180.

Also destroys the copies of the phantom number: displays.md carried 239.999 in
its diagram, its panel table and its copy-paste pattern block, and its rule 4
claimed an impossible mode leaves the output dark. Rule 4 now states the real
failure mode, a new section separates config / DP-link / amdgpu-driver faults by
log signature, and wlr-randr is corrected to not-installed (three pages claimed
it; `pacman -Qs wlr` matches nothing).

Applied live with `niri msg output <panel> mode <mode>` - the temporary
per-output path, so no config reload and no logged-out session - verified at
3440x1440 @ 180.000 Hz with the compositor PID unchanged.

Refs #58
2026-10-21 00:05:17 -07:00
dank ab94c91bcf Merge pull request 'One work log per bundle: the rule, the gate that enforces it, and the second log it came out of' (#57) from docs/single-work-log-rule into main 2026-10-20 22:23:21 -07:00
dank 5cf7e6bd8e Merge pull request 'APPEND_SYSTEM: report a PR as its link plus its title — and commit the doctrine that has been live but uncommitted since Oct 19' (#56) from docs/pr-reference-rule into main 2026-10-20 22:23:17 -07:00
dank 1cc4b76479 Merge pull request 'gen-keybinds: a C locale lost every section header — match the dash as a literal' (#55) from fix/gen-keybinds-locale-safe-sections into main 2026-10-20 22:21:29 -07:00
Frank 59920f849d docs(agent): re-add the work-log rule, restricted to one file
Rule 1 lost its work-log sentence in the point-of-use rewrite. Restoring it plainly would
re-create the old failure, where one agent kept logs in fifty-odd files and none of them
was the place to look. So the restored rule names the file: a single page in the
knowledge base — omp-docs/log.md here, whichever change-log page a repo's index names
elsewhere — read before starting, written in the same commit as the change it describes,
under that page's own entry law. Explicitly banned: per-directory logs, per-project or
per-run logs, NOTES.md/PROGRESS.md/STATUS.md/worklog/, dated diaries. If a KB has no log
page, the fix is one at its root plus an index entry, never a log somewhere else.

The bundle's own log records the change and the two-log incident it came out of.
2026-10-20 21:49:16 -07:00
Frank bfd6a6340e docs(okf): the work log is one file at the bundle root, and an entry is a lesson not a manifest
The spec listed `log.md` as a reserved name and the maintenance page said every bundle
"has a `log.md`" — neither said there is only one, so a subdirectory could invent its own
and look compliant. Which is how the sprawl Frank banned started: with fifty logs the
entry is never where the next reader looks, and every copy re-dates the same event.

The maintenance page also taught the wrong format: "one line per change with file path",
exampled as `/docs/ghostty.md — Updated keybind table`, i.e. a manifest duplicating git,
with absolute paths that kb-lint cannot resolve because OKF links are relative. Replaced
with the law this bundle already runs: what changed plus the durable lesson, a few lines,
relative links, laws and incidents rather than bookkeeping.
2026-10-20 21:49:16 -07:00
Frank 4849f46eb2 feat(kb-gates): one work log per bundle, and the quarantine is not bundle content
The bundle had two work logs. `core/log.md` was created "for tracking core directory
modifications" and holds two entries of pure migration bookkeeping (file moved,
directory created, log created) — every one recoverable from `git log` — plus a date
for the OKF conversion (2026-07-15) that matches neither the bundle log (2026-01-15)
nor git history, where the OKF rebuild lands 2026-08-28. `core/index.md` was still
linking it, which is exactly how a stale page stays in circulation. kb-lint now reports
any `log.md` that is not the bundle root's; the page moved to omp-docs/core/.AGENTTRASH/
per the trash convention, and the core index points one level up.

Both gates also walked `.AGENTTRASH/`, so trashing a page turned them red: kb-lint
demanded frontmatter from a quarantined page and kb-coverage demanded an index for the
trash directory. A gate that only goes green once the quarantined copy is deleted is a
gate that deletes history, so both skip any path containing `.AGENTTRASH` — pinned by a
test in each suite, and kb-coverage finally gets its own test file.
2026-10-20 21:49:15 -07:00
Frank dd91ef1d59 fix(gen-keybinds): match the section dash as a literal so headers survive LC_ALL=C
The section rule was `/^[[:space:]]*\/\/[[:space:]]*─+ (.+) ─+…$/`, a quantifier on a
multibyte character. Under LC_ALL=C — which is what cron, systemd user units and agent
shells get, regardless of LANG — gawk matches byte-wise, so `─+` means "the last byte of
U+2500, repeated" and never matches. Measured on the live keybinds.kdl: the old script
emitted 0 section headers instead of 12, all 120 binding rows intact, exit status 0. A
silent one-third-of-the-page loss, invisible to any caller that only checks the status.

The dash is now matched and stripped as a literal (`gsub(/─/, "")`), which is byte-safe
in every locale, and a comment must be dash-decorated on both sides to open a section, so
plain comments are still ignored. A test pins the C-locale case explicitly, because the
box's own terminal has always been UTF-8 and could never have caught this.

The committed artifact omp-docs/tools/niri/keybindings.md is unaffected (12 headers,
120 rows — it was generated from a UTF-8 shell); output is now identical either way.
2026-10-20 21:27:16 -07:00
Frank 289f74062a docs(agent): a pull request is reported as its link plus its title, never a bare number
"#54" made Frank go hunting: numbers are per-repository across the forges this
fleet uses, and a number carries no hint of which work is being reported. Every
mention of a PR now pairs its web URL with its title — gitea.franksdank.cloud for
the Dankclaw Gitea, the equivalent for any other forge.

Refs the AGENTS.md convention that an open PR gets opened in Frank's browser: the
rule covers prose, where the browser tab does not help.
2026-10-20 21:22:35 -07:00
Frank d7bd05398d docs(agent): commit the point-of-use documentation doctrine that has been live for a day
The live deployment has been running this rewrite of rule 1 since 2026-10-19, but
the file was never committed: agent/APPEND_SYSTEM.md is prose tier, outside the
live-sync allowlist, so the heartbeat surfaces the dirt instead of committing it —
which is correct behaviour, and it left the fleet's system prompt with no history
and no way for another machine to pull it. Committed verbatim as it runs, no
editorial changes: the purpose test, the dead-weight-number law, and the
deletion-is-an-update clause.

A config that only exists on one disk is not a deployment; it is a pending loss.
2026-10-20 21:20:04 -07:00
dank 004962ffce Merge pull request 'Rescue the detached job runner and the PCIe probe instruments from $HOME sediment' (#54) from feat/53-fleet-probes into main 2026-10-20 19:39:08 -07:00
Frank 56fc899763 Ship the PCIe probe instruments with a guard that refuses to contend with a service
The RDNA4 offload campaign left three HIP probes and a driver sitting loose in
$HOME, where the next agent cannot find them and a stray `rm -rf` ends them. They
answer a recurring question on the R9700 box — is decode limited by the link or by
the engine's read path — so they move into scripts/probes/ with a driver, tests,
and a page.

Two things changed in the code, not just the address:

- The driver measures only after a guard that runs BEFORE anything is built:
  gpu_busy_percent over PCIE_PROBE_MAX_BUSY, or any process holding /dev/kfd, is a
  refusal (exit 3) rather than a measurement. A benchmark taken beside a live
  endpoint is both disruptive and worthless, and silently unloading a production
  service is not a tool's decision — --force is the human saying otherwise. The
  refusal-before-build ordering is pinned by a test, because the sysfs and kfd
  paths are overridable and a refusal is otherwise untestable off the card box.
- hip_smoke.hip and pcie_probe.hip called hipGetDeviceProperties, which SEGFAULTS
  on this ROCm build even though hipGetDeviceCount works; only pcie_bw.hip avoided
  it, and that is the one that produced the campaign's numbers. Device identity now
  comes from the sysfs topology block the driver prints, so all three probes are
  runnable on the build we ship. Verified here: refusal on a real busy APU (exit 3),
  missing toolchain (exit 4), missing sources (exit 2). Compile-and-run still needs
  the box with hipcc.

Refs #53
2026-10-20 19:24:58 -07:00
Frank 35ceae6d7b Add remote-job.sh: a detached job whose result outlives the session
`ssh host 'cmd | tail -f log | sed -n /ready/q'` reported success on jobs that
never finished, three structural ways: the broker aborts the long connection, a
readiness pattern can match a log header printed before the work starts, and
`sed -n /x/q` exits the instant it matches — tearing down the very command it
was watching. The replacement runs the job detached on the host and writes
NAME.out, NAME.rc, then NAME.done LAST, so status is a cheap read of files over
a short-lived connection. That survives the connection, the restart, and the job
that restarts the endpoint the agent itself is running on.

Black-box tests pin the sentinel ordering (rc before done), that a finished-but-
failed job is DONE rc=N with the report still exiting 0, and that a name with no
state is never reported DONE.

Refs #53
2026-10-20 19:24:15 -07:00
dank 655a39ffb6 Merge pull request 'pkg-inventory.sh: detect a machine that has outgrown packages/*.pkgs, and declare blankpad's 24 missing ones' (#52) from feat/51-pkg-inventory into main 2026-10-20 18:20:07 -07:00
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
Frank 6f1f55345a Configure the engineering skills against this repo and this KB
The Matt Pocock engineering skills (triage, to-tickets, to-spec, research,
implement) assume per-repo config for three things: where work items live, what
the triage label vocabulary is, and where domain docs sit. Nothing here declared
any of them, so every skill run re-guessed.

Frank's decisions, 2026-10-20: Gitea on Dankclaw as the tracker (no gh, no tea on
any box, so it is recorded as prose — auth via a 600 curl config, the endpoint
table, the 404-on-empty-token / form-encoded-merge / empty-200-on-merge traps,
and the AGENTS.md laws a skill must not renegotiate); the canonical triage label
vocabulary kept as-is, with the five labels created on the tracker (the repo had
zero, so nothing collides); single-context domain docs.

The configuration moved out of the skill's default docs/agents/ and into the one
knowledge base this repo allows: three pages under omp-docs/skills/, an empty
omp-docs/decisions/ carrying the ADR format rule, an "## Agent skills" pointer
block in AGENTS.md, index entries, and a log entry. The glossary is deliberately
not created — every candidate term already has an owner in system/, core/ or
skills/, and a term list that paraphrases them is a second owner of facts already
written down. Known cost: re-running setup-matt-pocock-skills will not recognise
omp-docs/skills/* and will offer to recreate docs/agents/; the AGENTS.md pointer
is what stops that.

Also closes a verification gap found on the way: kb-lint.bats only ever points
the linter at fixtures, so a page with no frontmatter or an index that stops
listing a file passes the suite silently. Two tests now run the linter and the
coverage checker against the shipped omp-docs/ tree.

Refs #18
2026-10-20 16:13:44 -07:00
Frank 6af3714bd0 live: heartbeat sync 2026-10-20T15:15:41-07:00 (Refs #11) 2026-10-20 15:15:41 -07:00
Frank 495e2229f5 live: heartbeat sync 2026-10-20T14:12:41-07:00 (Refs #11) 2026-10-20 14:12:41 -07:00