Compare commits

..
Author SHA1 Message Date
Frank de7dd6f897 docs(links): five See Also links had no text at all
A generated See Also pass left `- ](index.md)` style links on the gitea pages, tools/cachyos.md,
and tools/applications.md. Rendered, an empty-text link is a bullet with nothing clickable in it,
so it reads as a formatting quirk and never as the broken reference it is. All five now name the
page they point at and use the absolute bundle path the spec recommends; the grep that finds them
is recorded in the log so the sweep is repeatable.

Refs #64
2026-10-21 02:43:15 -07:00
Frank 64bb23b329 docs(log): one dated section per day, and the sweep's lesson learned
The day had three `## 2026-10-21` headings — three logs wearing one date, which is the sprawl the
spec's one-dated-section rule exists to prevent. One date now, one `###` per campaign. `okf-maintenance.md`
states the multi-campaign shape explicitly so nobody has to infer it, and names `AGENTS.md` as the
co-owner of the entry law so an editor changes both.

The new entry records what the dedupe sweep actually taught: an index that restates its own
reference pages becomes the stale authority, and a subagent ledger is a claim until it is re-read
against the file it accuses — most rows returned by the audit fan-out were checkably false, and
half the ledgers never arrived at all. `skills/ecosystem.md` gains the `resource:` frontmatter the
bundle's other externally-sourced pages carry.

Refs #64
2026-10-21 02:40:59 -07:00
Frank e0b1466f74 docs(searxng): the upstream engine count is an order of magnitude, not a fact
The intro carried "388 engine entries" from a 2026-10-19 probe; upstream is at 385 today. The
number moves every week, which makes a precise one a liability in a runbook. The page now states
the size class, names `/config` as the only authority for what an instance loads, and keeps the
fleet's live 262 with today's date.

Refs #64
2026-10-21 02:26:24 -07:00
Frank d368394656 docs(research): break the two worst wall paragraphs
The chezmoi overview was one 488-character sentence-block; the git-crypt mechanism and verdict
lines were the same shape. Reflowed to ~100 columns with no wording lost, so the pages read the
same in an editor as they do rendered.

Refs #64
2026-10-21 02:25:56 -07:00
Frank ec19771b8b docs(llama-cpp): deployment detail goes back to the setup page
The overview page carried `docker run` invocations (including a CUDA variant, on a fleet with no
NVIDIA card left in it) and the `LLAMA_ARG_` environment rule, while `setup.md` — the page whose
own intro promises "build instructions, Docker, and systemd deployment" — carried the Compose
example and the same env form in use. Two places to look, one place to be wrong.

`setup.md` now states the env-prefix rule where the flags live (and why units prefer it: one
flag per line is auditable, a forty-flag command line is not), and the overview keeps only the
API surface and the GGUF format, pointing at the sections that absorbed the rest.

Refs #64
2026-10-21 02:25:14 -07:00
Frank 8b87b5ae1a docs(dedupe): one owner for the finish rule, and the Gitea index routes instead of restating
Two independent copies of the same law, and each page cited its own numbers. The finish rule —
stop when independent sources agree, when the retrieval budget is spent, or when every claim is
met; report gaps, never invent a source — stood in full in `agentic-search-best-practices.md`
(with the loop caps: 20 steps, re-plan every 4) and again in `research-effectiveness.md` without
them. The operational page owns it; the measurement page keeps the pointer and the one thing
that is its business, which is that unbounded loops are why "infinite re-search" is a named
failure mode.

`tools/gitea/index.md` was a second copy of its own directory: an architecture table, a
management command table, a data-persistence path table and a boot-persistence paragraph, every
one of them a subset of `architecture.md`, `management.md` and `data-persistence.md` — verified
line by line before cutting, including the two facts that looked unique to the index (the
`dns_enabled: false` default network, the `/opt/gitea` leftover) and turn out to be owned
upstream with more detail. The index now answers "which page owns my question" and gives three
one-line orientation facts. Gitea's live version was re-read while I was in there: still 1.27.3.

Refs #64
2026-10-21 02:24:11 -07:00
Frank 047f264fcf docs(ghostty): the index stops carrying a second copy of the reference
`tools/ghostty/index.md` had its own Custom Shaders section and its own eight-row default
keybinding table. `configuration.md` owns both, in more detail, and the copies had already
drifted: the index claimed `ctrl+` raises the font size where the reference's appendix lists
`ctrl+=` / `ctrl++`. An index exists to route a reader to the page that owns the fact; a second
summary of the same facts just guarantees two versions that age differently.

What the index keeps is the routing plus the one fact worth having at hand (`ctrl+shift+,`
reloads the config), and the config-syntax "Key Points" — repeatable options, `option = ""`
resets, `?` optional includes — which the reference never states as a set, only per option.

Refs #64
2026-10-21 02:23:25 -07:00
Frank 54bb0202c2 docs(system): the fleet re-read from the boxes, and the math that no longer belongs here
Three pages described the fleet; two of them still described the pre-September arrangement.
Re-read live on 2026-10-21 (`/v1/models`, `systemctl`, `lspci`, `nproc`, `resolvectl`):

- `system/network.md` called Dankclaw the "large inference model" box and Dankserver the
  "small" one — that is the topology from before the RTX 3090 moved in September. The served
  ids today are `frank-archon` on Dankserver:7770 (the main model) and `frank-dragoon`
  (:7770) + `frank-zealot` (:7771) on Dankclaw. The gateway and the DNS pin were re-read too.
- `system/dankserver.md` claimed `frank-medium` is *masked*: the unit file does not exist
  (`is-enabled` answers `not-found`), which is a different fact with a different remedy. And it
  claimed 8C/16T: this 7700X exposes **8 threads** (`nproc` = 8), so anything sized for 16 is
  sized for a machine that is not this one.
- `system/dankclaw.md` transcribed the engine's slot/KV/context math — `2×262,144 slots over
  one 524,288 pool, unquantized f16 K+V, MTP off` — while the live unit says `3x200704 slots
  over one 602112 pool, f16 K + q8_0 V, MTP armed`, and dragoon's ctx had moved too. That math
  belongs to the serverAppConfigs runbook the page already names: the unit's `ExecStart` is a
  wrapper script, so any copy here rots on the next engine change. The page now keeps the
  contract — id, unit, port, engine family, which GPU, and how to prove it — and disowns the
  numbers, with the reason stated so the next agent does not re-transcribe them.

Also recorded, because it is how this audit nearly missed a stopped service: `curl :7770/v1/models`
answered normally while `frank-dragoon.service` sat `inactive` with `Result=exit-code` — the
container is running outside systemd. A serving port is not a healthy unit. (Flagged for Frank
separately; no production unit was touched.)

Refs #64
2026-10-21 02:22:37 -07:00
Frank fb36a1b0fd docs(web-research): the search egress re-read from the live boxes
Two pages described Dankclaw's search stack from memory, and one of them invented a port.

`searxng-tuning.md` claimed "`:8081` (UI) / `:8082` (JSON API)". Re-probed 2026-10-21: JSON is
served on **8081** (`/search?q=…&format=json` returns results) and **8082 is not SearXNG at
all** — it publishes container port 8085, which is Marreta's nginx, moved off 8080 because
SearXNG owns that port inside the shared namespace. Four containers print the same port list in
`podman ps` because they share gluetun's namespace, which is exactly the detail that gets
mistaken for "the JSON API is on another port". The policy-drift flag now carries today's
reading: `/config` still reports 262 engines and zero disabled, unchanged since 2026-10-19, so
the tuned engine policy is still not live — still an operator decision, still not silently
re-applied.

`vpn-routing.md` gave a mullvad-shaped gluetun example and no fleet shape at all, while the
bundle's own work log says the manual-WireGuard law lives "on concept pages". It lives here now,
read from the running container: `VPN_SERVICE_PROVIDER=custom` + `VPN_TYPE=wireguard` + the
single-peer `WIREGUARD_*` names (`protonvpn` and the plural `WIREGUARD_PEERS_*` family are both
wrong for this image), `FIREWALL_ENABLED_DISABLING_IT_SHOOTS_YOU_IN_YOUR_FOOT=on` — whose name
is the warning that disabling it leaks egress while every check keeps passing — and
`tun0` present. The mullvad block stays as the generic illustration, now labelled as one.

Refs #64
2026-10-21 02:19:23 -07:00
Frank c37ad6e870 docs(indexes): skim layer skimmable, one undescribed entry described
An index is the skim layer: its entries decide whether a reader opens the page, so an entry
that runs 250 characters is a paragraph wearing a bullet. Four entries in `tools/index.md`
(`dotfiles-deploy`, `packages`, `pcie-probe`, `remote-job`) now say what the page is for in one
line and let the page carry the detail — which is the whole argument for progressive disclosure
having an index at all.

`engineering/infra/index.md` pointed at `../sdlc.md` with no description at all; it now carries
the one from that page's frontmatter, like its four siblings.

Refs #64
2026-10-21 02:16:56 -07:00
Frank c6e2a1daca docs(log): the work log stops re-teaching what the concept pages teach
504 lines, 4,496 words — the log had become the largest page in the bundle, and the reason was
not history: twelve entries dated 2026-10-18 → 2026-10-21 each re-narrated the incident AND
re-stated the law AND pointed at the page that owns the law. Three copies of one fact, dated
three ways. 384 lines / 3,251 words now.

What was cut: the restatements. The refuse-by-default guard belongs to
`tools/pcie-probe.md`, the completion-sentinel law to `tools/remote-job.md`, the verbatim
mode-string law to `tools/niri/displays.md`, the enable-snapshots-a-path trap to
`tools/dotfiles-deploy.md`, the sorted-directive rule to `caddy/caddyfile.md`, the effort-map
contract to `pi/models.md`, the tier law and the footer/`xdg-open` fleet laws to `AGENTS.md`,
and the gate's mechanics to `.githooks/pre-commit` plus its tests — each now named once, from a
short entry.

What was kept deliberately: laws with no other owner anywhere in the bundle. Fail-closed on a
blind `git diff`; a stub that echoes its environment instead of its argument cannot see the bug;
a name is data, never let the shell expand it; extensions must never await work that can exceed
30 s; `systemctl --user enable` snapshots an absolute path; agents never run
sudo/yay/pacman/pkexec (faillock incident, with the reset command); a claim about what the
harness sends is worth exactly its last capture; a number in a page is a claim, not a
measurement. Deleting those would have been the actual loss.

The 2025-08-05 entry is annotated rather than deleted: this repo's history dates that file's
arrival at 2026-08-28 (the OKF rebuild copied it), so 2025-08-05 is a legacy-tree date and the
entry now says so instead of silently contradicting `git log`.

Refs #64
2026-10-21 02:16:33 -07:00
Frank 88d486b37b docs(okf): make the bundle note describe the bundle
The note in `references/okf-spec.md` claimed the bundle keeps a "`tags` discipline". It does
not: 22 of 76 concept pages carry `tags`, and neither `kb-lint.sh` nor `kb-coverage.py`
reads that field or `verified` — so the note was a claim about intent that an agent would
believe and pay the wrong cost for (adding tags to a page helps no tool, while `verified`
silently does the real work: it is the date someone last re-checked the page against the
fleet, and its absence means nobody ever has).

The note now states the measured usage instead of the aspiration, records the house link
style (bundle-absolute, 99 links already written that way), and says plainly that Obsidian
`[[wikilinks]]` are not OKF links — with the count this bundle had and the date they were
converted. The spec's own repository is now cited under `# Citations`, which the page was
missing while it lectured others about skipping citations.

Refs #64
2026-10-21 02:14:39 -07:00
Frank 49f67b2192 docs(niri): find a keysym without a tool that is not installed
`key-bindings-syntax.md` told you to run `wev` and read `sym: XXXX` — a placeholder nobody
fills in, for a tool that is not on the machine: `command -v wev` is empty, `pacman -Ss
--sync wev` is empty on CachyOS, and the AUR carries it as `wev-git`. The bind token is an
XKB keysym, and the complete list is already on every box that links libxkbcommon, so the
page now leads with the grep against `/usr/include/xkbcommon/xkbcommon-keysyms.h` (run here:
`a A Escape Left Return Tab`), and keeps `wev` as the "press the key and see" option with its
real package name and where to get it.

`screenshots.md` line 35 read `(FIXME upstream).`, which scans as unfinished work in a page
that is finished: the defect is upstream's, tracked in the two issue links six lines below.
Reworded to say that.

Refs #64
2026-10-21 02:14:39 -07:00
Frank e49537c2ac docs(caddy): where the live Caddyfile is, and what the syntax page's claims cost
The page explained matcher ordering beautifully and never said where the file lives — so the
next agent hunts for a Caddyfile on the host and finds nothing. Measured on Dankclaw
2026-10-21: Caddy is a rootless Quadlet container (`caddy.container`, a *user* unit, so a
root `systemctl is-active caddy` answers `inactive` and means nothing), its config is
`/etc/caddy/Caddyfile` **inside the container**, `/etc/caddy` does not exist on the host, and
`pgrep -a caddy` prints a config path that no host path satisfies. Version is v2.11.7, which
closes the one version-dependent rule on the page: the leading-`/` `rewrite` form needs
2.8.0+, so the `*` disambiguator is legacy in this fleet's config.

Also: the seven caddyserver.com links sat in a mid-page "Sources:" paragraph. OKF wants
external claims under `# Citations`, so they move there and each entry says which claim it
supports. The validate section now points at the container instead of telling you to run the
validator on a file that is not there.

Refs #64
2026-10-21 02:14:39 -07:00
Frank 62a70caebb docs(okf): 53 Obsidian wikilinks become OKF links
`[[page|Label]]` is Obsidian syntax, not OKF. `kb-lint` resolves it Obsidian-style — by
filename, anywhere in the bundle — so it has never tripped the link gate, while every other
consumer of the bundle (an OKF renderer, a Gitea wiki export, `grep` for reachability) sees
bracketed text where a link should be. That is how two generated pages ended up with no
inbound link at all and nobody noticed: the my-first link census of the bundle reported them
as orphans because their only inbound edges were wikilinks.

Mechanical conversion, 52 links across 17 pages, each resolved against the same page set
`kb-lint` uses: an explicit `|Label` is kept verbatim, an unlabelled link takes the target's
frontmatter `title`. Targets are emitted bundle-absolute (`/tools/niri/tips.md`), the form
the spec recommends and the form 99 existing links in this bundle already use.

Two links were `[[index]]`, which resolves by filename to 21 pages — the ambiguity that
filename-resolution invites. Both meant their own directory's index, so both are now written
out in full: `/tools/gitea/index.md`, `/tools/niri/index.md`.

The linter still tolerates wikilinks; whether it should reject them outright is an open
question on #64, because that would replace the two tests pinning today's tolerance.

Refs #64
2026-10-21 02:11:56 -07:00
Frank 4735702965 fix(kb-lint): a markdown link may not resolve into the quarantine
The link-integrity pass ended in `is_file()`, and `.AGENTTRASH/` is a real directory on
this machine: a page pointing at `[x](.AGENTTRASH/gone.md)` passed the gate here and was a
dead link everywhere else, because the quarantine is gitignored. This is incident #57 — the
wikilink resolver resolving `[[log]]` onto a quarantined copy — reproduced in markdown
clothing, which is to say: fixing one resolver while leaving the identical assumption in the
other one is not a fix.

A link that lands inside the bundle must land on a page that belongs to the bundle. A link
that leaves the bundle is still honoured (`tools/live-sync.md` -> `../../AGENTS.md` points at
the operating contract, which is not bundle content and is real on disk). Red test added
first, both link spellings covered; nothing previously green changed.

Refs #64
2026-10-21 02:10:37 -07:00
Frank 22d2f3bcd8 chore(merge): land origin/main (PR #61, PR #63) in the deployment checkout
Two conflicts, both resolved to trunk, both proven content-free for the running
machine before the merge was committed:

- `agent/APPEND_SYSTEM.md` — the checkout's dirty copy and trunk differ in one line
  only: #61's trim of the duplicated one-log reasoning, replaced by the owner pointer.
  The doctrine text itself is byte-identical on both sides.
- `desk-1-home.kdl` — non-comment lines are byte-identical (`diff` on the stripped
  file: no output), so the deployed modes are unchanged: `3440x1440@180.000` ×3 and
  `1920x1080@60.001`. What changes is the comment: trunk's carries the measured mode
  list (`3440x1440@239.998` exists), the reason 180 is deliberate, and the verbatim
  mode-string law — the live copy still asserted "no 240 Hz mode exists", which the
  panel contradicts. `niri validate` on the assembled config: valid.

Deployment state after this: the checkout is main, and `agent/memories/**` +
`agent/managed-skills/**` were committed by the heartbeat commit underneath. Refs #60 #62
2026-10-21 01:45:33 -07:00
Frank 205b54adcb live: heartbeat sync 2026-10-21T01:45:04-07:00 (Refs #11) 2026-10-21 01:45:04 -07:00
Frank 8af40c402b chore(agent): commit the machine tier and the doctrine text the checkout was already running
Landing blocker for #61 and #63: the deployment checkout is 12 commits behind and
`merge --ff-only` refuses while tracked paths are dirty. Discard is not available here
(workspace policy: no `checkout`/`stash` on shared state), so the dirt is committed.

- `agent/APPEND_SYSTEM.md`: the point-of-use / one-work-log / PR-link doctrine, already
  byte-identical to `origin/main` (`cmp` against the trunk blob: no difference). No new
  prose — the commit exists so the trunk merge can proceed.
- `agent/config.yml` and `agent/managed-skills/**`: the machine-live tier the heartbeat
  owns (AGENTS.md § Live-sync tiers).

`ALLOW_SECRETS=1` was used, deliberately and after auditing the staged diff by hand: the
only credential-shaped strings in it are two copies of `AKIA0000000000000EXAMPLE` — the
inert all-zero AWS example id — and they appear inside the two skill pages that teach
that exact placeholder shape. This checkout is still running the pre-#61 hook; the merged
gate (value-scoped placeholder filter, `71601f7`) exempts this value, so the refusal
dies with this landing. No `--no-verify` was used: the gate ran and its verdict is
recorded here.

Refs #61
2026-10-21 01:44:51 -07:00
dank 6e89c63e5d Merge pull request 'fix(#62): frank-dragoon's reasoning_effort contract, measured instead of assumed' (#63) from fix/62-dragoon-effort-contract into main 2026-10-21 01:44:23 -07:00
dank e690a2ca66 Merge pull request 'fix(#60): checks that lied, and claims the machine stopped honouring' (#61) from fix/60-review-findings into main 2026-10-21 01:44:07 -07:00
Frank d4d6dd0c44 fix(omp): frank-dragoon's effort map folded against rejections that do not exist
The entry rewrote every effort before the wire: `minimal -> low`, `high -> xhigh`,
`max -> xhigh`, under a comment claiming flash-server rejects anything outside
{xhigh, medium, low}. The engine says otherwise, in its own 400:

    reasoning_effort 'maximum' unsupported; use minimal|low|medium|high|xhigh|max|none

and the harness passes an unmapped level through verbatim
(`compat.reasoningEffortMap?.[effort] ?? effort`, read in the pinned `dist/cli.js`). So the
fold protected nothing and silently changed what the caller asked for. The levels are not
cosmetic either — thinking length on one prompt at `max_tokens 2048`: `low` 766 reasoning
tokens, `xhigh` 614, `max` 1049 with the block closed at 1024.

The map is identity. Two things came out of capturing the wire (scratch HOME + a logging
proxy forwarding :7799 to :7770) that reading the config could not have told me:

- `:off` does not switch thinking off. The default disable mode sends the lowest effort,
  so `off` left as `reasoning_effort: "minimal"` — and `minimal` still thinks, a canned
  26-token block on every request. `reasoningDisableMode: none-effort` makes the harness
  send `"none"`, which the engine honours as a real switch: `reasoning_tokens: 0`, answer
  only, no thinking block rendered. Verified after the change, not before it.
- `:max` still arrives as `xhigh`: the harness clamps the requested effort to its own
  ladder for this model before the map applies. Recorded in the comment as the engine-side
  half of the contract rather than papered over — nothing in models.yml forces it.

Also pinned `top_k: 20` (accepted; the comment claimed the wire matched the server sampler
while `top_k` was never sent, leaving the recipe one env edit away from drifting), and
restored the pre-callsign model ids `frank-medium` / `frank-large` / `frank-small` — the
2026-10-08 rename had replaced the old id inside its own explanation, leaving "renamed
from frank-dragoon" three times and destroying the record needed to resolve an old id.

Verified: full suite green (0 failures), `kb-lint` 0 violations, YAML parses, and the four
efforts `off/high/medium/max` captured on the wire as `none/high/medium/xhigh` with
`top_k: 20` on every request. Refs #62
2026-10-21 01:28:37 -07:00
Frank 66e1129e6c docs: one owner for the one-log rule, and an honest scope on the census
The one-work-log rule was written near-verbatim in four places — okf-spec,
okf-maintenance, log.md's preamble, and APPEND_SYSTEM.md. Four copies is four
authorities: each can be edited alone, and the next reader cannot tell which one the
linter implements. okf-spec.md owns it; maintenance now carries the action (collapse
a second log into the root, then re-run the linter) instead of re-arguing it; the log
page points at the rule rather than restating it. APPEND_SYSTEM keeps its imperative —
a system prompt cannot fetch a link at the moment of writing — but no longer carries
the duplicated reasoning.

`inventory/index.md` advertised "all userspace program configuration on the
workstation". The one page in that directory measures `~/.config` on blankpad: not
`~/.local/share`, not system package state, not the servers. A coverage claim that
names what was measured is the difference between a census and an alibi.

Refs #60
2026-10-21 01:06:51 -07:00
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
Frank 37ef4897ae live: heartbeat sync 2026-10-20T13:10:41-07:00 (Refs #11) 2026-10-20 13:10:41 -07:00
Frank 06e549cb27 live: heartbeat sync 2026-10-20T12:07:41-07:00 (Refs #11) 2026-10-20 12:07:41 -07:00
Frank c96741794a live: heartbeat sync 2026-10-20T11:04:41-07:00 (Refs #11) 2026-10-20 11:04:41 -07:00
Frank e14d0906ea live: heartbeat sync 2026-10-20T10:02:41-07:00 (Refs #11) 2026-10-20 10:02:41 -07:00
Frank d5be16127b live: heartbeat sync 2026-10-20T08:59:41-07:00 (Refs #11) 2026-10-20 08:59:41 -07:00
Frank b7aef40266 live: heartbeat sync 2026-10-20T07:57:45-07:00 (Refs #11) 2026-10-20 07:57:45 -07:00
Frank fafdaa2617 live: heartbeat sync 2026-10-20T06:56:41-07:00 (Refs #11) 2026-10-20 06:56:41 -07:00
Frank 7213068d91 live: heartbeat sync 2026-10-20T05:55:41-07:00 (Refs #11) 2026-10-20 05:55:41 -07:00
Frank 05dbe8c5c1 live: heartbeat sync 2026-10-20T04:54:41-07:00 (Refs #11) 2026-10-20 04:54:41 -07:00
Frank 806352f8f6 live: heartbeat sync 2026-10-20T03:51:41-07:00 (Refs #11) 2026-10-20 03:51:41 -07:00
Frank 34a7af2847 live: heartbeat sync 2026-10-20T02:50:41-07:00 (Refs #11) 2026-10-20 02:50:41 -07:00
Frank 635c64d773 live: heartbeat sync 2026-10-20T01:48:41-07:00 (Refs #11) 2026-10-20 01:48:41 -07:00
Frank 64d9c54657 live: heartbeat sync 2026-10-20T00:45:41-07:00 (Refs #11) 2026-10-20 00:45:41 -07:00
Frank aec7f0f068 live: heartbeat sync 2026-10-19T23:44:41-07:00 (Refs #11) 2026-10-19 23:44:41 -07:00
Frank 4023b21365 live: heartbeat sync 2026-10-19T22:41:41-07:00 (Refs #11) 2026-10-19 22:41:41 -07:00
Frank e14fc884a3 live: heartbeat sync 2026-10-19T21:40:41-07:00 (Refs #11) 2026-10-19 21:40:41 -07:00
Frank b3a84c5bb8 live: heartbeat sync 2026-10-19T20:37:41-07:00 (Refs #11) 2026-10-19 20:37:41 -07:00
Frank 9e6254108a live: heartbeat sync 2026-10-19T19:34:41-07:00 (Refs #11) 2026-10-19 19:34:41 -07:00
Frank ce87958b77 live: heartbeat sync 2026-10-19T18:31:41-07:00 (Refs #11) 2026-10-19 18:31:41 -07:00
Frank 802014fea9 live: heartbeat sync 2026-10-19T17:29:41-07:00 (Refs #11) 2026-10-19 17:29:41 -07:00
Frank e22acf4913 live: heartbeat sync 2026-10-19T16:27:41-07:00 (Refs #11) 2026-10-19 16:27:41 -07:00
Frank c14c64d6c0 live: heartbeat sync 2026-10-19T15:24:41-07:00 (Refs #11) 2026-10-19 15:24:41 -07:00
Frank 6e3e5594eb live: heartbeat sync 2026-10-19T14:21:41-07:00 (Refs #11) 2026-10-19 14:21:41 -07:00
dank 36ff3742b4 Merge pull request 'docs: auto-speak narrates every turn' (#38) from docs/dankvoice-narration-cadence into main 2026-10-19 13:33:42 -07:00
Frank fd6a07bb58 docs: auto-speak narrates every turn, not only the final answer (Refs #36)
The Limits bullet still described the pre-fix behaviour ("thinking and the
final message only"), which dank/dankvoice#3 just retired: narration rides
`turn_end`, so the assistant's prose and thinking are spoken while the agent
works. Records the two rules an operator needs when audio seems missing —
arming is read at startup only, and a turn landing during playback is dropped
rather than queued — plus where the design rationale lives.

kb-lint 0 violations, index-coverage 0 issues.
2026-10-19 13:31:01 -07:00
Frank 1921d76319 Merge remote-tracking branch 'origin/main' 2026-10-19 13:29:52 -07:00
Frank d302151a8d docs: land merged #37 content in the live checkout
The same bytes are already in origin/main via PR #37; the workspace policy here
blocks restore/stash/switch, so the identical working-tree copies are committed
rather than discarded, letting the deployment take the merge commit.
2026-10-19 13:29:52 -07:00
dank 5b6f4381b0 Merge pull request 'docs: LazyVim profile page + auto-speak arming rule' (#37) from docs/36-voice-and-editor-truth into main 2026-10-19 13:29:28 -07:00
Frank cae49fcd09 docs: record the LazyVim profile and the auto-speak arming rule (Refs #36)
Neither setting did what it looked like it did, and the knowledge base had no
page for either, so both were invisible until the operator asked.

tools/neovim.md: the two entry points (vanilla `nvim`, `lazyvim` as an
NVIM_APPNAME profile), the fresh-box install, the base16 wallpaper-palette hook
with its SIGUSR1 contract, and the trap that matters — the official recipe
clones over ~/.config/nvim and destroys the vanilla quick-edit path.
tools/applications.md gets the editor row (Ghostty's row had lost its label)
and Neovim's verified version.

core/dankvoice.md gains the arming rule: arming is session-scoped, a silent
session start is a config state, and the two checks — `/say status` then
`pgrep -a pw-play` — prove the chain without blaming the endpoint.

kb-lint 0 violations, index-coverage 0 issues.
2026-10-19 13:28:24 -07:00
Frank ee292ffd10 live: heartbeat sync 2026-10-19T13:20:41-07:00 (Refs #11) 2026-10-19 13:20:41 -07:00
dank dd4713fa81 Merge pull request 'chore: retire dankvoice drop-in for the dank/dankvoice package' (#35) from feat/34-dankvoice-cutover into main 2026-10-19 12:43:48 -07:00
Frank a8727ddf0b chore: retire dankvoice drop-in for the dank/dankvoice package
The extension's full behaviour now lives in dank/dankvoice (41/41
black-box suite against the built artifact; this branch's 26 drop-in
tests are a strict subset, names ported 1:1). Delete the drop-in and
its bats suite; repoint omp-docs/core/dankvoice.md at the package,
keeping only fleet wiring (frank-tts + @tiny resolution, the
enable_thinking hybrid law, voice/roster contract) and the cutover
procedure. kb-lint 0 violations; bats tests/ 45/45.

Refs #34
2026-10-19 12:38:28 -07:00
Frank 2faeab665a live: heartbeat sync 2026-10-19T12:19:36-07:00 (Refs #11) 2026-10-19 12:19:36 -07:00
Frank 62b3b92e69 live: heartbeat sync 2026-10-19T11:18:41-07:00 (Refs #11) 2026-10-19 11:18:41 -07:00
dank ec6a707fe4 Merge pull request 'fix(dankvoice): pin thinking off in rewrite, fix silent and timeout modes' (#33) from fix/32-dankvoice-latency into main 2026-10-19 11:12:50 -07:00
Frank 96553979fb fix(dankvoice): pin thinking off in rewrite, fix silent and timeout modes
Refs #32

The humanizer's raw fetch to frank-zealot omitted
chat_template_kwargs.enable_thinking: false, so the Qwen35 hybrid thought
instead of answering (6.9 s reasoning_content, empty content - timing out
the 6 s abort; or answering "" and muting the unit). Pin the kwarg, treat
empty-content-with-reasoning as failure over silence, and stop the menu's
test phrase from blocking its handler. Three red-captured regressions;
26/26 green, verified red against HEAD.
2026-10-19 10:46:06 -07:00
Frank ac5b377750 live: heartbeat sync 2026-10-19T10:17:52-07:00 (Refs #11) 2026-10-19 10:17:52 -07:00
dank 8f5a229cc6 Merge pull request 'chore(scripts): promote index-coverage checker; document bundle gates' (#31) from chore/29-kb-coverage-tooling into main 2026-10-19 10:01:30 -07:00
Frank ea514c8fd2 chore(scripts): promote index-coverage checker from campaign scaffolding
PR #30 merged the throwaway checker under its .tmp- name. Promoted to
scripts/kb-coverage.py and documented on okf-maintenance.md § Automated
Gates alongside kb-lint (the bundle previously documented neither gate).
Self-tested: kb-coverage.py reports 0 issues on the current bundle.

Refs #29
2026-10-19 09:59:50 -07:00
dank bc60fa33be Merge pull request 'docs: exhaustive dedupe, compaction, OKF conformance, fact verification' (#30) from docs/29-dedupe-compact into main 2026-10-19 09:56:23 -07:00
Frank e4ecc93f29 live: heartbeat sync 2026-10-19T09:17:41-07:00 (Refs #11) 2026-10-19 09:17:41 -07:00
Frank d055eb1e7f live: heartbeat sync 2026-10-19T08:15:41-07:00 (Refs #11) 2026-10-19 08:15:41 -07:00
Frank 70346f3d57 live: heartbeat sync 2026-10-19T07:12:41-07:00 (Refs #11) 2026-10-19 07:12:41 -07:00
Frank 1251cfbd40 live: heartbeat sync 2026-10-19T06:09:41-07:00 (Refs #11) 2026-10-19 06:09:41 -07:00
Frank e67bbd0de0 live: heartbeat sync 2026-10-19T05:07:41-07:00 (Refs #11) 2026-10-19 05:07:41 -07:00
Frank 986081bf1d docs(log): compact changelog to entry law (406 -> 284 lines)
Changelog entries were essay-length incident writeups duplicating the
concept pages they created. New entry law recorded at top of log.md:
one dated entry = what changed + durable lesson + concept-page link;
full narratives recoverable via git log --follow. Every date, headline,
incident, and durable lesson preserved; narrative prose that already
lives on concept pages (caddy handle-ordering, gluetun/limiter field
notes, quadlet gotchas, faillock law, credential-store trap) replaced
by pointers. Historical pi-era entries tagged '(pi era)'.

Also: self-hosted-web-agents.md '44.5% peer-reviewed CUA' figure could
not be re-attributed to any published benchmark via web search today
— dropped with an explicit note; the valid core claim (87.4% = managed
Cloud, not the self-hostable library) kept.

Refs #29
2026-10-19 04:23:58 -07:00
Frank a9b3c495da docs(research): fact-check web-research and chezmoi docs against sources
- open-models.md: Qwen2.5-VL 'best open vision model' superseded by
  Qwen3-VL (Apache 2.0 dense sizes; repo QwenLM/Qwen3-VL, arXiv
  2511.21631, verified via web search); decision table + mermaid +
  citations updated
- chezmoi-evaluation.md: fleet facts were fabricated — servers run
  Ubuntu 26.04 LTS (probed via ssh, both boxes), not 'Arch/FreeToken'
  (distro does not exist); dev workstation is AMD Ryzen 5 PRO 5650U,
  not Intel Ultra 7; frank-default-* endpoints retired 2026-10-18
- agentic-web-research/index.md: pi-era topology blurb replaced with
  current fleet endpoints
- self-hosted-web-agents.md: sample model frank-default-small ->
  frank-archon (endpoint/IP already correct: dankserver)
- mcp.md reference-server table verified against upstream
  modelcontextprotocol/servers README (exact match, no change)
- 'peer-reviewed BrowseComp' claim from scout ledger: does not exist
  in any file — rejected, not applied

Refs #29
2026-10-19 04:17:49 -07:00
Frank c99eb8e11b docs(fleet): live-probe corrections across dankclaw, caddy, gitea, searxng, vpn docs
Live read-only probes on Dankclaw (BatchMode ssh, 2026-10-19):

- system/dankclaw.md: full 32-container census added (authentik IdP,
  prometheus/grafana/loki/alloy/alertmanager monitoring, pihole, ntfy,
  seerr, hermes stack, anubis, frank-tts :7772 all undocumented);
  dragoon pool corrected 401,408 @ ctx 200,704 (was conflated);
  zealot corrected to PrismML build 10777, Ternary-Bonsai-2-9B
  PQ2_0-MTP, 2x262,144 over one 524,288 pool, f16 K+V (doc claimed
  build 10754, 27B, 2x200,704)
- caddy/index.md: v2.11.7 (in-container ), deduped
  caddyfile listing
- gitea: version 1.27.3 probed via API; systemd-gitea network + rootless
  100999 mapping confirmed live (gitea-server attaches systemd-gitea;
  dank subuid 100000:65536); index See-Also de-duplicated
- searxng-tuning.md: live instance section (ghcr image, valkey:9-alpine,
  marreta netns-sharing explains duplicate port maps); POLICY DRIFT FLAG:
  tuned engine policy (google/bing disabled, verified 2026-08-16) is NOT
  live — /config now shows all 262 engines enabled; upstream settings.yml
  count corrected 388@master; limiter table already matched source
- vpn-routing.md: verify snippet fixed — gluetun image ships no curl;
  health server is 127.0.0.1:9999/health ('OK'), :8000 is the CONTROL
  server (404s on health paths); correct probes provided

Refs #29
2026-10-19 04:12:04 -07:00
Frank cd20d6ddfd live: heartbeat sync 2026-10-19T04:06:41-07:00 (Refs #11) 2026-10-19 04:06:41 -07:00
Frank 82dd0c7d09 docs(power): reclaim the sleep docs from the sold box; fix AGENTS.md machine facts
The sleep/wake saga (Alienware-M16a2 research + Sleep-Fix-Guide) framed a
machine sold on 2026-09-28 as 'this system' — the 2026-09-28 identifier
purge missed both because they name no hostname. blankpad probed live:
Lenovo, AMD Ryzen 5 PRO 5650U / Cezanne, unified memory, s2idle only,
no NVIDIA, no ~/GitPackages/llama.cpp.

- research/Sleep-Fix-Guide.md -> .AGENTTRASH (verbatim restatement of the
  research doc's fix steps; PowerCluster overlap census)
- Alienware doc: archive banner, 'this system' -> retired dankware
- linux-power-management.md: scope note (blankpad s2idle-only; NVIDIA
  section is dankware/future-NVIDIA territory), boilerplate AGENTS.md
  cross-ref dropped
- cachyos.md: de-pinned rolling-release kernel versions (7.1.4 doc vs
  7.2.9 live), repo list confirmed verbatim against pacman.conf, SDDM
  confirmed live, verified: 2026-10-19
- AGENTS.md: dev-workstation row rewritten for blankpad; Gitea gotcha
  corrected — remote URLs carry NO token (credential-store file, verified)

Refs #29
2026-10-19 03:46:02 -07:00
Frank 99b9a6a17c docs(pi): unify split package trees; move live OMP pages to core
Two directories documented one concept (pi npm packages): top-level
packages/ (browser-use, permission-system) and pi/packages/ (peon,
web-access, subagents). Merged into pi/packages/ — one row per package
in a single index, detail pages kept as archive records. The root
index's 'OMP packages and skills' section was doubly wrong (packages/
was pi, not OMP); it becomes a Skills tooling section pointing at the
consolidated /skills/ tree.

Moved the two LIVE omp pages out of the retired-harness banner:
pi/statusline.md -> core/statusline.md, pi/subagent-limits.md ->
core/subagent-limits.md (both document omp behavior against
agent/config.yml + the installed bundle; pi/index.md itself admitted
the mis-shelf). Retargeted every inbound link (core/index, system/
dankserver, log x2, pi/index, settings x2, moved files' own xrefs).

Refs #29
2026-10-19 03:32:07 -07:00
Frank 39385636be docs(llama): delete fabricated ornith-llama-cpp-setup tree (608 lines)
Night fact-check falsified the tree's subject: no `ornith` CLI exists
(npm 404), github.com/rhitching/ornith is 404, and the model IDs the
tree cites are wrong — the real family is ornith-ai/Ornith-1.0/1.5
(9B, 35B-A3B, 397B), built on Qwen3.5/Gemma4 per its model card, not
the 'Ornith-9B-Thinking-1.5T based on Qwen3-8B' the docs assert.
Nothing in the tree describes this fleet: no ornith binary on PATH
(audit 2026-10-18 said the same). Real facts it echoed (PrismML fork
on frank-zealot) already live in /system/.

Also: tools/index.md de-duplicated (same files were listed twice, once
as wikilinks once as links, live-sync missorted) and links normalized;
llama-cpp setup/index xrefs retargeted.

Refs #29
2026-10-19 03:17:49 -07:00
Frank d99c1c3c4f live: heartbeat sync 2026-10-19T03:04:41-07:00 (Refs #11) 2026-10-19 03:04:41 -07:00
Frank 651924a4bc docs(skills): collapse three overlapping skills trees into one verified tree
Three parallel trees documented the same ecosystem: omp-docs/skills/
(two overview pages), tools/npx-skills/ (10 pi-era npm pages), and
tools/vercel-labs-skills/ (11 CLI pages), plus a fourth leaderboard page
at engineering/skills-ecosystem.md. Verbatim overlap (identical SKILL.md
field tables in skills-spec.md vs skill-format.md; 13-agent vs 68-agent
copies of the same upstream table). Consolidated to four concept pages:

- skills/agent-skills.md: standard, format, loading, authoring (merges
  skills-spec, skill-format, skill-file-structure, skill-loading,
  best-practices, skills-vs-extensions)
- skills/skills-cli.md: CLI reference (merges commands, source-formats,
  installation-scope, skill-discovery, lock-file, env-variables,
  agent-support, related-links)
- skills/store-wiring.md: moved, fleet ops, links retargeted
- skills/ecosystem.md: replaces skills-ecosystem.md; stale 2026-08-12
  top-50 table dropped, replaced with a live top-10 re-queried from the
  skills.sh API tonight and the API contract re-verified (zero-based,
  200/page, {skills,total,hasMore,page}, total 9909)

All facts re-verified 2026-10-19 against primary sources:
agentskills.io, vercel-labs/skills README + src/cli.ts (command set,
--full-depth) + src/skill-lock.ts (v3 lock, XDG path, wipe-on-old),
live skills.sh API. Deleted superseded core/index-doc.md (old pi-era
docs index). Retargeted all inbound links (tools/index, engineering x3,
packages x3, pi/skills, core/index). Pi-era npm skill packaging is
listed as archived under /pi/.

Refs #29
2026-10-19 02:58:52 -07:00
Frank e86e60af39 docs: close OKF conformance gaps found by night audit
- references/okf-spec.md: replace v0.2 note with the deltas verified
  against upstream SPEC.md 2026-10-19 (timestamp->generated.at and
  # Citations->sources are the two breaking changes; trust fields and
  root-index okf_version are additive; bundle stays v0.1).
- index.md: restore the agentic-web-research section lost in the
  2026-08-28 rebuild and link log.md (index-coverage doctrine).
- gen-keybinds.sh emits OKF frontmatter so its generated artifact
  (tools/niri/keybindings.md, regenerated, 120 bindings) passes kb-lint;
  test added red->green.
- fix the two broken [[niri]] wikilinks (handy, noctalia-shell).

Refs #29
2026-10-19 02:43:33 -07:00
Frank 01d9d3fbd5 live: heartbeat sync 2026-10-19T02:02:41-07:00 (Refs #11) 2026-10-19 02:02:41 -07:00
dank 570b5509d0 Merge pull request 'dankvoice: humanized narration via @tiny and thinking aloud' (#28) from feat/dankvoice-enhanced-narration into main 2026-10-19 01:57:51 -07:00
Frank 3579c7ebc5 dankvoice: humanize answers via the @tiny role and narrate thinking
Answers spoken with /say were verbatim markdown-cleaned text while the
built-in vocalizer speaks prose rewritten by a small model. Port that
enhancer (bundle Qyr/T6o: same prompt, 1536 tokens, 6 s, 4000-char
window, null-fallback/empty-skip contract) and resolve its model from
modelRoles.tiny exactly as the harness does, so auto-speak sounds like
the enhanced TUI voice instead of a pager. agent_end now also speaks the
last assistant message's thinking blocks as their own unit before the
answer; narration-flagged thinking counts as answer text. Both knobs are
menu-toggleable, persisted, and env-overridable; the fixture gains a
chat route (echo/prose) and seven scenarios cover the round-trip, the
404 fallback, ordering, the kill switches and role resolution (22 green,
58 suite-wide; live smoke through real frank-zealot + frank-tts).

Refs #27
2026-10-19 01:55:57 -07:00
dank 0075ca3bbf Merge pull request 'dankvoice: segmented sequential speech (ported bundle vocalizer)' (#26) from fix/dankvoice-segmented-speech into main 2026-10-19 01:25:03 -07:00
Frank bcd52bb0f4 dankvoice: speak long answers as sequential sentence-sized requests
agent_end awaited one whole-answer synthesis; long answers hit the
harness 30s extension-handler timeout (handler timed out after 30000ms)
and the single frank-tts request was the stall. Port the built-in
speak-aloud vocalizer's utterance segmenter (fHe in dist/cli.js) so
segmentation matches the known-working stock path: [.!?…]-run
boundaries, abbreviation/decimal/inline-code guards, fenced code and
tables swallowed, markup cleaned. Play segment N to player exit before
synthesizing N+1 (audio never stacks); fire-and-forget the chain so
utterances outlive the handler timeout; a run token retires superseded
chains. DANKVOICE_MAX becomes a per-answer budget of whole segments;
stripForSpeech is gone. Refs #23 follow-up.
2026-10-19 01:21:34 -07:00
Frank edd7e0cf68 live: heartbeat sync 2026-10-19T01:00:41-07:00 (Refs #11) 2026-10-19 01:00:41 -07:00
Frank f033639b54 chore(agent): disable built-in speak-aloud — dankvoice cloned voices are the TUI voice (Refs #23) 2026-10-19 00:49:14 -07:00
dank 6fffd83957 Merge pull request 'feat(dankvoice): /say options menu with output-device routing' (#25) from feat/23-dankvoice-menu into main 2026-10-19 00:40:33 -07:00
dank acf67182e9 Merge pull request 'docs: record omp subagent fan-out caps and per-endpoint in-flight ceilings' (#24) from docs/subagent-limits into main 2026-10-19 00:23:29 -07:00
Frank 38bf9c4897 docs: record omp subagent fan-out caps and per-endpoint in-flight ceilings
Codifies the limits set in agent/config.yml 2026-10-19: task concurrency 2,
no nested subagent spawns (maxRecursionDepth 1 - and why 0 must never be
set), 30-min runtime / 120-request budgets, isolation opt-in enabled,
completion probes off, 600s tool timeout ceiling, and per-provider
in-flight caps (archon 3, dragoon 2, zealot 3) keyed by models.yml provider
ids with their per-machine scope caveat. Small docs-only change; no issue
opened per trunk-based small-fix exemption.
2026-10-19 00:22:17 -07:00
346 changed files with 10959 additions and 5391 deletions
+10
View File
@@ -1,3 +1,13 @@
# LFS routing for image types. Measured 2026-10-21: this repo tracks ZERO LFS files
# (`git lfs ls-files` = 0) — the wallpaper library became live theme state and is gitignored
# (omp-docs/tools/dotfiles-deploy.md), so these four rules match nothing today. They are kept
# because tracked media is a plausible future and idle filters cost nothing; deleting the file
# is a pruning call for a session with Frank, not a cleanup an agent runs alone.
#
# Do not cite this file as the reason `git-lfs` is installed on a box. The manifest carries its
# own justification (packages/common.pkgs), and a tool justified by dead config is a tool nobody
# can defend when the config goes.
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
+130 -10
View File
@@ -1,25 +1,145 @@
#!/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.
#
# `-z`, and the list lives in a file. Two traps decide this: git quotes any path it cannot print
# as UTF-8 (core.quotePath is on by default), and a quoted name handed back to
# `git diff -- <path>` matches nothing — the file is skipped, and a skipped file used to be a
# silent pass. Command substitution cannot hold the list either, because $() deletes NUL bytes,
# so the names go to a temp file and the loop reads them with a NUL delimiter.
TMPD=$(mktemp -d) || TMPD=""
if [ -z "$TMPD" ]; then
echo "SECRET GATE: cannot create a temp dir — refusing to commit." >&2
[ -n "${ALLOW_SECRETS:-}" ] && exit 0
exit 1
fi
trap 'rm -rf "$TMPD"' EXIT
if ! git diff --cached --diff-filter=ACM --name-only -z > "$TMPD/files" 2> "$TMPD/err"; then
{
echo "SECRET GATE: cannot read the staged diff — refusing to commit."
sed 's/^/ /' "$TMPD/err"
echo "Override deliberately: ALLOW_SECRETS=1 git commit ..."
} >&2
[ -n "${ALLOW_SECRETS:-}" ] && exit 0
exit 1
fi
# git's stderr stays out of the name list on purpose: a `warning:` line mistaken for a path
# becomes a file that "cannot be read", which is how a gate starts skipping the real ones.
[ -s "$TMPD/files" ] || exit 0
report=""
exempt=""
count=0
while IFS= read -r -d '' 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
# Shape hits are judged on the MATCHED TOKEN, not on the whole line. Documentation about
# credentials quotes vendor placeholders — AWS publishes an all-zeros access-key id that ends
# in EXAMPLE for exactly this purpose — and
# a gate that blocks prose over an inert example gets worked around until it means nothing:
# the live-sync heartbeat failed three ticks on 2026-10-21 because a managed skill page quotes
# exactly that id. A line is exempted only when EVERY shape on it is inert, so one live token
# sharing the file with a documented fake still blocks.
hits=$(printf '%s\n' "$added" | while IFS= read -r line; do
live=0
while IFS= read -r m; do
# The shape regexes carry fixed quantifiers, so the match they return is TRUNCATED:
# `AKIA` + exactly 16 characters stops inside the word EXAMPLE and the placeholder
# disappears from what is judged. Judge the match plus what follows it instead.
ctx=$(printf '%s' "$line" | awk -v m="$m" '{ i = index($0, m); print (i > 0 ? substr($0, i, length(m) + 8) : m) }')
printf '%s' "$ctx" | grep -Eiq 'EXAMPLE|X{4,}|Y{4,}|Z{4,}|_{4,}|REDACTED|CHANGEME|PLACEHOLDER' || live=1
done < <(printf '%s\n' "$line" | grep -Eio "$shapes" || true)
[ "$live" = 1 ] && printf '%s\n' "$line"
done)
if [ "${f##*.}" != "md" ]; then
# The placeholder filter reads the VALUE, never the whole line. A trailing comment that
# says `# example` says nothing about the token the same line assigns; scoping the filter
# to what follows the separator is what keeps `API_TOKEN=<real> # placeholder` a block.
assigns=$(printf '%s\n' "$added" | grep -Ei "$assign" | grep -Ev "$assign_exempt" \
| while IFS= read -r line; do
# The judged value is the assigned token alone: it ends at whitespace or `#`, so a
# trailing comment cannot vouch for it. Truncating a quoted value early only ever errs
# toward blocking, which is the safe direction for a gate that can be overridden.
value=${line#*[:=]}
value=${value#"${value%%[![:space:]]*}"}
value=${value%%[[:space:]]*}
value=${value%%#*}
printf '%s\n' "$value" | grep -Eiq "$placeholders" && continue
printf '%s\n' "$line"
done)
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/^/ /')"$'\n'
fi
done < "$TMPD/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
+36
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,35 @@ 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; `/w/<id>` answers `text/html`, so resolve the CDN URL at
# `https://wallhaven.cc/api/v1/w/<id>` and download `data.path` — the recipe, with the reason it
# is not a plain `curl -L`, is on 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. These patterns are the ignore half of the
# exclusion list in `deploy_file` (scripts/deploy.sh): change one, change the other, or a
# generated file becomes tracked-but-never-deployed — the fiction class #41 removed. The
# capital-N rule is noctalia's own palette-directory naming, and it was in deploy.sh only.
# Law: omp-docs/inventory/home-config-census.md, Refs #42, #60
dotfiles/**/themes/noctalia*
dotfiles/**/colors/noctalia*
dotfiles/**/noctalia.*
dotfiles/**/noctalia-*
dotfiles/**/Noctalia*
# --- harness natives: unpacked runtime payloads, never configuration ----------
# 2.4 MB of tree-sitter grammars the harness unpacks into the repo root on first use (measured
# on blankpad 2026-10-21). Regenerable, unreviewable, not config — every clause of AGENTS.md's
# runtime-state doctrine applies, and it sat unnamed in the root, one `git add -A` away from
# being committed as if it were configuration.
natives/
@@ -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.
+40 -8
View File
@@ -86,29 +86,61 @@ never committed, only surfaced. Conflicts abort and alert (ntfy); it never
force-pushes, auto-resolves, or uses `--no-verify`. Details:
[omp-docs/tools/live-sync.md](omp-docs/tools/live-sync.md).
## Agent skills
The engineering skills (`triage`, `to-tickets`, `to-spec`, `research`,
`implement`, …) read their per-repo configuration from three pages in the
knowledge base. Those pages own their facts; this is only the pointer.
### Issue tracker
See [`omp-docs/skills/issue-tracker.md`](omp-docs/skills/issue-tracker.md).
### Triage labels
See [`omp-docs/skills/triage-labels.md`](omp-docs/skills/triage-labels.md).
### Domain docs
ADRs: `omp-docs/decisions/`. `omp-docs/glossary.md` is intentionally not created.
See [`omp-docs/skills/domain-docs.md`](omp-docs/skills/domain-docs.md).
## 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)
| box | role | GPU | notes |
|-----|------|-----|-------|
| Dev workstation | client box, never runs inference | laptop iGPU + dGPU (non-unified) | mainline llama.cpp at `~/GitPackages/llama.cpp/build/bin/`; this repo lives here at `~/.omp` |
| Dev workstation (`blankpad`) | client box, never runs inference | Lenovo laptop, AMD Ryzen 5 PRO 5650U / Cezanne APU (**unified memory**, s2idle sleep only, no dGPU) | this repo lives here at `~/.omp`; the earlier "iGPU + dGPU / `~/GitPackages/llama.cpp`" description belonged to the sold `dankware` box |
| Dankclaw | bench target, secondary models + service stack | Strix Halo APU (**unified memory**) + RTX 3090 (moved over from Dankserver 2026-09) | prod services `frank-dragoon` (halogen-flash-server, :7770, iGPU) and `frank-zealot` (llama.cpp PrismML fork, :7771, 3090); runbooks in the serverAppConfigs vault (`~/Projects/serverAppConfig/serverAppConfigs-docs/LLM/`); ssh: `ssh dankclaw` |
| Dankserver | main-model box | 2× Radeon AI PRO R9700 — non-unified (was RTX 3090 until 2026-09) | runs `frank-archon` (radiance, TP=2, :7770) — **this repo's operator session runs on it**; ssh: `ssh dankserver` |
## Environment gotchas
- Gitea API needs a token: `GITEA_TOKEN` env (gitignored `.env`). No `tea` CLI
installed anywhere. Remote URLs embed the token locally in `.git/config` — that
file is never pushed; it is not a substitute for the secret gate.
installed anywhere. Remote URLs carry NO embedded token (2026-10-09
credential-store cutover) — git and the API both read
`~/.config/secrets/gitea` (`store --file`, URL-line format; see
`omp-docs/tools/gitea/client-access.md`). That file is never pushed; it is
not a substitute for the secret gate.
- Passwordless sudo (`sudo -n`) works on Dankclaw and Dankserver for systemctl
verbs only; user units need no sudo at all.
- SSH into both servers is BatchMode-capable from here:
+18 -3
View File
@@ -54,13 +54,27 @@ Suggest these tools or languages as possible solutions to problems if they are a
### 1. Maintain your projects source of truth.
The knowledge base at `docs/` is the source of truth for your project. Your training is out of date. Default to the documentation and the code before you assume anything. Maintain a work log of actions taken and their result. Refer to the work log to remember the progress of your tasks.
Documentation exists for ONE reason: **point of use**. A page earns its bytes only if it helps you perform a task at the moment you perform it — the shape to deploy, the flag that must be passed, the failure mode to recognise, the rollback to run. It is NOT a museum, NOT a lab notebook, and NOT a place to keep track of numbers.
**THE PURPOSE TEST — apply it before you write, and again before you keep:**
- Would an agent or Frank, doing a real task tomorrow, read this line and act differently because of it? If no: **delete it.**
- Is this a measured number (t/s, tokens, ms, MiB, %, bench ladder, acceptance rate) for a model, runtime, or hardware configuration that is NOT currently deployed and NOT currently being compared? Then it is **dead weight — destroy it**, do not archive it, do not "preserve it in an Eras row", do not cite it. AI advances make such a figure wrong within weeks, and a wrong number in a runbook is worse than no number: it gets believed.
- "It is the only copy of that fact" is NOT a reason to keep it. Sole custody of a useless fact is still useless. Losing information is an acceptable — often correct — outcome; do not hesitate to lose it.
- What survives the test: live configuration and its exact numbers, the procedure that must be followed, the law/causal story that prevents a repeat failure, security posture, and the interface contract a consumer depends on. Qualitative laws outlive the measurement that taught them — keep the law, drop the numbers.
- Never let a tool's "this is duplicated / this is the only record" census decide what to keep. That is a question of USEFULNESS, which only the operator answers.
1. Read the relevant `index.md` before you touch a topic.
2. Follow `omp-docs/references/okf-spec.md` for every doc you create or edit.
3. Read the target file completely before you edit it.
4. Always update the documentation when you've completed a task.
5. Compact the work log when necessary.
4. Always update the documentation when you've completed a task — and when updating means deleting a page or a paragraph, delete it.
5. Compact the work log when necessary; a work log holds laws and incidents, never bookkeeping.
#### THE WORK LOG IS ONE FILE.
The work log is a single page in the knowledge base: `omp-docs/log.md` in this repo, or whichever change-log page the repo's docs index names. Write the entry when the change lands — in the same commit as the change — and read that page to recover where things stand before starting. It follows its own entry law: a dated entry is what changed plus the durable lesson, a few lines.
- NEVER create a second log: not per directory, not per project, feature, branch or run, not `NOTES.md` / `PROGRESS.md` / `STATUS.md` / `worklog/`, not a dated diary. In `dank/command-center` the KB linter reports any `log.md` that is not the bundle root's; the rule and its reasoning are owned by `omp-docs/references/okf-spec.md`.
- A log holds laws and incidents, never bookkeeping: no per-file inventories, no run-by-run numbers, no transcripts. A number in a log still has to pass the purpose test above.
- If a knowledge base has no log page, add one at its root and list it in the index. That is the fix — never a log somewhere else.
#### USE PROGRESSIVE DISCLOSURE.
Always ensure that all produced documentation is laid out in files following WIKI conventions: start with a top level README.md index file that provides a brief, concise introduction to the item being documented, and then SPLIT OFF individual aspects, features, or arguments into their own interlinked pages so that future agents can read the documentation in a piecewise fashion, only taking in the details they need. Where relevant, crosslink any created documentation with other relevant documentation. This structure may also be nested, if necessary.
@@ -90,6 +104,7 @@ Never assume the user reports correct information. He is human, and humans make
- Before starting new work, `git pull` from the remote, and start a new branch in a separate worktree using `master` or `main` as the base branch.
- Commit atomically: after every change that forms a complete functional unit, commit. Do so early and often.
- When the work is complete, ask the user if they would like to open a pull request.
- **Report a pull request as link AND title, every time.** A bare `#54` is not a reference: numbers are per-repository, so a number alone does not say which work, in which repo, is being reported. Give the PR's web URL on that forge — on the Dankclaw Gitea that is `https://gitea.franksdank.cloud/<owner>/<repo>/pulls/N` — together with the PR's title. Applies everywhere a PR is mentioned: chat replies, status summaries, issue and commit bodies, alerts. Another forge (GitHub, GitLab, …): the equivalent URL its web UI serves, same pairing.
### 5. Test Driven Development
+17 -4
View File
@@ -1,6 +1,10 @@
providers:
openrouterVariant: online
fetch: auto
maxInFlightRequests:
llamacpp (dankserver): 3
llamacpp (dankclaw): 2
llamacpp (dankclaw-3090): 3
modelRoles:
slow: llamacpp (dankclaw)/frank-dragoon:medium
vision: llamacpp (dankclaw-3090)/frank-zealot:xhigh
@@ -64,7 +68,7 @@ retry:
- llamacpp (dankclaw)/frank-dragoon
maxRetries: 10
searxng:
endpoint: search.franksdank.cloud
endpoint: http://192.168.0.143:8081
exa:
enabled: false
memory:
@@ -145,17 +149,25 @@ display:
subagentLivePreview: true
task:
showResolvedModelBadge: true
maxConcurrency: 3
maxConcurrency: 2
agentModelOverrides:
designer: llamacpp (dankclaw-3090)/frank-zealot
librarian: llamacpp (dankclaw-3090)/frank-zealot
reviewer: llamacpp (dankserver)/frank-archon
scout: llamacpp (dankserver)/frank-archon
scout: llamacpp (dankclaw-3090)/frank-zealot
security-reviewer: llamacpp (dankclaw)/frank-dragoon
sonic: llamacpp (dankclaw-3090)/frank-zealot
task: llamacpp (dankserver)/frank-archon
seance: llamacpp (dankclaw-3090)/frank-zealot
eager: default
enableLsp: true
maxRuntimeMs: 1800000
softRequestBudget: 120
maxRecursionDepth: 1
disabledAgents: []
isolation:
enabled: true
completionProbe: false
snapcompact:
systemPrompt: none
toolResults: false
@@ -203,7 +215,7 @@ vault:
tts:
localVoice: bm_george
speech:
enabled: true
enabled: false
mode: all
enhanced: true
voice: am_fenrir
@@ -232,6 +244,7 @@ followUpMode: one-at-a-time
tools:
approvalMode: yolo
format: auto
maxTimeout: 600
marketplace:
autoUpdate: notify
bash:
-626
View File
@@ -1,626 +0,0 @@
/**
* dankvoice — cloned-voice vocalization for omp, served by frank-tts.
*
* The built-in speak-aloud feature (`speech.enabled` / `speech.voice`) and the
* `omp say` CLI are gated to the bundled Kokoro voice enum, so the cloned
* voices on frank-tts (Dankclaw :7772, the `speech` model role in
* agent/models.yml) could only ever produce files. This extension adds the
* missing path — the TUI speaks with the cloned voices:
*
* /say open the options menu
* /say <text> speak once through the speakers
* /say on | off arm/disarm auto-speak of the assistant's final answer
* /say menu the options menu (voice, output device, player, cap)
* /say voice <name> choose a voice (empty = whatever the server defaults to)
* /say voices list the voices frank-tts reports
* /say stop cut off the current utterance
* /say status voice, endpoint, player and device in use
* /say help the command list
*
* The endpoint comes from agent/models.yml — the provider block carrying
* `api: openai-speech`, i.e. the same row the speech role resolves — so this
* file carries no host address and no secret. Menu choices persist to
* ~/.omp/cache/dankvoice.json (runtime state, never tracked). Environment
* overrides win over the file, which wins over defaults:
*
* DANKVOICE_URL speech base URL (…/v1) instead of models.yml
* DANKVOICE_MODEL served model id (default: the id in that block)
* DANKVOICE_CONFIG settings file (default ~/.omp/cache/dankvoice.json)
* DANKVOICE_PLAYER player binary, skipping detection
* DANKVOICE_VOICE voice name ("" = server default)
* DANKVOICE_MAX spoken-character cap per utterance (default 700)
* DANKVOICE_AUTO=1 start with auto-speak armed
*
* Utterances are synthesized to a temp WAV in $TMPDIR; the file is removed
* when its player process exits. A new utterance interrupts the previous one
* instead of queueing behind it. Output routing: a saved sink becomes
* `pw-play --target <sink>` or `paplay --device=<sink>` (ffplay has no device
* concept and ignores it); with no saved sink the player's own default applies.
*/
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
const DEFAULT_MAX_CHARS = 700;
const SYNTH_TIMEOUT_MS = 60_000;
const VOICES_TIMEOUT_MS = 15_000;
/** A voice name the shim must reject; its 400 body carries the real list. */
const VOICE_PROBE = "_dankvoice_probe_";
// ---------------------------------------------------------------- pure logic
export interface SpeechTarget {
/** POST target for OpenAI speech synthesis. */
speechUrl: string;
/** GET target for the voice roster (frank-tts answers a bare JSON array). */
voicesUrl: string;
/** GET target for the liveness route of the same host:port. */
healthUrl: string;
/** Served model id to name in the request body. */
model: string;
}
export interface Player {
command: string;
args: string[];
}
export type SayAction =
| { kind: "menu" }
| { kind: "help" }
| { kind: "speak"; text: string }
| { kind: "on" }
| { kind: "off" }
| { kind: "stop" }
| { kind: "voices" }
| { kind: "status" }
| { kind: "voice"; name: string };
/** `/say <args>` grammar: subcommands win, anything else is spoken text. */
export function parseSayArgs(raw: string): SayAction {
const args = raw.trim();
const lower = args.toLowerCase();
if (lower === "" || lower === "menu") return { kind: "menu" };
if (lower === "help") return { kind: "help" };
if (lower === "on") return { kind: "on" };
if (lower === "off") return { kind: "off" };
if (lower === "stop") return { kind: "stop" };
if (lower === "voices") return { kind: "voices" };
if (lower === "status") return { kind: "status" };
if (lower === "voice" || lower.startsWith("voice ")) return { kind: "voice", name: args.slice(5).trim() };
return { kind: "speak", text: args };
}
/**
* Pull the speech endpoint out of agent/models.yml. The runner API lives on
* the MODEL row (`api: openai-speech`), the base URL on its provider, so this
* scan remembers the last provider base URL and model id seen and returns when
* a model row claims the speech runner API. A new provider key (indent two)
* resets both.
*/
export function resolveSpeechEndpoint(modelsYml: string): SpeechTarget | undefined {
let base: string | undefined;
let id: string | undefined;
for (const line of modelsYml.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
if (/^ {2}\S.*:\s*$/.test(line)) {
base = undefined;
id = undefined;
continue;
}
const providerUrl = trimmed.match(/^baseUrl:\s*(\S+)/);
if (providerUrl) {
base = providerUrl[1].replace(/\/+$/, "");
continue;
}
const modelId = trimmed.match(/^-\s*id:\s*(\S+)/);
if (modelId) {
id = modelId[1];
continue;
}
if (/^api:\s*openai-speech\s*$/.test(trimmed) && base) {
return {
speechUrl: `${base}/audio/speech`,
voicesUrl: `${base}/audio/voices`,
healthUrl: `${base.replace(/\/v1$/, "")}/health`,
model: id ?? "frank-tts",
};
}
}
return undefined;
}
/** OpenAI speech body; `voice` is sent only when named — the shim carries its own default. */
export function speechBody(model: string, text: string, voice: string): Record<string, string> {
const body: Record<string, string> = { model, input: text, response_format: "wav" };
if (voice) body.voice = voice;
return body;
}
/** Voice names as frank-tts reports them: the invalid-voice 400 body carries `available_voices`. */
export function parseVoices(payload: unknown): string[] {
// frank-tts answers GET /v1/audio/voices with a bare array and an unknown voice with
// {error:{available_voices:[…]}}; a flat body is accepted too.
if (typeof payload !== "object" || payload === null) return [];
let list: unknown;
if (Array.isArray(payload)) {
list = payload;
} else if ("error" in payload && typeof payload.error === "object" && payload.error !== null && "available_voices" in payload.error) {
list = payload.error.available_voices;
} else if ("available_voices" in payload) {
list = payload.available_voices;
} else if ("voices" in payload) {
list = payload.voices;
} else if ("data" in payload) {
list = payload.data;
}
if (!Array.isArray(list)) return [];
const names: string[] = [];
for (const entry of list) {
if (typeof entry === "string") {
names.push(entry);
} else if (typeof entry === "object" && entry !== null) {
const name = "id" in entry ? entry.id : "name" in entry ? entry.name : undefined;
if (typeof name === "string") names.push(name);
}
}
return names.filter((name) => name.length > 0);
}
/** Known players, PipeWire first (the workstations run WirePlumber); first existing wins. */
export function availablePlayers(lookup: (command: string) => string | null): string[] {
return ["pw-play", "paplay", "ffplay"].filter((command) => lookup(command) !== null);
}
/** One player invocation: the saved sink becomes each player's routing dialect. */
export function playerFor(command: string, sink: string): Player {
const base = command.split("/").pop() ?? command;
if (base === "ffplay") return { command, args: ["-nodisp", "-autoexit", "-loglevel", "quiet"] };
if (!sink) return { command, args: [] };
if (base === "paplay") return { command, args: [`--device=${sink}`] };
return { command, args: ["--target", sink] };
}
/** What a speech engine should say: prose only, no fences, no markup, bounded. */
export function stripForSpeech(text: string, maxChars: number = DEFAULT_MAX_CHARS): string {
let out = text;
out = out.replace(/```[\s\S]*?```/g, " ");
out = out.replace(/~~~[\s\S]*?~~~/g, " ");
out = out.replace(/`([^`]*)`/g, "$1");
out = out.replace(/!\[[^\]]*\]\([^)]*\)/g, " ");
out = out.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
out = out.replace(/^\s{0,3}#{1,6}\s+/gm, "");
out = out.replace(/^\s*[-*+]\s+/gm, "");
out = out.replace(/^\s*\|[-: |]+\|\s*$/gm, " ");
out = out.replace(/\|/g, " ");
out = out.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, "$1");
out = out.replace(/\s+/g, " ").trim();
if (out.length <= maxChars) return out;
const window = out.slice(0, maxChars);
const cut = Math.max(window.lastIndexOf(". "), window.lastIndexOf("! "), window.lastIndexOf("? "));
const clipped = cut > maxChars / 2 ? window.slice(0, cut + 1) : window.replace(/[,;:]\s*\S*$/, "");
return `${clipped.trim()} …`;
}
/** The text the assistant last actually said; tool-only messages do not count. */
export function lastAssistantText(messages: readonly unknown[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!message || typeof message !== "object" || !("role" in message) || message.role !== "assistant") continue;
if (!("content" in message)) continue;
const content = message.content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) continue;
const parts: string[] = [];
for (const part of content) {
if (part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
parts.push(part.text);
}
}
const joined = parts.join("\n").trim();
if (joined) return joined;
}
return "";
}
/** Menu choices, persisted between sessions; every field optional. */
export interface Settings {
voice?: string;
player?: string;
sink?: string;
maxChars?: number;
auto?: boolean;
}
/** Where menu choices live: DANKVOICE_CONFIG, else ~/.omp/cache/dankvoice.json. */
export function settingsPath(env: NodeJS.ProcessEnv): string {
return env.DANKVOICE_CONFIG?.trim() || join(env.HOME ?? "", ".omp", "cache", "dankvoice.json");
}
export function readSettings(path: string): Settings {
try {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const raw = parsed as Record<string, unknown>;
const settings: Settings = {};
if (typeof raw.voice === "string") settings.voice = raw.voice;
if (typeof raw.player === "string") settings.player = raw.player;
if (typeof raw.sink === "string") settings.sink = raw.sink;
if (typeof raw.maxChars === "number" && Number.isFinite(raw.maxChars) && raw.maxChars > 0) settings.maxChars = Math.floor(raw.maxChars);
if (typeof raw.auto === "boolean") settings.auto = raw.auto;
return settings;
} catch {
return {};
}
}
/** A sink row from `wpctl list audio sinks`: `116<TAB>alsa_output…<TAB>audio/sink<TAB>*`. */
export interface SinkRow {
id: string;
name: string;
}
export function parseSinkList(output: string): SinkRow[] {
const rows: SinkRow[] = [];
for (const line of output.split("\n")) {
const cols = line.split("\t").map((col) => col.trim());
if (cols.length < 3 || !cols[2]?.includes("audio/sink")) continue;
const id = cols[0] ?? "";
const name = cols[1] ?? "";
if (!id || !name) continue;
rows.push({ id, name });
}
return rows;
}
// ------------------------------------------------------------- live wiring
interface Config {
target?: SpeechTarget;
player?: Player;
sink: string;
maxChars: number;
auto: boolean;
voice: string;
}
function lookupOnPath(command: string): string | null {
const found = spawnSync("sh", ["-c", `command -v ${command}`], { encoding: "utf8" });
return found.status === 0 && found.stdout.trim() ? found.stdout.trim() : null;
}
function readModelsYml(): string {
const path = process.env.DANKVOICE_MODELS ?? join(process.env.HOME ?? "", ".omp", "agent", "models.yml");
try {
return readFileSync(path, "utf8");
} catch {
return "";
}
}
function loadConfig(): Config {
const env = process.env;
const settings = readSettings(settingsPath(env));
const overrideUrl = env.DANKVOICE_URL?.trim().replace(/\/+$/, "");
const modelOverride = env.DANKVOICE_MODEL?.trim();
const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(readModelsYml());
const target: SpeechTarget | undefined = overrideUrl
? {
speechUrl: `${overrideUrl}/audio/speech`,
voicesUrl: `${overrideUrl}/audio/voices`,
healthUrl: `${overrideUrl.replace(/\/v1$/, "")}/health`,
model: modelOverride || "frank-tts",
}
: fromYml
? { ...fromYml, model: modelOverride || fromYml.model }
: undefined;
const playerCommand = env.DANKVOICE_PLAYER?.trim() || settings.player || availablePlayers(lookupOnPath)[0];
const sink = settings.sink ?? "";
const maxChars = Number(env.DANKVOICE_MAX);
return {
target,
player: playerCommand ? playerFor(playerCommand, sink) : undefined,
sink,
maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : (settings.maxChars ?? DEFAULT_MAX_CHARS),
auto: env.DANKVOICE_AUTO === "1" || (settings.auto ?? false),
voice: env.DANKVOICE_VOICE?.trim() ?? settings.voice ?? "",
};
}
/** PipeWire sinks via wpctl; empty when wireplumber is unreachable. */
function listSinks(): SinkRow[] {
const probe = spawnSync("wpctl", ["list", "audio", "sinks"], { encoding: "utf8" });
return probe.status === 0 ? parseSinkList(probe.stdout) : [];
}
/** Synthesize one utterance into a WAV inside `dir`; throws with the endpoint's own message. */
async function synthesize(target: SpeechTarget, body: Record<string, string>, dir: string, timeoutMs: number): Promise<string> {
const res = await fetch(target.speechUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
const contentType = res.headers.get("content-type") ?? "";
if (!res.ok || contentType.includes("json")) {
let message = `frank-tts HTTP ${res.status}`;
try {
const payload: unknown = await res.json();
if (
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof payload.error === "object" &&
payload.error !== null &&
"message" in payload.error &&
typeof payload.error.message === "string"
) {
message = payload.error.message;
}
} catch {
// keep the status-line message
}
throw new Error(message);
}
const bytes = new Uint8Array(await res.arrayBuffer());
if (bytes.byteLength === 0) throw new Error("frank-tts returned no audio");
const file = join(dir, `say-${Date.now()}.wav`);
writeFileSync(file, bytes);
return file;
}
/** frank-tts's roster route first; if it ever answers nothing, ask with a voice it must reject. */
async function fetchVoices(target: SpeechTarget): Promise<string[]> {
const roster = await fetch(target.voicesUrl, { signal: AbortSignal.timeout(VOICES_TIMEOUT_MS) })
.then((res) => res.json())
.catch(() => undefined);
const listed = parseVoices(roster);
if (listed.length > 0) return listed;
const probe = await fetch(target.speechUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(speechBody(target.model, "voice list probe", VOICE_PROBE)),
signal: AbortSignal.timeout(VOICES_TIMEOUT_MS),
}).catch(() => undefined);
if (!probe) return [];
return parseVoices(await probe.json().catch(() => undefined));
}
/** Play a WAV and clean its temp directory up when the player exits. */
function startPlayback(player: Player, file: string, dir: string): ChildProcess {
const child = spawn(player.command, [...player.args, file], { stdio: "ignore" });
const clean = () => {
try {
rmSync(file, { force: true });
rmSync(dir, { recursive: true, force: true });
} catch {
// temp files are not worth a toast
}
};
child.once("exit", clean);
child.once("error", clean);
return child;
}
export default function dankvoice(pi: ExtensionAPI) {
const cfg = loadConfig();
const state: { auto: boolean; voice: string; child?: ChildProcess; lastCtx?: ExtensionContext } = {
auto: cfg.auto,
voice: cfg.voice,
};
const voiceLabel = () => state.voice || "server default";
const reflectStatus = () => state.lastCtx?.ui.setStatus("dankvoice", state.auto ? `🔊 ${voiceLabel()}` : undefined);
const stop = () => {
if (state.child && state.child.exitCode === null) state.child.kill("SIGTERM");
state.child = undefined;
};
const missingTarget = () => "dankvoice: no `api: openai-speech` provider in agent/models.yml (set DANKVOICE_URL)";
const persistSettings = (ctx: ExtensionContext) => {
const settings: Settings = {
voice: state.voice,
player: cfg.player?.command ?? "",
sink: cfg.sink,
maxChars: cfg.maxChars,
auto: state.auto,
};
try {
writeFileSync(settingsPath(process.env), `${JSON.stringify(settings, null, 2)}\n`);
} catch (error) {
ctx.ui.notify(`dankvoice: settings not saved: ${error instanceof Error ? error.message : String(error)}`, "warning");
}
};
const setVoice = (ctx: ExtensionContext, name: string) => {
state.voice = name;
reflectStatus();
ctx.ui.notify(name ? `dankvoice: voice = ${name}` : "dankvoice: voice = whatever frank-tts defaults to", "info");
persistSettings(ctx);
};
const setAuto = (ctx: ExtensionContext, on: boolean) => {
state.auto = on;
if (!on) stop();
reflectStatus();
ctx.ui.notify(
on ? `dankvoice: auto-speak armed (${voiceLabel()} @ ${cfg.target?.speechUrl ?? "no endpoint"})` : "dankvoice: auto-speak off",
"info",
);
persistSettings(ctx);
};
async function speak(ctx: ExtensionContext, text: string) {
const spoken = stripForSpeech(text, cfg.maxChars);
if (!spoken) return;
if (!cfg.target) {
ctx.ui.notify(missingTarget(), "error");
return;
}
if (!cfg.player) {
ctx.ui.notify("dankvoice: no audio player found (pw-play, paplay or ffplay)", "error");
return;
}
stop();
const dir = mkdtempSync(join(tmpdir(), "dankvoice-"));
try {
const file = await synthesize(cfg.target, speechBody(cfg.target.model, spoken, state.voice), dir, SYNTH_TIMEOUT_MS);
state.child = startPlayback(cfg.player, file, dir);
} catch (error) {
stop();
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// ignore
}
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
async function openMenu(ctx: ExtensionContext): Promise<void> {
const options = [
`Auto-speak: ${state.auto ? "on" : "off"} (toggle)`,
`Voice: ${voiceLabel()} (change)`,
`Output device: ${cfg.sink || "system default"} (change)`,
`Player: ${cfg.player?.command ?? "none"} (change)`,
`Spoken cap: ${cfg.maxChars} chars (change)`,
"Speak a test phrase",
];
const choice = await ctx.ui.select("dankvoice — options", options);
if (!choice) return;
if (choice === options[0]) {
setAuto(ctx, !state.auto);
return;
}
if (choice === options[1]) {
if (!cfg.target) {
ctx.ui.notify(missingTarget(), "error");
return;
}
let voices: string[];
try {
voices = await fetchVoices(cfg.target);
} catch (error) {
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error");
return;
}
const picked = await ctx.ui.select("dankvoice — voice", ["server default", ...voices]);
if (picked === undefined) return;
setVoice(ctx, picked === "server default" ? "" : picked);
return;
}
if (choice === options[2]) {
const picked = await ctx.ui.select("dankvoice — output device", ["system default", ...listSinks().map((sink) => sink.name)]);
if (picked === undefined) return;
cfg.sink = picked === "system default" ? "" : picked;
if (cfg.player) cfg.player = playerFor(cfg.player.command, cfg.sink);
ctx.ui.notify(`dankvoice: output device = ${cfg.sink || "system default"}`, "info");
persistSettings(ctx);
return;
}
if (choice === options[3]) {
const choices = [...new Set([cfg.player?.command, ...availablePlayers(lookupOnPath)].filter((c): c is string => Boolean(c)))];
if (choices.length === 0) {
ctx.ui.notify("dankvoice: no audio player found (pw-play, paplay or ffplay)", "error");
return;
}
const picked = await ctx.ui.select("dankvoice — player", choices);
if (picked === undefined) return;
cfg.player = playerFor(picked, cfg.sink);
ctx.ui.notify(`dankvoice: player = ${picked}`, "info");
persistSettings(ctx);
return;
}
if (choice === options[4]) {
const picked = await ctx.ui.select("dankvoice — spoken cap", ["300", "700", "1500", "3000"]);
const next = Number(picked);
if (!Number.isFinite(next) || next <= 0) return;
cfg.maxChars = next;
ctx.ui.notify(`dankvoice: cap = ${next} chars`, "info");
persistSettings(ctx);
return;
}
await speak(ctx, `dankvoice: ${voiceLabel()} through ${cfg.player?.command ?? "no player"}, checking.`);
}
pi.registerCommand("say", {
description:
"Speak with the fleet's cloned voices (frank-tts): /say (menu) | <text> | on | off | menu | voice <name> | voices | stop | status",
handler: async (args: string, ctx) => {
state.lastCtx = ctx;
const action = parseSayArgs(args);
switch (action.kind) {
case "menu":
await openMenu(ctx);
return;
case "help":
ctx.ui.notify(
"dankvoice: /say (menu) · /say <text> · /say on|off (auto-speak answers) · /say voice <name> · /say voices · /say stop · /say status",
"info",
);
return;
case "on":
setAuto(ctx, true);
return;
case "off":
setAuto(ctx, false);
return;
case "stop":
stop();
return;
case "voice":
setVoice(ctx, action.name);
return;
case "voices": {
if (!cfg.target) {
ctx.ui.notify(missingTarget(), "error");
return;
}
const voices: string[] = [];
try {
voices.push(...(await fetchVoices(cfg.target)));
} catch (error) {
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error");
return;
}
ctx.ui.notify(
voices.length
? `dankvoice voices: ${voices.join(", ")} — using ${voiceLabel()}`
: "dankvoice: frank-tts reported no voice list",
voices.length ? "info" : "warning",
);
return;
}
case "status":
ctx.ui.notify(
`dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · device ${cfg.sink || "system default"} · auto-speak ${state.auto ? "on" : "off"} · cap ${cfg.maxChars} chars`,
"info",
);
return;
case "speak":
await speak(ctx, action.text);
return;
}
},
});
pi.on("agent_end", async (event, ctx) => {
state.lastCtx = ctx;
if (!state.auto) return;
// `willContinue` means the harness already scheduled a continuation
// (auto-retry, empty-stop retry): this is not the user-visible terminal
// settle, so narrating it would speak an answer the model is about to redo.
if (event.willContinue) return;
if (ctx.mode === "print" || ctx.mode === "json") return;
await speak(ctx, lastAssistantText(event.messages));
});
pi.on("session_shutdown", () => {
stop();
});
}
@@ -0,0 +1,64 @@
---
name: amd-apu-vulkan-tenant-onboarding
description: "Add a ggml/Vulkan inference tenant to an AMD APU (gfx1151 / Strix Halo iGPU) that already serves another engine: APU memory census, RADV heap gate, render-node resolution, build-side glslc/ICD deps, co-tenancy measurement."
---
Use when a ggml-based engine (llama.cpp, qwentts.cpp, anything built on ggml) must move from CPU to the **iGPU** of an AMD APU (gfx1151 / Strix Halo / Ryzen AI Max) that **already serves another engine on the same card**. Answers two questions only a booted host can: what gate the second tenant must clear, and which files the build needs to clear it.
## 1. The usual VRAM reasoning is wrong on an APU
`/sys/class/drm/cardN/device/mem_info_vram_total` reports a small **carve-out** (2 GiB class), and `rocm-smi`'s "dedicated" figure is not the working set either. The real working set lives in **GTT / system pages**: `/proc/meminfo` → `GTT` and `System pages`. A second GPU tenant therefore consumes host RAM from the same pool the first tenant's weights and the OS use — no supplementary groups, no separate aperture to reserve.
- Price the tenant from a **booted** footprint, never from `weights + KV + buffers`: on an APU the compute buffers and any projector ride on GTT, which is host RAM; check `free` *after* the load, not before.
- Never subtract a "dedicated" figure from it to find headroom.
- Unheld fabric clock (`pp_dpm_mech` = `auto`) is a hazard only when the NPU joins too; a pure iGPU pair is unaffected — check it before adding a third tenant.
- Swap on a host with GPU tenants protects co-tenants; paging a KV pool is worse than the OOM it prevents.
## 2. The gate that decides feasibility: the RADV heap a container sees
`rocm-smi` and `free` cannot see this number; only a container off the **engine's own base image** can:
```bash
# one-off container, no model, no load, no compute — safe beside production
podman run --rm --device /dev/dri --group-add keep-groups <engine-base> bash -c \
'apt-get update -qq >/dev/null 2>&1; apt-get install -y -qq --no-install-recommends mesa-vulkan-drivers vulkan-tools glslang-tools >/dev/null 2>&1; vulkaninfo --text'
```
Read `VkPhysicalDeviceMemoryProperties` → the `DEVICE_LOCAL` heap size. On a hybrid Strix Halo box Mesa splits the aperture (~1/3 : 2/3), so `DEVICE_LOCAL` is the budget the ggml tool will actually allocate against — measured 77.33 GiB *with the first tenant loaded*, against a 2 GiB DRM carve-out. Two traps, both measured:
- `vulkaninfo --batch` does not exist in Ubuntu 24.04's `vulkan-tools` (prints help, exits 1). Every `vulkaninfo` parse needs `--text`.
- The container may have **no NVIDIA ICD**, so Vulkan index `0` *is* the iGPU — but do not trust ordering by index: `--text` and read `deviceName` + `driverID` (`RADV_GFX1151` vs `NVIDIA`). A hand-picked `/dev/dri/renderDN` is worse than useless on a hybrid host: **`renderD128` there is the NVIDIA card and `renderD129` the amdgpu iGPU**; resolve the node with `readlink -f /sys/class/drm/renderD*/device/driver` and pass `/dev/dri` wholesale, letting the driver pick by name.
- A rootless container that drops its supplementary groups enumerates **nothing**. `--group-add keep-groups` (quadlet: `GroupAdd=keep-groups`) plus the host udev rule that makes the render nodes group-writable is the whole requirement; `--device /dev/dri` alone is not enough.
## 3. Build-side deps: the configure fails before the device ever matters
ggml's `src/ggml-vulkan/CMakeLists.txt` requires, in order: `find_package(Vulkan COMPONENTS glslc REQUIRED)` then `find_package(SPIRV-Headers CONFIG REQUIRED)`, and it compiles its shaders with **glslc**, never `glslangValidator`. On Ubuntu 24.04 (measured against the digest-pinned `ubuntu:24.04` base, 2026-10-19):
- `glslang-tools` ships `glslangValidator` and **NOT `glslc`** → `command -v glslc` empty, `find_package(Vulkan … glslc REQUIRED)` fails, and the Vulkan backend silently never exists.
- The package is the standalone **`glslc`** (universe, `2023.8-1build1`). Working build-stage set: `libvulkan-dev glslc spirv-headers` — `libvulkan-dev` provides `vulkan/vulkan.h` + the `VulkanHeaders`/`VulkanLoader` CMake configs `find_package(Vulkan)` resolves to, `spirv-headers` ships `/usr/share/cmake/SPIRV-Headers/SPIRV-HeadersConfig.cmake`. A configure probe in the pinned base prints `Found Vulkan: … found components: glslc` then `Including Vulkan backend`.
- **The archive's glslc is old.** 2023.8 compiles `GL_KHR_cooperative_matrix` (the path ggml's vectored quantized matmul needs) but reports `cooperative_matrix2`, `integer_dot_product`, `bfloat16`, `float_e2m1/e4m3` as unsupported, so those shader families are compiled out. If a Vulkan prefill reads as inexplicably slow, suspect the **shader compiler before the device**; the fix is a pinned glslang build passed as `-DVulkan_GLSLC_EXECUTABLE=<path>`, not a different runtime.
- Runtime stage needs the loader **and an ICD**: `libvulkan1` + `mesa-vulkan-drivers`. Loader-without-ICD enumerates zero devices, and ggml-based servers die in device lookup — often *after* reading the weights, so the journal reads like a model fault.
- Ship **no NVIDIA ICD** on purpose: one Vulkan device in the enumeration is what makes the *name* `Vulkan0` safe to write into config. Prefer the name over an index in every flag.
## 4. Prove enumeration without allocating anything
Do not boot a scratch second engine beside production to ask which devices exist — a second model load is a real GPU tenant, and fleet law stops that. Instead run the binary with a device name that cannot exist and read its error; it names what it enumerated, with no allocation and no model resident:
```bash
podman run --rm --device /dev/dri --group-add keep-groups --entrypoint /app/tts-server <image> \
--model /dev/null --codec /dev/null --device NoSuchDevice 2>&1 | tail -20
```
## 5. The rollback must be a flag, not a rebuild
Put the device in the env file as a value the entrypoint passes only when set, and make **set-but-empty** mean "pass no flag at all" — the binary's own default (CPU, always built) is the rollback. `: "${VAR:=default}"` is the WRONG form: `:=` fires on an empty value too, so the documented rollback line `VAR=` is silently rewritten back to the GPU and the only way out is a rebuild. Use:
```bash
if [[ ! -v QWENTTS_DEVICE ]]; then QWENTTS_DEVICE=Vulkan0; fi
```
(`podman`'s `--env-file` passes `VAR=` through as set-and-empty, and `source` does the same, so both readers agree.) Pin it in a test: empty device ⇒ the flag is absent from argv.
## 6. Co-tenancy measurement (only after the tenant is live)
The gate is the **first tenant's** performance with a real request from the second in flight — not the second tenant's own speed. And price the tenant from a booted footprint, never from weights+KV+buffers: on an APU the compute buffers and any projector ride on GTT, which is host RAM; check `free` after the load, not before.
@@ -0,0 +1,113 @@
---
name: authentik-rp-sso-login-diagnosis
description: "Diagnose/repair a dead OAuth2 relying-party login against authentik (Grafana-style): authorize 500, missing email claim, Grafana /emails fallback. Live probe harness + ak-shell gotchas."
---
# Authentik OAuth2 relying-party (RP) SSO login diagnosis
Use when a vhost authenticates through authentik via the OIDC authorization-code
flow (Grafana, Seerr OIDC, any "Sign in with <authentik>" button) and the login
fails — even though the guard-chain e2e (Anubis + outpost) passes green.
## Key insight: the edge test lies
The outpost-guard spec (e.g. `13-sso`) only proves an authenticated session
reaches the app's *login page*. It never follows the RP OAuth button. So the RP
login can be 100% dead and the suite still passes. **Always drive the real RP
round-trip** to get ground truth (harness below).
## Symptom → cause map (all verified live on 2026-10-20, grafana)
| Signature | Root cause | Fix |
|---|---|---|
| Browser hits `…/application/o/authorize/?client_id=…` → "Server Error — Powered by authentik" (500), traceback `AttributeError: 'NoneType' object has no attribute 'slug'` in `flows/planner.py` | provider `authorization_flow` is NULL (raw `ak shell` `create()` leaves it unset) | set `authorization_flow = default-provider-authorization-implicit-consent` |
| Grafana log `logger=oauth.generic_oauth level=error msg="Error getting email address" url=<api_url>/emails … 404/403` | the granted scope mapping returns a **bare string**, not a dict → no `email` claim emitted | fix the ScopeMapping expression to `return {"email": user.email, "email_verified": True}` |
| authorize 500 on scope-overlap, log `Application requested scopes not configured` for a scope like `user:email` | client requested a scope authentik doesn't define (GitLab-ism) | client must request authentik's real scopes: `openid email profile` |
| Grafana `?api=1` on API URL → GET `<…>/userinfo/?api=1/emails` → 403 | Grafana v13 appends `/emails` to a `?api=1` URL | use the standard `…/userinfo/` with **no `?api=1`** |
Crucial causal note: **Grafana v13 GETs `<api_url>/emails` for the address ONLY
when the token carries no `email` claim.** So the `/emails` 404 is a *symptom of
the missing claim*, not a Grafana misconfig. Do NOT chase it with `USE_ID_TOKEN`
or attribute paths; fix the claim. `USE_ID_TOKEN=true` does NOT stop the
`/emails` fetch. Once the claim exists, standard userinfo carries `email` and no
`USE_ID_TOKEN` is needed.
## Live RP-login probe harness (ground truth)
A `qa-e2e` authentik session storage state exists at
`tests/e2e/.auth/qa-e2e.json` after an e2e run; the e2e node_modules live in the
main checkout (`tests/e2e/`). Throwaway probe:
```js
// node _p.cjs (run from a checkout with node_modules + .auth)
const { chromium } = require('playwright');
(async () => {
const b = await chromium.launch({ headless: true });
const ctx = await b.newContext({ storageState: '.auth/qa-e2e.json',
userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36" });
const p = await ctx.newPage();
await p.goto('https://<vhost>/', { waitUntil: 'domcontentloaded' });
for (let i=0;i<30;i++){ const t=await p.locator('body').innerText().catch(()=>''); if(!t.includes('not a bot')) break; await p.waitForTimeout(3000);}
await p.goto('https://<vhost>/login/generic_oauth', { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(9000);
const r = await ctx.request.get('https://<vhost>/api/user');
console.log(p.url(), r.status(), (await r.text()).slice(0,200));
await b.close();
})();
```
Success = final URL on the app host (not `/login`) AND `/api/user` 200 with an
email. Before the fix it is `/login` + 401. Move the throwaway to
`~/.AGENTTRASH/…` after (never `rm`).
## ak-shell census: operational traps (authentik 2026.8)
- **Run it via `ssh dankclaw bash -s <<'EOF' … EOF`, NEVER direct
`ssh host '… "{{…}}"'`.** Direct ssh eats inner quotes locally; `podman inspect
--format '{{…}}'` and multi-line `ak shell -c "…"` only survive inside a quoted
heredoc. Authentik runs **rootless** (`podman exec authentik-server …`, no sudo).
- `OAuth2Provider` has **no `properties` attribute** in 2026.8; scopes are derived
from `p.property_mappings`. Accessing `.properties` raises and silently aborts
the whole `-c` payload.
- `ScopeMapping` is **not importable** from `authentik.core.models`. To read
scopes: iterate `p.property_mappings.all()`, then `m.scopemapping.scope_name`
(reverse one-to-one; the base `PropertyMapping` row has no `scope_name`).
- Introspect before guessing: `[f.name for f in Model._meta.get_fields()]`. Don't
trial-and-error attribute names — that wastes turns.
- Claims-into-id_token field is `include_claims_in_id_token`.
- Provider census that works:
```
from authentik.providers.oauth2.models import OAuth2Provider
for p in OAuth2Provider.objects.all():
af=p.authorization_flow
print(p.name, af.slug if af else None)
# email mapping shape (the real bug):
p=OAuth2Provider.objects.filter(name='grafana').first()
for m in p.property_mappings.all():
print(m.name, getattr(getattr(m,'scopemapping',None),'scope_name',None), repr(getattr(m,'expression','')))
```
## Repair = idempotent script (DB state, not deployable)
Authentik config lives in its DB, not in any repo file; a blueprint re-import or
an `ak shell` re-creation reverts provider fields silently. Ship an idempotent
`scripts/authentik-<svc>-sso-fix.sh` that `ssh HOST 'podman exec -i
authentik-server ak shell' <<'PYEOF' … PYEOF` and re-applies each fact guarded by
"already set?" checks. Match the style of
`scripts/authentik-set-login-session-duration.sh`; add a header note "DB state —
re-run after a rebuild". Reference it from the service page. Grafana's half
(env) lives in `grafana.container` and deploys normally.
## Repo test-seam gotchas this triggers
- **literal-secret seam** (`observability_quadlet.bats`, "no literal secret")
globs keys `*_SECRET|*_PASSWORD|*_TOKEN|*_API_KEY` and demands a `${VAR}`
value. A boolean env ending in `_TOKEN` (`…_USE_ID_TOKEN=true`) is a **false
positive**. Prefer not to set such a key (drop `USE_ID_TOKEN`); otherwise the
seam must be refined (ask the owner before editing a pre-existing test).
- Quadlet `Environment=` **word-splits on spaces** (systemd semantics) — a spaced
list must be quoted: `Environment="KEY=a b c"`, else it truncates to `a`. The
repo already uses this (`open-webui.container` OAUTH_SCOPES).
- **OKF conformance gate** rejects a page frontmatter `timestamp` in the future.
The fleet host clock reads UTC (often a day ahead of local "today") — set the
timestamp to the repo's today, not the host's UTC date.
@@ -0,0 +1,34 @@
---
name: authentik-session-ttl-query
description: "Query or change authentik login-session TTL on Dankclaw via ak shell — the string-typed UserLoginStage.session_duration trap, the apply logic, and why SESSION_SAVE_EVERY_REQUEST is unavailable."
---
Change or audit how long an authentik login session lives (Dankclaw `authentik-server`, build 2026.x). The real knob is a DB stage row, not env — and the field format will bite you.
## Where the TTL actually lives
- Run program through raw stdin: `ssh dankclaw 'podman exec -i authentik-server ak shell' <<'EOF' ... EOF 2>&1 | sed -n '/objects imported/,$p'`.
- Get models by label, NOT import paths (`from authentik.core.models.setting import ...` fails; `authentik.core.models` is not a package; GlobalSettings is NOT a model in 2026.x):
`from django.apps import apps; ULS = apps.get_model("authentik_stages_user_login", "UserLoginStage")`
- The password sign-in path is flow `default-authentication-flow` → stage `default-authentication-login` (FlowStageBinding FK is `target`, not `flow`). Other stages `default-source-authentication-login` / `default-source-enrollment-login` govern external-IdP/enrollment logins only.
## THE FORMAT TRAP (will break login if wrong)
`UserLoginStage.session_duration` is **string-typed** in authentik's own idiom: `"days=7"` / `"seconds=2"`.
- Assigning a `datetime.timedelta` stores Python's `str(timedelta)` = `'7 days, 0:00:00'`, which authentik's parser `authentik.lib.utils.time.timedelta_from_string` **REJECTS** (`ValueError: not enough values to unpack`). Left like that, login crashes computing the expiry.
- Correct write: `s.session_duration = "days=7"; s.save(update_fields=["session_duration"])`
- Verify it parses: `from authentik.lib.utils.time import timedelta_from_string as P; P(s2.session_duration)` → `7 days, 0:00:00`. A single `key=value` token only; `"days=7 seconds=0"` also errors.
## How it applies (stage.py:77-87, verified)
`delta = timedelta_from_string(session_duration)`; `if remember: delta += remember_me_offset`; then `set_expiry(0) if delta==0 else set_expiry(delta)`. So any positive duration applies to EVERY login (remember-me only adds on top), and `set_expiry(<positive>)` overrides `SESSION_EXPIRE_AT_BROWSER_CLOSE` → persistent cookie. Existing sessions keep their old expiry; new logins only.
## SESSION_SAVE_EVERY_REQUEST (sliding window) is NOT available
`/authentik/root/settings.py` reads `SESSION_COOKIE_AGE` from `CONFIG.get("sessions.unauthenticated_age","days=1")`, sets `SESSION_EXPIRE_AT_BROWSER_CLOSE=True`, and references **`SESSION_SAVE_EVERY_REQUEST` nowhere** → no `AUTHENTIK_*` env or config.yml key can flip it. Enabling a sliding window means patching the vendor image; instead raise the login-stage duration (absolute, supported).
## Audit the rest of the expiry surface (live values)
- framework: `settings.SESSION_COOKIE_AGE`, `SESSION_SAVE_EVERY_REQUEST`
- OAuth2 RPs: `apps.get_model("authentik_providers_oauth2","OAuth2Provider")` → `access_token_validity` / `refresh_token_validity` (this fleet: access 1h [embedded-outpost 24h], refresh 30d)
- proxy claim cache: `authentik_providers_proxy.ProxySession.expires` (observed ~1–24h; silent re-auth, not a logout)
- tenant: `authentik_tenants.Tenant.default_token_duration` (hand API tokens, not sessions)
## Notes
- These stage/provider rows are DB state, NOT repo-managed → a blueprint re-import or fresh rebuild reverts them; drop a setup script/blueprint in the repo if reproducibility matters.
- Don't probe the guarded vhost for the agent API and don't confuse Anubis' 168h challenge cookie with the authentik login session.
@@ -0,0 +1,28 @@
---
name: bash-cwd-drift-multi-worktree
description: "Diagnose \"files keep reverting / phantom edits\" in multi-worktree sessions: the persistent bash shell's cwd silently reverted to another checkout, so probes and git commands ran against different files."
---
# Multi-worktree bash cwd drift
## Symptom (all of these at once, in one session)
- A file "keeps reverting" to an older version between edits (esp. test files).
- `grep -n` line numbers disagree with the tool/test output (e.g. bats reports `line 637`, grep says the match is at 519).
- `wc -l`/`md5sum` stable when sampled back-to-back, but state "flaps" across the session.
- `git status` clean in one call, surprising in the next.
- Probes against `src/` show code that doesn't exist in the branch you think you're on.
## Root cause
The persistent bash shell resets to the session's ORIGINAL cwd when a call omits `cwd` (e.g. after a shell respawn at a turn boundary). With several git worktrees of the same repo checked out, consecutive calls silently alternate between two full copies of the tree — half the reads land in the old branch's files. Nothing is rewriting anything; two files are being inspected.
## Decision
- In multi-worktree work, pass an **explicit `cwd` on EVERY bash call**. Never rely on persistence.
- Use **absolute paths** for `read`/`edit`/`write` (already required) — file tools don't drift; only bash does.
## Check
When state seems to flap: `pwd` in the same call as the probe. If two calls to the same relative path report different content, `git worktree list` and census each checkout separately — you have two files, not a gremlin.
## Repair pattern (if corruption got committed via the wrong tree)
1. `git log --oneline --all -- <file>`; per candidate commit print `wc -l` + counts of a few marker strings to classify clean vs corrupted blobs.
2. Restore the good blob with `git show <commit>:<path> > <path>` (no checkout — safe under worktree policies).
3. Re-apply the intended additions as surgical edits; verify with targeted tests before the full suite.
@@ -0,0 +1,57 @@
---
name: bats-assertion-trust
description: "When writing or reviewing bats shell tests, catch vacuously-passing assertions: grep -vq, sed range boundaries, unanchored section slices, and fixtures too small to trigger pipefail/SIGPIPE bugs."
---
Prove a bats assertion can actually fail before trusting that it passes. These four traps each made a green suite lie in real work.
## 1. `grep -vq` proves nothing
`some_list | grep -vq 'x'` succeeds when ANY line lacks `x` — nearly always true. To assert absence, capture the text and test it:
```bash
s="$(report_section)"
[[ "$s" != *libfoo* ]] # absence
[[ "$s" == *bun* ]] # presence
```
Same trap in reverse: bash `[[ $s == *A*B* ]]` — `*` crosses newlines, so a substring check spanning two lines matches the wrong lines. Slice to one line first, then assert.
## 2. Section slicing helpers must have explicit boundary semantics
A helper used to scope assertions to one report section must define, and comment, both ends. The default you want: **start inclusive** (labels carry the count/names), **terminator exclusive** (a `sed -n "/A/,/B/p"` range includes B and leaks the next section's first line):
```bash
# Call sites pass a bare anchored regex; both ends documented.
section() { printf '%s\n' "$output" | awk -v s="$1" -v e="$2" '$0 ~ e {on=0} $0 ~ s {on=1} on'; }
```
Two bugs this shape hides:
- Helper prepends `^` while call sites already pass `^name` → pattern `/^^name/` → never matches → empty slice.
- An **unanchored start address** matches the first line and the range runs to end-of-output, so every absence assertion becomes a vacuous pass. Symptom: tests that "verify X is not in section A" pass while X is printed later in the same output.
After fixing a helper like this, re-run the whole suite: tests that were passing vacuously suddenly fail, and that is the fix working.
## 3. Fixtures too small hide pipefail bugs
`set -euo pipefail` + `sort | head -1`: `head` exits, closes the pipe, `sort` dies of SIGPIPE, the command substitution returns `141`, `set -e` aborts the script. Small fixtures never overflow the 64 KiB pipe buffer, so the suite stays green and the first run against real data fails with no output.
- Take a minimum in one pass: `awk '{ if (m=="" || $1<m) m=$1 } END { print m }' file`
- `awk 'NR==1{print; exit}'` is **not** a fix — exiting early closes the pipe exactly like `head`.
- Pin it with a fixture larger than the pipe buffer: `seq 1 20000 | awk '{print "[2026-01-01T10:00:00-0700] [ALPM] installed pkgfiller (1-1)"}' > big.log` (use `seq`, not `yes | head`, or the fixture itself SIGPIPEs).
## 4. `tr` swallows the final newline and downstream tools preserve that
`tr '\n' ' ' < file` emits no trailing newline; `fold`, `sed`, and `cat` faithfully preserve the absence, so the *next* output line gets glued on. Breaks any tool that later slices output by line-start labels.
```bash
{ tr '\n' ' ' < file; echo; } | fold -s -w 88 | sed 's/^/ /'
```
Pin with: assert the following section's label does NOT appear inside the preceding section's slice.
## Debugging order when a bats assertion fails but the script output looks right
1. Reproduce the exact command outside bats (`bash -x` against the script under test) — if it works there, the bug is in the *test helper*, not the script.
2. Dump the constructed argument: `printf '%s' "$sed_script" | od -An -c` inside the helper. That is how `/^^ repo /` (double anchor) got found.
3. Keep stubs faithful to the real interface: if the stub answers `-Sqq` but the script calls `-Sl`, the test measures nothing. Make the stub exit `64` on unhandled args so a new real query fails loudly instead of silently returning empty.
@@ -0,0 +1,51 @@
---
name: bats-output-glob-red-proof-traps
description: "Write red bats tests over command output: [[ ]] glob adjacency (quoted * is literal), run-capture vs test stdout in failure dumps, and proving red against pre-fix code before trusting green."
---
Three traps hit while writing red-then-green bats tests for `remote-job.sh` (Refs #60, `dank/command-center` PR #61). Each one cost a detour because it makes a test look like a bug report when it is an assertion bug.
## 1. In `[[ $s == pat ]]` a quoted `*` is LITERAL — and it eats the wildcard beside it
```bash
s=' x* DONE rc=0 1 lines'
[[ "$s" == *"x*"DONE*"rc=0"* ]] # NOMATCH — and it looks like the tool under test broke
[[ "$s" == " x*"*"DONE rc=0"* ]] # MATCH — quoted literals, UNQUOTED separators
```
`*"x*"DONE*` means: anything, then the literal substring `x*DONE`, then anything. Quoting only makes the `*` inside the quotes literal; it does **not** keep the next unquoted token from butting up against it. When the subject has whitespace between the two facts, the pattern must carry an **unquoted** `*` between the quoted literals:
```
"literal-a"*"literal-b" # a, then anything, then b
```
Existing suites in the repo write `*"a"*DONE*rc=0*` — unquoted separators throughout — precisely for this reason. Copy that shape; do not quote for "clarity".
Discriminate before believing the test: run the pattern against BOTH the expected line and the line the buggy code produced (`correct-red` / `NOMATCH` on the second is the pass condition). A pattern that matches the old output was never a regression test.
## 2. A bats failure dump prints the TEST's stdout, not `$output`
```
not ok 1 status treats a job name as a name, not a pattern
# `[[ "$output" == *"x*"DONE* ]]` failed
# started x*
```
`started x*` is the stdout the test body itself printed (from a `start` call several lines earlier), **not** what `run` captured. Reading it as the captured value sends you to debug the tool. To see the real capture, echo it:
```bash
run some-command
echo "CAP:[${output}]"
```
in a scratch copy of the test (`/tmp/dbg.bats`, same `setup`/harness) rather than adding diagnostics to the committed test.
## 3. Reproduce outside bats when green refuses to arrive
When a test fails but the same command works by hand, replicate the *exact* bats call shape in `/tmp` with the same env exports (`REMOTE_JOBS_DIR` etc.) and the same `run bash -c '…' _ "$A" "$B" "$C"` argument mapping. If the scratch copy passes and the committed one fails, the difference is the assertion, not the system — go back to trap 1.
## Red-proof rule
Run the new test against the pre-fix code and read *why* it failed. Both traps above produced a failure that looked meaningful while proving nothing (one matched the buggy output; one printed the wrong stream). Keep the test only when the failure reason is the defect you set out to pin.
Related: `bats-assertion-trust` (vacuous assertions), `red-verify-without-stash` (proving red in a shared worktree).
@@ -1,82 +1,32 @@
---
name: bounding-subagent-runtime
description: "Cap and diagnose runaway or hung omp subagents: task.maxRuntimeMs, softRequestBudget, agentIdleTtlMs, per-agent model overrides, the cfg save-approval quirk, and the finished-work-but-never-exits nested-fanout deadlock."
description: "Cap and diagnose runaway or hung omp subagents: task.maxRuntimeMs, softRequestBudget, maxConcurrency, maxRecursionDepth (the 0-trap), per-provider in-flight ceilings."
---
# Bounding subagent runtime (omp harness)
## When
Subagent runs long, fan out too wide, or you must (re)declare the fleet's subagent limit policy. Also when asked "what limits can subagents have".
Use when a `task`/`eval` subagent runs absurdly long (tens of minutes on a small job),
when it **finishes its deliverable and still never exits**, when you want a hard cap
before spawning, or when you must tell a busy agent to stop.
## Read first
- Repo KB page `omp-docs/pi/subagent-limits.md` — the live policy of record (values + rationale + deliberately-not-set list). Do not re-derive; read it.
- `cfg://task` and `cfg://providers/maxInFlightRequests` — effective values, not the stock defaults.
## The knobs (`cfg://task/...`)
| Key | Default | Meaning |
## The knobs (cfg:// writes; `/save` suffix persists)
| Knob | Effect | Gotcha |
|---|---|---|
| `maxRuntimeMs` | `0` (unlimited) | **Hard wall-clock limit per subagent (ms).** The only true time cap. |
| `softRequestBudget` | `200` | Soft cap on assistant **requests** per run; force-stop at **1.5×**. |
| `softRequestBudgetNotice` | `true` | Injects one "wrap up" steering message at the soft mark. |
| `agentIdleTtlMs` | `420000` | Parks an **idle** agent after 7 min. Useless against a busy-but-slow agent. |
| `completionProbe` | `true` | Asks a working subagent to self-report completeness at 2/5/10 requests. |
| `maxConcurrency` | `32` (often lowered) | Concurrent subagents; sizes GPU/endpoint load. |
| `agentModelOverrides` | `{}` | Per-agent model routing, e.g. `{"scout":"provider/model-id"}`. |
| `tools.maxTimeout` | `0` | Caps the timeout the agent may set on a **tool**, not on the agent. |
| `task.maxConcurrency` | session semaphore; excess queues | stock default 32 — "2" is not a default |
| `task.maxRecursionDepth` | nesting cap | predicate is `depth < n`; **1 = children cannot spawn (correct for "no nesting"); 0 disables spawning for the MAIN session too — never reach for 0**; negative = unlimited |
| `task.maxRuntimeMs` | hard wall clock, overrun = `aborted` | default 0 = off |
| `task.softRequestBudget` | wrap-up notice at N, force-yield at 1.5N | scout/sonic may impose lower built-ins |
| `task.agentIdleTtlMs` | park idle finished agents | `<= 0` disables parking |
| `providers.maxInFlightRequests` | concurrent LLM requests per provider id | keys are the quoted block labels from `agent/models.yml` (e.g. `"llamacpp (dankclaw-3090)"`), flat map lookup; absent/≤0 = unlimited; scope = this machine's config root only — other workstations and non-omp clients are NOT counted; excess queues |
| `tools.maxTimeout` | ceiling on any agent-requested tool timeout | kills `timeout: 0` runaways |
| `task.isolation.enabled` | makes per-spawn `isolated` flag available | opt-in per spawn, not a blanket sandbox; needs git repo; off in plan mode |
| `task.completionProbe` | periodic completeness side-requests | token sink on shared endpoints |
Reasonable fleet defaults: `maxRuntimeMs = 900000` (15 min), leave the request budget
alone unless the failure mode is a looping search agent (then lower it — request count,
not the clock, is what bounds a 200-call research loop).
## Verify semantics from the bundle, not the label
Setting descriptions lie by omission. `~/.bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js`:
- depth gate: `grep -o '.\{130\}xC\b.\{130\}'` finds `U_(n,s)`; `grep -o 'function U_(e,t){.\{0,60\}'` → `e<0||t<e` (n=cap, s=taskDepth).
- in-flight lookup: `grep -o '.\{150\}axInFlightRequests.\{200\}'` → flat `[providerId]` lookup, non-number/≤0 = unlimited, clamp ≥1.
**Verify the cap instead of assuming it.** `read cfg://task/maxRuntimeMs` at the start of
any session that plans to rely on it. A session-only write is gone next session, and an
unanswered `/save` approval means the value never persisted — so the fleet can run hours
with the cap at `0` while everyone believes it is 15 minutes. Nothing else bounds a child.
## Three failure modes, three different fixes
- **Runaway loop** (hundreds of sequential tool calls): lower `softRequestBudget`; the
clock cap only truncates it late.
- **Slow model, legitimate work** (dozens of calls on a low-t/s endpoint = an hour of wall
clock): fix routing, not the clock — check `cfg://task/agentModelOverrides` and move the
agent class onto a faster/shared endpoint. Watch the slot math: subagent endpoints share
KV slots with the interactive session, so `maxConcurrency + 1` sequences on an N-slot
endpoint degrades everyone's decode.
- **Finished work, no exit (nested fan-out deadlock).** A subagent that spawned **its own**
children blocks on their barriers. If those children die out-of-band (killed, starved, or
waiting on an answer whose subject changed mid-flight), the parent waits forever with its
deliverable already written. No instruction text prevents this — not the repo AGENTS.md,
not the task prompt; only the clock cap or a kill ends it.
Signature: parent and children report **the same elapsed wall clock**, and the parent's
on-disk output is complete. Census (agent registry + `ps`): group by elapsed time, look
for identical `etimes` across a parent/child set, then judge the **artifacts** instead of
the process — balanced code fences, no truncated tables, trailing newline, front matter or
the page's H1+status convention intact. Complete artifacts + still "running" = deadlock,
not work.
Repair: kill the **subtree** and confirm the children leave (a killed parent does not
cancel children that came from a separate `tasks[]` batch). Then **flatten**: the
top-level session does the fan-out and children never spawn children. Re-run only the
slices whose artifacts are missing — integrate from what is already on disk.
## cfg write mechanics (bit me once)
- `write cfg://task/maxRuntimeMs 900000` → applies **session-only**, replies "NOT saved".
- Persist = `write cfg://task/maxRuntimeMs/save` with the same content, and that write
needs its **own interactive approval**. If the approval prompt is not answered in time
the value stays session-only and the tool says *NEVER retry unless the user asks* —
report it in the reply instead.
- Revert a session value by writing the old value back (e.g. `0`).
## Order of operations when one is already running late
1. `read proc://` → registry of running agents; `read proc://<id>` for status/output.
2. `read agent://<id>` → live progress ("Latest assistant text") — often shows it is
mid-batch, not hung. If the latest text is already a wrap-up/summary, treat it as
**done** and collect its output instead of waiting for an exit event.
3. `write agent://<id>` an explicit wrap-up order: stop searching, answer what is
sourced, mark the rest `NOT VERIFIED — needs <what>`, cap the report length.
4. Only then `write proc://<id>/kill` — the cap does **not** retroactively bind agents
spawned before the change, so a long runner must be ordered or killed, never just
re-configured. After any kill, re-read the registry: unreaped children and orphaned
host-side processes (a scratch server holding GPU/RAM) are the expensive residue, and
`pkill -f` patterns that also match production services are forbidden — kill by pid.
## Landing a policy change
cfg `/save` writes persist immediately; `agent/config.yml` is machine-live tier (heartbeat commits it). Human-facing policy changes land as an `omp-docs` page update via branch → PR (docs tier), `git worktree add` from `origin/main` if the current checkout is branch-locked by workspace policy.
@@ -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,49 +1,62 @@
---
name: dankclaw-pihole-lan-dns-record
description: "Add/repair a LAN DNS record for a new *.franksdank.cloud service on Dankclaw's Pi-hole v6 (hosts/custom.list path), plus the public Porkbun A record step and client cache gotchas."
description: "Add/repair a LAN DNS record for a new *.franksdank.cloud service on Dankclaw's Pi-hole v6 (hosts/custom.list + repo addn-hosts, plus the public Porkbun A record side) — and triage \"LAN names stopped resolving\" including the FTL boot-time record-load failure."
---
Use when a new `*.franksdank.cloud` service resolves publicly/LAN-needs a local address, on Dankclaw (Pi-hole v6 container, quadlet `pihole.container`).
# Dankclaw Pi-hole LAN DNS record
## Ground truth layout (verified 2026-10-11)
## What this fixes
- Quadlet: `~/.config/containers/systemd/pihole.container`; volumes `/home/dank/Containers/pihole/{etc-pihole,etc-dockers,addn-hosts}`.
- **LAN records live in `/home/dank/Containers/pihole/etc-pihole/hosts/custom.list`** — plain hosts format: `192.168.0.143 gitea.franksdank.cloud`. This is the boring, working path.
- `pihole.toml` `[[dns.records]]` array is EMPTY on this host — do not expect v6 UI-style records there.
- `gravity.db` exists but has NO `domain` table (v6 layout) — do not insert local records into SQLite.
A new `*.franksdank.cloud` service resolves publicly (via Porkbun → `franksdank.cloud` A record → Caddy on Dankclaw `192.168.0.143`) but **does not resolve on the LAN**. Pi-hole is the LAN DNS server (`dankclaw_default` bridge, published port 53) and serves local records only from files it imports.
## Procedure (30 s, proven)
## Live truth
```bash
ssh dankclaw 'echo "192.168.0.143 <name>.franksdank.cloud" | sudo tee -a /home/dank/Containers/pihole/etc-pihole/hosts/custom.list >/dev/null \
&& podman exec pihole pihole restartdns'
ssh dankclaw 'getent hosts <name>.franksdank.cloud' # must resolve
dig +short <name>.franksdank.cloud @192.168.0.143 # from any LAN box
- **The repo owns the records.** `dankclaw/pihole/addn-hosts` (hosts-style lines: `192.168.0.143 <name>`) is copied by `deploy-remote.sh` to `/home/dank/Containers/pihole/addn-hosts` and bind-mounted **read-only** into the container at `/etc/pihole/hosts/addn-hosts` (`Volume=/home/dank/Containers/pihole/addn-hosts:/etc/pihole/hosts/addn-hosts:ro` in `dankclaw/pihole/quadlet/pihole.container`). Add the name there, in the repo, then deploy — that is the durable path. `tests/pihole.bats` validates every line (IPv4, LAN range, name suffix, Caddyfile cross-check).
- `/home/dank/Containers/pihole/etc-pihole/hosts/custom.list` also exists on the host (older mechanism, mode `640` owned by the container uid, root reads it with `sudo`). It is **not** the repo's owner file — do not hand-edit it as the fix.
- `pihole.toml` carries a `dns.hosts = [ "192.168.0.143 …", … ]` array too (FTL's own in-config records). Both sources exist; if LAN names fail, neither is being applied.
- Pi-hole runs as a rootless quadlet named `pihole` — query it with `podman exec pihole …`, never `sudo podman` (per-user storage law).
- `pihole.franksdank.cloud` resolves through Pi-hole itself (loop/edge), so a vhost record pointing at `192.168.0.143` is correct for every Caddy-fronted service.
## Procedure (repo-first)
1. Append the line to `dankclaw/pihole/addn-hosts`:
```
192.168.0.143 <name>.franksdank.cloud
```
One record per vhost; keep it in sync with the Caddyfile (the bats seam cross-checks the two).
2. `./tests/run.sh` (the `pihole` seam validates the file), commit, push.
3. Deploy so the copy step runs: `./scripts/deploy.sh dankclaw --service pihole`.
4. Verify from the box: `getent hosts <name>.franksdank.cloud` → `192.168.0.143`.
5. Public edge record (optional; skip when every subscriber is LAN-only): Porkbun `POST /api/v3/json` with `apikey`/`secretapikey`/`command=add`/`type=A`/`hostname=<name>`/`data=76.49.118.136` (see `dankclaw-porkbun-edge`).
Client-side proof (avoid the resolver trap):
```
sudo resolvectl flush-caches # systemd-resolved otherwise keeps the negative cache
curl -sv --max-time 5 https://<name>.franksdank.cloud 2>&1 | grep 'Connected to'
```
Hosts files are read-only-readable only via sudo (dir owned by uid 100999). `sudo -n` works on dankclaw.
Expected: `Connected to <name>.franksdank.cloud (192.168.0.143)` — LAN route, not the public IP.
## API maze — DON'T re-explore
## Triage: every LAN name broke at once (2026-10-19 law)
- FTL API base is `/api` at the web port (8083) **with `Host: pi.hole` header**; `/admin/api/*` → bad_request hint; bare 127.0.0.1 without the vhost → 404 `not_found`.
- Login: `POST /api/login {"password": <WEBPASSWORD from /home/dank/Containers/pihole/env>}` → session in `X-Cors` header/cookie.
- `POST /api/apps/domains/custom` → **404 on this build** (FTL version predates it); `/api/customdns/custom` also absent. There is no container sqlite3. Use custom.list.
Signature: **all** `*.franksdank.cloud` return NXDOMAIN / Playwright `ERR_NAME_NOT_RESOLVED` while **external names still resolve**. The external-OK/local-fail split is the tell: FTL is answering external names from cache and holds **no local records at all** after a container/host restart — the mounts and files are intact and readable inside the container (`ls -l /etc/pihole/hosts/`, `grep -c . …/addn-hosts`), `dns.hosts` in `pihole.toml` is complete. The mechanism is unestablished; do not go hunting for a lost file.
## Public record (off-LAN reachability)
Porkbun API, keys in `/home/dank/Containers/caddy/env` (`PORKBUN_API_KEY`, `PORKBUN_API_SECRET_KEY`):
```bash
curl -sL -X POST https://api.porkbun.com/api/json3/domain/api/create/franksdank.cloud \
-H "Content-Type: application/json" \
-d "{\"apikey\":\"$K\",\"secretapi\":\"$S\",\"name\":\"<name>\",\"type\":\"A\",\"content\":\"71.227.227.172\",\"ttl\":\"600\"}"
```
ssh dankclaw 'getent hosts grafana.franksdank.cloud' # empty => FTL, not the client
ssh dankclaw 'podman exec pihole pihole restartdns reload' # fixes it in seconds
```
- `json3` path 302-redirects → **`-L` required**; no-`-s` to see status.
- Porkbun edge (openresty) intermittently 503s — transient; retry later, don't burn turns. Public record is OPTIONAL when LAN-only subscribers exist.
- Wildcard `*.franksdank.cloud` TLS is already covered: Caddy + Porkbun DNS-01 (caddy-porkbun image), so a new subdomain needs NO new cert work — just the Caddyfile site block + reload.
Distinguish before acting: a **single** client failing while others work is FTL rate-limiting that client — `podman exec pihole grep Rate-limiting /var/log/pihole/FTL.log`; a `10.0.2.x` client address is a pasta container's DNS forwarder (SNAT), not a machine. Full causal page: `serverAppConfigs-docs/Infrastructure/Pi-hole.md` (**Records** pitfall + Failure modes table).
## Client-side cache gotchas
## Gotchas
- Workstation (systemd-resolved): negative cache survives Pi-hole changes; `sudo resolvectl flush-caches` needs a TTY (faillock-sensitive — no `-S` guessing). Non-interactive proof: `curl --resolve <name>.franksdank.cloud:443:192.168.0.143 https://<name>.franksdank.cloud/...`; cache self-expires (SOA negative TTL).
- Dankclaw/Dankserver resolve via Pi-hole directly — verify there first.
- Do NOT insert local records into SQLite. v6's `gravity.db` `domain` table has NO `type=2` custom-DNS rows in this build.
- `pihole restartdns reload` re-imports the hosts files **and** `dns.hosts`; that is the remedy for the boot-load failure above. Plain `pihole restartdns` also restarts the resolver — brief LAN-wide DNS blip.
- `dig @<ip> <name>` from the workstation bypasses systemd-resolved — use it to check FTL's answer directly (`+short @192.168.0.143`).
## Cross-reference
- Fleet DNS flow: `dankclaw/Caddyfile` (Caddy binds `:443` on the host, serves `*.franksdank.cloud` vhosts)
- Pi-hole service page: `serverAppConfigs-docs/Infrastructure/Pi-hole.md`, flow page `Infrastructure/LAN-DNS.md`
- Public edge: managed skill `dankclaw-porkbun-edge`
@@ -0,0 +1,44 @@
---
name: dankvoice-bats-serialization
description: "Run dankvoice bats suites without hangs or false failures: serialize dist rebuilds, never edit src mid-run, and rebuild before committing the tracked bundle."
---
Use when running the dankvoice `bats tests/` suite on the shared box, when a suite run hangs or times out, or when the "committed dist is byte-identical to a fresh build" pin test fails after a commit.
## The two serialization hazards
### 1. `setup()` rebuilds `dist/` from `src/`
Every test runs `cd "$REPO" && bun run build` in `setup()`. Consequences:
- **Never edit `src/` while a suite is running.** The next test's `setup()` compiles whatever is on disk at that instant, so an in-flight edit gets attributed to the run and produces a failure with no owner. Finish the gate, then write.
- **Two suites on the same checkout race on `dist/`.** Serialize: run one, wait for its report, then start the next. Multiple worktrees of the repo each build their own `dist/` and do not collide.
- Suite runtime is ~85 s for 55 tests; a backgrounded job with a 600 s deadline is the right shape (`timeout: 600`, `async: true`). Never poll it — `setup()` output lands in the conversation when the job ends.
### 2. `dist/` is tracked, and a pin test enforces it
One test rebuilds and compares against the committed bundle. Therefore the commit order is:
```bash
bun run build # BEFORE staging
git add -A && git commit -m '…'
bats tests/ # gate AFTER the commit
```
Committing `src/` changes without rebuilding first fails the pin test (`not ok … byte-identical to a fresh build`) — the tracked bundle is the *previous* state. If that happens on an unpushed branch, fix it by amend rather than a follow-up commit:
```bash
bun run build && git add dist && git commit --amend --no-edit
```
Verify freshness any time the pin is in doubt:
```bash
bun run build >/dev/null 2>&1 && git status --porcelain dist # empty = committed == fresh build
```
## If a backgrounded gate job vanishes
A crash can kill the job before its report; `read proc://bg_N` returning "not found" means it is gone, not that it failed. Reconstruct what landed before resuming — `git log --oneline -3`, `git status --porcelain`, then the freshness check above — and rerun the suite; the commit may well have succeeded while the gate never ran.
## Gotchas
- `DV_HOLD` is seconds (`sleep "${DV_HOLD:-0.1}"`), so `DV_HOLD=3` really holds the stub for three seconds.
- Isolation tests must not count shared state: any assertion that sweeps `$TMPDIR` or `~/.omp/cache` observes every other live session on the box (each narrating session creates `dankvoice-*` scratch dirs). Point `TMPDIR` at a private directory instead — see the `cutting off mid-utterance leaves no orphaned audio behind` test.
- `bun run build` does not typecheck. Run `bunx tsc --noEmit` separately when moving code between modules; the bundler happily emits a bundle that references a deleted identifier.
@@ -0,0 +1,58 @@
---
name: dankvoice-bundle-swap-red-proof
description: "Prove a dankvoice behavior test is red against the pre-change bundle by pointing DV_EXT at git show HEAD:dist/index.js, with the driver extracted from the bats heredoc into a pinned scratch directory. Use before claiming a new bats assertion is red-capable."
---
Prove a new `tests/behavior.bats` assertion is **red-capable** — that it fails against the *previous* build, not just passes against the new one — without stashing, branching, or editing `src/`.
## Why
`tests/behavior.bats` is a black-box suite: the plugin bundle is loaded through `DV_EXT`, so a *different bundle* is the red side. Stashing the fix would break the `dist` byte-identity pin test and the shared worktree.
## Working recipe
```bash
cd ~/Projects/dankvoice # or the feature worktree: ~/Projects/wt-dv-*
bun run build # current src = the green side
git show HEAD:dist/index.js > /tmp/old-dist.js # the red side (pre-change bundle)
W=/tmp/dv-redproof # PINNED name; see traps below
rm -rf "$W" && mkdir -p "$W/bin"
cat > "$W/player.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$DV_STUB_LOG"
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done
sleep "${DV_HOLD:-0.1}"
SH
chmod +x "$W/player.sh"
ln -s "$W/player.sh" "$W/bin/pw-play"
printf '#!/usr/bin/env bash\nprintf "116\\talsa_output.usb-KT.0\\taudio/sink\\t*\\n"\n' > "$W/bin/wpctl"
chmod +x "$W/bin/wpctl"
# The driver lives inside the bats file as a heredoc: extract it verbatim.
end=$(grep -n '^TS$' tests/behavior.bats | cut -d: -f1)
sed -n "86,$((end - 1))p" tests/behavior.bats > "$W/driver.ts"
export DV_EXT="$PWD/dist/index.js" DV_STUB="$W/player.sh" DV_STUB_LOG="$W/player.log" \
DV_COPY="$W/played.wav" DV_OMPCONFIG=/dev/null DANKVOICE_CONFIG="$W/settings.json" \
DV_MODELS="$W/models.yml" DV_WPCTL=x DV_SELECT=""
for ext in /tmp/old-dist.js "$PWD/dist/index.js"; do
rm -f "$W/player.log" "$DANKVOICE_CONFIG"
DV_EXT="$ext" bun "$W/driver.ts" <scenario> 2>&1 | grep -E '^PLAYS|^CALLS|^NOTIFY|^SETTINGS'
done
```
The scenario name is the driver's third argv (`speak`, `auto`, `repeat`, `humanizecycle`, `badurl`, …); the bats `-f` test body's env (`DV_PLAYER_BIN`, `DV_SETTINGS_JSON`, `DANKVOICE_VERBOSITY`, …) is passed the same way. `DV_SELECT` holds the menu answers, `|`-separated, consumed in order.
## Traps, each one cost a turn
- **The scratch directory must be pinned by name.** `ls -dt /tmp/tmp.*` picks up *other sessions'* temp dirs, and then `player.sh` and `driver.ts` live in different directories — the run silently does the wrong thing.
- **The stub must append to `$DV_STUB_LOG`.** The driver counts player invocations from that file; a stub that only `sleep`s reports `PLAYS 0` for *both* bundles and the whole proof reads as "fix does nothing".
- **`DV_EXT` is required.** Missing it yields `Cannot find package 'undefined'` from the driver, which looks like a broken driver, not a missing env var.
- **`DV_SETTINGS_JSON` supports `__PORT__`** (the driver substitutes the fixture port), so a preseed can name a prefixed route — needed whenever the test is about *which* endpoint got resolved.
- **Never run this while a `bats` run is in flight** in the same repo: `setup()` runs `bun run build`, so two runs clobber `dist/` and the pin test.
## Reading the result
Red side must show the *defect* (`PLAYS 0`, `644`, a chat `CALL` with the wrong prefix, `Invalid URL`), green side the fix — assert on printed markers only (`PLAYS`, `CALLS`, `CALL <url>`, `NOTIFY`, `SETTINGS`). `CALLS` counts `/audio/speech` calls only; the rewrite call appears as its own `CALL /…/chat/completions … sys=<full system prompt>` line, which is how prompt-level knobs (verbosity, heckle) are observed.
Prefer this over a synthetic leak: if the check already caught a real leftover/failure once, that event *is* the sensitivity proof — say so in the commit message instead of faking one.
@@ -0,0 +1,32 @@
---
name: dankvoice-epithet-pool-audit
description: "Grow or audit the dankvoice 1000-name epithet pool: gender classes, individual eviction, typo sweep, safe regeneration into framing.ts."
---
# Dankvoice epithet pool audit
Use when adding names to, reclassifying, or cleaning `src/framing.ts` `EPITHETS_*` in `dank/dankvoice`. The pool is law: exactly 1000 distinct names, curated at build time, never user-editable, "the commons, never the characters".
## Classification rules (proven traps in parentheses)
- **man**: gendered lexicon (king, lord, priest, uncle...) + tokens ending `man|men|master` — but never `human|inhuman|nonhuman`. Compounds like `quartermaster`, `hangwoman` are single tokens: token-equality checks miss them, use suffix rules.
- **woman**: lexicon (queen, sister, daughter...) + tokens ending `woman` + an EXPLICIT `-ess` title list (baroness, countess, prioress, canoness, chartress, heiress, priestess...). Never a bare `-ess` suffix match: `endless`, `harmless`, `fortress` all end in "ess".
- **Possessives follow the head noun**: `lieutenant of the king's hull` is a lieutenant → neutral commons; `count's armsman` is an armsman → man. Do not gender by words inside `'s` phrases.
- Modern-neutral offices stay commons: hero, governor, wizard. `witch/lass/bride` are female-coded; `warlock` male-coded.
- Regex trap for absence checks: `he\b` matches inside `the`; token-split before matching.
## Individual eviction law
Evict: named fictional/historical individuals (duke caladan, uncle toby, pyrrhus, usul, endymion, skynet, moya-born, medusa), named protagonist ships (rocinante), and real authors in universe clothing (strugatsky, niven).
Keep: species, orders, houses, orgs/companies (sardaukar, jedi, cyberdyne), places (kessel; `falconer of the caladan eyries` is fine — the person problem was only `duke caladan`), institutions (zekma, karass).
Replace each eviction with the generic office beside it, class-stable, and unique against the pool.
## Invariants tests pin (breaking any of these goes red)
- 1000 names, all distinct.
- Sentinel literals must survive: `hangman of the holiday` (man), `high priestess of the ear moon` (woman), `comrade operator` (other).
- Pool firsts feed pool[0] probe tests — union order is MAN-first / WOMAN-first / OTHER; keep source order within classes so firsts stay: `chairman of the presidium of processors` / `hangwoman of the public convenience` / `comrade operator`.
- Class counts (86/15/899 as of 2026-10) may churn but must be reported; tests never pin them.
## Regeneration
Pool lives as three generated arrays between markers `/** Male-coded` and `/** The unions` in `src/framing.ts`. Splice new arrays between those markers (python splice of a generated `const NAME = [ "x", ... ] as const;` block is fine; do not hand-edit 1000 lines). Then `bun run build` (dist pin test byte-compares), `bun run typecheck`, and the full `bats tests/` — the class sweeps are exhaustive `Math.random`-cycled walks (each ~200 s; suite ~17 min; never two bats processes at once).
## Fleet cutover after merge
main checkout ff-only → `omp plugin upgrade omp-dankvoice` → `cmp` `~/.omp/plugins/node_modules/omp-dankvoice/dist/index.js` against merged `dist/index.js`.
@@ -0,0 +1,38 @@
---
name: dankvoice-menu-knob-workflow
description: "Add a persisted /say knob to dankvoice and test its menu row through the bats driver (DV_SELECT picks, INPUT stub, dist-pin rebuild gate)."
---
Adding a setting to `dankvoice` (`~/Projects/dankvoice`, omp speech extension) means five touchpoints plus one behavioral test. Skip one and TypeScript or a silent drop catches it; skip the test and the row ships dead.
## The five touchpoints (order matters)
1. `src/settings.ts` — field on `Settings` (optional) **and** a type-guarded line in `readSettings`. A field without a guard line is written to disk and never read back; tsc stays clean, so nothing else catches it.
2. `src/config.ts` — `Config` field (required, defaulted), resolution line, entry in the returned object, and the knob documented in the file header comment (that header is the README's pointer for "full knob list").
- Convention: explicit env wins over settings (`env.X === "1" ? true : env.X === "0" ? false : (settings.x ?? false)`); booleans use `1`/`0`, enumerations use the literal value.
3. `src/index.ts` — `persistSettings()` key list (a knob missing there never survives the session) **and** the getter in the `new Speaker({...})` deps object.
4. `src/utterance.ts` — getter on `SpeakerSettings` if the Speaker reads it at speak time.
5. `src/menu.ts` — the row. Rows carry their own `run`; never dispatch on `choice === options[N]`.
Gate at the call site, not the prompt, when a mode must not apply to operator-typed text: `Speaker.speak(ctx, text, thinking, auto)` — `auto=true` only from `message_end`/`agent_end`; `/say <text>` passes nothing. Prompt-only gating leaks.
## Test the row behaviorally, never as a config assertion
`tests/behavior.bats` drives the built `dist/index.js` against a mock harness. Selects and typed text both come from one pipe-separated queue: `DV_SELECT="row label|sub choice|typed text"`, consumed in call order; a consumed-exhausted `select` returns `options[0]`, so always name the row by its **rendered label** (`"Verbosity: normal (change)"`, `"Intro: none (change)"`).
- `ui.input(title, placeholder)` in the driver mock logs `INPUT <title> <placeholder>` and takes the next queue entry. If the mock lacks `input`, no typed value was ever testable — add the stub before writing the test.
- Scenario pattern that proves a pick works (`case "knobs"`): `say.handler("menu")` → `say.handler("on")` → two `agentEnd(answer(...))` with `settle()` between. Then assert on observable output, not on the settings file:
- prompt-shaping knob: `[[ "$output" == *"ONE spoken sentence"* ]]` (directive reached the rewrite request)
- framing: `[[ "$output" == *"input=Update, chief!"* ]]`
- throttle/framing counting: `[[ "$output" == *"CALLS 1"* ]]`
- A guard test whose negative case would pass trivially must also assert its premise, e.g. truncation: assert `CALLS 1` so "no outro" cannot be a green caused by nothing ever being cut.
Red first, in a batch: write all the new `@test` blocks, run `bats tests/behavior.bats -f "<pattern>"`, confirm the new ones fail, then implement.
## Gates and gotchas
- **Two `bats` runs at once corrupt each other**: `setup()` runs `bun run build`, so a concurrent run rebuilds `dist/` mid-suite and the byte-identical pin test fails spuriously. Serialize.
- **Edit `src/` → rebuild before commit**: the pin test compares committed `dist/` to a fresh build. Sequence is `bun run build && bunx tsc --noEmit && bats -f <pattern>`, then `git add -A` (dist included). A failure there is normally a stale bundle, not a bad assertion.
- `VAR=value some_function args` in bats does reach the child process, so knob tests need no `export`.
- `edit` tool: inserting after a line whose content you know but that the current tag never displayed gets refused — the refusal echoes the real line; if it matches intent, re-issue the identical edit instead of re-reading.
- Ship: branch `feat/<issue#>-<slug>` off fresh `origin/main`, one commit per knob, PR targets `main`, merge only on Frank's explicit OK (`do=merge`, `merge_style=no-ff`), then `omp plugin upgrade dankvoice` and `cmp` installed `dist/index.js` against the merged one.
@@ -0,0 +1,60 @@
---
name: dankvoice-silence-triage
description: "Triage silent or late dankvoice auto-speak on the fleet: arming key, /say grammar, endpoint/sink, event granularity, humanizer latency, plus the black-box bats driver."
---
# Triage "dankvoice is silent" on the fleet
Symptom that recurs: auto-speak only speaks when `/say <text>` is typed
explicitly. Work the gates in this order; each has an objective check.
1. **Armed?** `cat ~/.omp/cache/dankvoice.json | jq .auto` — must be `true`
(or `DANKVOICE_AUTO=1`). Arming is read at **startup only** and `/say on`
never persists, so a session started before the key was added is silent even
though fresh sessions work. `/say start` is *not* a subcommand: unknown
arguments are spoken verbatim (it read out the word "start"). `/say on` is
the verb; `/say status` must print `auto-speak on`.
2. **Endpoint + sink?** `/say status` must show a real endpoint (not
`NO ENDPOINT`) and the saved sink must exist:
`pw-dump | grep -o 'alsa_output\.[^"]*' | sort -u`.
3. **Did it synthesize?** `pgrep -a pw-play` during a turn: one process per
segment, `--target <sink>`, wav under `/tmp/dankvoice-*/say-<epochms>.wav`.
One temp **directory per utterance chain** — count dirs to count utterances,
pids to count segments.
4. **Event granularity (the structural cause).** `agent_end` fires once per
*prompt* when the loop settles and is skipped when the harness maps a
non-terminal settle (`willContinue`) — long runs with background jobs or
subagents therefore narrate nothing. Mid-run narration needs `turn_end`
(fires per assistant message + tool results). Verify delivery, don't infer
it: skill `omp-extension-event-probe`.
5. **Latency is not silence.** Narration passes through the humanizer
(frank-zealot) before synthesis, so an utterance lands several seconds after
its turn; under endpoint load that stretches to tens of seconds.
`/say humanize off` = mechanical voice, near-instant. Judge cadence from
probe-log timestamps, never from when audio starts.
## Design rules the plugin now encodes (dank/dankvoice#2→#3)
- Narrate `turn_end` per turn **plus** `agent_end` for the settled answer;
dedupe identical text per run (reset on `agent_start`) so the answer speaks once.
- A turn landing while the player is alive is **dropped** — no queue, no
interrupting; a tool loop would cut every sentence off after a word.
Busy test: `state.child !== undefined && state.child.exitCode === null`.
- Tool-only turns (no prose, no narration-flagged thinking) stay silent.
- `print`/`json` modes stay silent, so benches never chatter.
- Settings writes must **round-trip the file** (`{...onDisk, ...owned keys}`).
Rebuilding from owned keys deleted `"auto"` on any menu choice and silently
un-armed every later session — the classic cause of "it worked yesterday".
## Black-box test harness (repo `dank/dankvoice`)
`tests/behavior.bats` drives the built artifact through an in-process driver:
`const say = DV_EXT(ctx, cfg)[0]`, handlers via `const events = new Map();
const pi = { on: (n,h)=>events.set(n,h) }`, then `events.get("turn_end")?.({
turnIndex:0, message:{role:"assistant",content:[{type:"text",text:"…"}]},
toolResults:[]}, ctx)`. Assert on `CALL`/`PLAYS`/`NOTIFY` lines and
`SETTINGS <file contents>`; stubs are `DV_PLAYER_CMD` (logs voice+input, honours
`DV_HOLD`) and `DV_REWRITE_URL` (404 → falls back to mechanical text). `setup()`
runs `bun run build` per test → serialize suites on the shared `dist/`.
`tests/packaging.bats` requires `dist/` **committed** (compares to HEAD), so
build → commit → the drift pin goes green.
@@ -0,0 +1,49 @@
---
name: docker-pin-latest-census
description: "Prove a Docker Hub / lscr.io image pin is the latest version before bumping it: registry-API tag census plus digest-equals-latest verification, no docker CLI needed."
---
# Is my pinned image actually the latest?
Use when bumping a pinned container image tag (e.g. `lscr.io/linuxserver/*` quadlet `Image=` lines) and "latest" must be PROVEN, not assumed. Works from any box with curl — no podman/docker daemon required. `lscr.io` fronts Docker Hub; its tags are NOT on quay.io (querying quay returns nothing and reads like "no newer tags exist" — false).
## 1. Full tag census via the registry API
```bash
tok=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:<owner>/<repo>:pull" \
| python3 -c "import json,sys;print(json.load(sys.stdin)['token'])")
curl -s "https://registry-1.docker.io/v2/<owner>/<repo>/tags/list?n=2000" \
-H "Authorization: Bearer $tok" | python3 - <<'PY'
import json,sys,re
tags=json.load(sys.stdin).get('tags') or []
# linuxserver form: <version>-<sha>-ls<N>; group by version, keep newest ls per version
v={}
for t in tags:
m=re.match(r'^(\d+(?:\.\d+)+)-([0-9a-f]+)-ls(\d+)$',t)
if m: v.setdefault(tuple(map(int,m.group(1).split('.'))),[]).append((int(m.group(3)),t))
for k in sorted(v)[-5:]:
print('.'.join(map(str,k)),'-> newest:',max(v[k])[1]) # max on (ls#, tag) tuple
PY
```
- Sort versions as **tuples of ints**, never strings (`1.4.10 < 1.4.9` lexicographically).
- Keep the max `ls` build per version, then the newest version — both can lag independently.
- Trap already eaten: joining the tuple key to a string mid-loop then re-looking-it-up → `KeyError`. Keep keys as tuples; format only when printing.
- Some channels have NO `latest` tag in `/tags/list` (or the endpoint truncates) — absence there is not evidence.
## 2. Digest-proof the candidate against `latest`
```bash
for t in latest <candidate-tag>; do
curl -sI "https://registry-1.docker.io/v2/<owner>/<repo>/manifests/$t" \
-H "Authorization: Bearer $tok" \
-H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json" \
| grep -i '^docker-content-digest'
done
```
Equal digests ⇒ the pinned tag IS what `latest` serves right now — the strongest "ensure latest" evidence, and recordable in the endpoint page's pin note (the dank-fleet law: *pinned; verified == the registry's `latest` manifest digest at pin time*). A multi-arch index digest matches only if both requests send the same Accept set — send all three types above.
## 3. Bump through git, not by hand
Edit the quadlet/unit `Image=` line + the vault page's pin row (one owner each), run the repo suite, PR, deploy `--service <svc>` only. Rollback stays "revert one line".
@@ -0,0 +1,35 @@
---
name: docs-dedupe-verify-campaign
description: "Run an exhaustive docs dedupe + fact-verification campaign in dank/command-center: scout cluster ledgers, override scouts with evidence, gate every commit, and sweep scaffolding before PR."
---
# Exhaustive docs dedupe + verification campaign (dank/command-center)
## Frame
- Read `omp-docs/references/okf-spec.md` + root `omp-docs/index.md` first; inventory with `find omp-docs -name '*.md' | wc -l`.
- Run `scripts/kb-lint.sh omp-docs` baseline; index-coverage gate is `scripts/kb-coverage.py omp-docs` (each dir's `index.md` must link every sibling file/subdir).
- Branch off fresh `origin/main` in a NEW worktree (`git -C ~/.omp worktree add ~/.omp/wt/<name> -b docs/<issue>-slug origin/main`) — workspace policy blocks `git checkout -b` in session worktrees.
- Open the Gitea issue first (multi-moving-parts law); branch name `docs/<issue#>-<slug>`.
## Scout fleet (overlap detection)
- One `task` call, `tasks[]` batch, agent `scout`, read-only; one cluster per task (skills trees, llama trees, pi vs packages, niri, sleep/power, core, gitea, gitea+cross-cutting sweep). Cap 2 concurrent; queue rest.
- Ledger format per cluster → `local://dedupe/<cluster>.md`: inventory table (file | lines | verdict KEEP|MERGE|DELETE|VENDOR|UPDATE), overlaps, facts-to-keep, inbound links, staleness.
- **Scouts lie.** Verified misses in the 2026-10-19 campaign: fabricated ledger entries ("peer-reviewed BrowseComp" claim that exists in no file), a DELETE verdict for a generated artifact that is checked in by design AND under test (`tools/niri/keybindings.md`, see `scripts/gen-keybinds.sh` + `tests/gen-keybinds.bats`), and a KEEP for a leftover the scout misread its own changelog about. NEVER apply a scout verdict without re-checking the file, the tests, and the changelog yourself.
## Fact verification
- Fleet facts: read-only probes over `ssh -o BatchMode=yes dankclaw|dankserver` (podman ps/inspect/top, stat, /etc/os-release, curl localhost APIs, `podman exec <c> <tool> --version`). Live state beats docs even when docs claim recent verification — the 2026-10-18 "audit" still drifted (zealot 27B→9B, build 10754→10777, container census 14→32).
- External claims: web_search/web read yourself (no subagents per Frank's rule). Kill fabrications outright (invented distro `FreeToken`, nonexistent `ornith-llama-cpp-setup` tree, unattributable "44.5% peer-reviewed CUA") — delete/drop with an explicit note, never keep "plausible" numbers.
- Policy drift (live ≠ tuned docs): flag in the doc + PR body as an operator decision; NEVER silently re-apply policy.
## Log compaction (log.md)
- Entry law: dated entry = what changed + durable lesson + concept-page pointer; narratives recoverable via `git log --follow`. Record the law at the top of the log. Tag historical-era entries.
## Gates + landing
- Gate every commit: `bash scripts/kb-lint.sh omp-docs && python3 scripts/kb-coverage.py omp-docs && bats tests/`. Pre-commit hook prints `error: command not found: caddy` on Caddyfile-touching commits — warning only, commit still lands.
- Before opening the PR: `git diff --stat origin/main..HEAD | grep -i 'tmp\|scratch\|\.bak'` — scaffolding merged under a `.tmp-` name in the 2026-10-19 campaign and needed a follow-up PR to promote.
- PR via API (skill `gitea-api-pr-operations` for token-extraction + merge payload); `xdg-open` the PR the moment it opens; comment the campaign ledger on the issue.
- After Frank's merge OK: propagate deployment `git -C ~/.omp fetch && git merge --ff-only origin/main`; reap campaign worktree (`git worktree remove --force`) + delete branch local/remote; close issue with landing comment.
## Line-edit op syntax trap (bit three times)
- Valid ops: `PUT N.=M:` (body `+text` lines), `CUT N.=M` / `CUT N.=M @reg`, `PUT <N @reg`, `PUT >N:` / `PUT <N:`. There is NO `PUT N+=:` form — it lands in the file as a literal garbage line and shifts the insert to the wrong position. For gap-insert use a lone `PUT >N:` or `PUT <N:` per call; pre-compute line numbers against the file map (ops reference original lines within one call, but a failed op poisons positions).
- Recovery recipe when a literal op line lands: read exact bounds, `CUT N.=N` (junk), `CUT A.=B @block` (misplaced section), then `PUT <M @block` at the true anchor, re-read tail, add spacing blank if a `---` now abuts a list line.
@@ -0,0 +1,70 @@
---
name: docs-point-of-use-destruction
description: "Documentation exists for point of use, not archival. Use before any dedupe/compact/verify-docs job, and whenever a census or \"only copy\" argument is being used to KEEP documentation: apply the purpose test and destroy obsolete measurements. Supersedes the preservation conclusions of vault-one-fact-one-owner-dedupe §3/§12."
---
# Documentation exists for point of use
**Supersedes** `vault-one-fact-one-owner-dedupe` §3 ("port before cut") and §12
("expect docs flat") wherever those conclude a page must be *kept*. Sole custody of a
useless fact is still useless.
## The test, before you write and again before you keep
- Would the reader, doing a real task tomorrow, **act differently** because of this
line? No → delete it.
- Is it a measured rate (t/s, tok/s, ms, MiB-of-pool, acceptance %, a bench ladder) for
a model / runtime / layout that is **not running now and not being compared now**?
Destroy it. Not "file it in an Eras row", not "preserve it for history". AI moves
faster than the vault: the number is wrong before anyone reads it, and a wrong number
in a runbook gets believed.
- Keep the **law** the measurement taught; drop the measurement. Qualitative causal
stories survive; numbers do not.
- What survives: live config and its exact numbers, procedures, failure signatures,
security posture, interface contracts, rollback pointers.
- **Losing information is an acceptable, usually correct outcome.** Frank said so
explicitly on 2026-10-19 after rejecting a PR that had preserved 53 ledgers.
## Traps that lead agents to preserve junk
- **A census cannot decide what to keep.** `port-census.py`-style tools answer "where
does this fact live", never "does anyone need it". "14–63 sole-record lines per page"
was read as *irreplaceable* when the fact was *obsolete*. Ask usefulness, not custody.
- **A census that includes the file under test proves the page is held by itself.**
Exclude it, print the corpus size, assert absence; treat `0 unheld` on a long page as
a tool bug until proven otherwise.
- **"One fact, one owner" is a placement rule, not a preservation rule.** It says where
a surviving fact lives; it never says the fact must survive.
- **A change diary (`log.md`) is the largest archive in most vaults** (this one: 2,380
lines, ~8 % of the vault). Git holds the chronology; a causal law belongs on the page
that owns the thing it governs. Retire the diary.
## Execution that worked (2026-10-19, −13.7k lines)
1. Write the kill list as a reviewed artifact, one path per line, grouped by *why it is
dead* — that list becomes the PR's argument and the trash manifest.
2. Destroy whole pages first (`scripts/destroy-vault.py <list>`): move to
`.AGENTTRASH/<date>-destruction/`, then delete every reference from survivors, whole
line included when the dead link was its only content. Do not compare link targets
with `pathlib.Path.stem` — it strips real suffixes (`sd.cpp`).
3. Then strip numbers from what remains (`scripts/axe-measurements.py`): delete ledger
sections (noun-LEDS the heading, nothing instructional after it), blank any table cell
reporting a rate, fold Eras older than the current one into a single row pointing at
git. Never touch a shape number or a Laws/Wire/Rollback section.
4. Sweep indices (generator must exclude the trash dir, drop rows whose page cell became
empty), re-lint, re-check links.
5. Rewrite the governing policy in the same PR, or the next agent re-grows the vault:
project `AGENTS.md` *Documentation Policy* + the global agent settings.
## Report honestly, per area
docs / scripts / operator-doc separately, measured against the merge-base. The tools are
a cost that must be named, not hidden inside a headline "net" number — that is what got
the previous pass rejected.
## Verify the repair tools themselves
One repair pass matched "line ends with a comma" and deleted the trailing comma from 660
wrapped prose lines. Restore by matching lines that are byte-identical to `HEAD` except
for trailing punctuation (`restore-punctuation.py`), keep the destructive rule out of the
tool, and record the incident in its docstring.
@@ -0,0 +1,85 @@
---
name: docs-refresh-scout-summaries
description: "Refresh a stale documentation cluster without delegating the writing: scout subagents produce per-line verdict tables (holder verified by comma-insensitive grep), the parent makes every edit, and a port-before-cut census proves nothing was lost. Use for dedupe-our-docs, fix-the-stale-runbooks, pointers-instead-of-copied-numbers jobs."
---
# Refresh stale docs with scout summaries, parent-side writes
Use when a cluster of documentation pages is stale and/or restates facts another page owns, and
the fix touches many files. **Scouts read and classify; the parent writes.** Sibling skill
`vault-one-fact-one-owner-dedupe` covers collapsing era pages into owner pages - its section 3
(orphan census), section 7 (edit-tool hazard) and section 9 (verify/PR) apply unchanged.
## Why not writer subagents (measured cost)
Writer agents produce plausible output and cannot be trusted on the one claim that matters - the
owner page already holds this fact. In one run they asserted every cut was grep-confirmed while
FOUR cuts left pointers to pages that did not contain the fact (118->238 GiB, 16,095 tokens,
0.739/0.554/0.427/0.340, PR #23897). Each became a broken promise the next reader trusts.
Classification is the only part that parallelizes; writing is where taste (ownership, era
naming, what counts as a law) lives. So fan out read-only scouts and keep the pen.
If writer agents were already spawned and are no longer wanted: `write agent://all` to stop
them, then `git status` the target tree - `proc://` does not list agent jobs, and a finished
worker may already have edited files.
## Pipeline
1. **Cluster** targets into groups of 3-4 pages sharing subject matter (one cluster per scout).
Know the owner map first (endpoint/service/host pages, plus the repo documentation law).
2. **Wave of 2 scouts** (harness cap is `task.maxConcurrency`, usually 2). Scout contract:
> READ-ONLY, no writes of any kind. Per file, one table row per candidate fact:
> `| lines | verbatim figure + 6 words | holder elsewhere (file:line) or none | VERDICT |`
> Verdicts: `CUT -> [[Owner]]` (holder verified), `KEEP(owned here)` (no holder, this page is
> the record), `PORT(parent)` (rightful owner lacks it - name page and section), `STALE`
> (contradicted by current state; quote the contradicting line), `INDEX` (locator page).
> Verify every holder claim with a COMMA-INSENSITIVE grep: a holder writing 1,350,182
> satisfies a page writing 1350182 and vice versa; 16,095 does NOT satisfy a check for 16095
> unless commas are stripped. Never name a holder line you have not read.
> End with a per-file tally: kept / cut / port / stale.
Put the law, the owner map and the verification rule in the batch-level `context`, never per
task.
3. **Write while the next wave runs**: edit cluster N from wave N, launch wave N+1 in the same
turn. Never launch a wave you have nothing to write.
4. **Verify each CUT yourself** before cutting - read the holder line. A scout row is a
hypothesis, not evidence.
5. **PORT items are your edits**: add the fact to the owner page section (Eras row / Laws / spec
table), then cut it from the technique page in the same commit.
6. **STALE items: check live state**, not another document - `ssh <host> systemctl is-active
<svc>`, the endpoint `/health`, the env file. Then rewrite with a date, or drop transient
live state an owner page already records.
## The ownership test that stops wasted ports
Two pages carrying one figure is the defect - a figure with exactly one holder is fine wherever
it lives. A technique page that is the sole record of a campaign owns that campaign's arm
tables; do not port such ledgers into an endpoint Eras table. Write the clause into the repo
documentation law so the next pass stops porting single-owner facts. Corollary: a worked example
may repeat a live number if it declares itself dated and names the owner (a snapshot, not fleet
state - recompute from the live files).
## Census after each cluster (catches your own slips too)
Run the sibling skill's section 3 census over `git diff -U0` for the cluster (deleted lines x
distinctive numeric keys vs the whole vault **plus AGENTS.md**), comma-insensitively; drive the
count to zero or list each accepted loss in the commit message.
```python
flat = corpus.replace(',', '')
pres = lambda n: n in corpus or n.replace(',', '') in flat
toks = lambda l: [n for n in re.findall(r'\d[\d,]*(?:\.\d+)?', l)
if len(n.replace(',', '').split('.')[0]) >= 4
or (len(n.split('.')[-1]) >= 2 and len(n.replace(',', '')) >= 5)]
orphan = [l for l in deleted if toks(l) and not any(pres(n) for n in toks(l))]
```
## Verify and land
- Vault link seam must print `0 unresolved links`; hard-wrapped `[[Two Word Page]]` links split
across lines are the usual cause - unwrap them.
- Full suite once at the end, not per cluster; a docs seam running while scouts are live may
read half-written files, so re-run after the last edit.
- Commit per cluster (docs-only means **no deploy**); push, open the PR through the API with a
600-perm curl config, `xdg-open` the PR URL immediately.
@@ -0,0 +1,88 @@
---
name: dotfiles-adoption-audit
description: "Use when deciding which userspace configs on a Dank box belong in the command-center dotfiles tier — separates tracked intent from generated expansion, app state, secrets, and config owned by another repo. Not for deploying one known file."
---
# Dotfiles adoption audit
Decides, with evidence, which files under `~/.config` (and top-level dotfiles) belong in
`dank/command-center`'s `dotfiles/` tier. The repo law being applied: **config in, state
out**, and **one fact, one owner**.
## The four gates (all four must pass)
1. **Installed?** `command -v <prog>` — config for software absent from the box is a prune
candidate, never an adoption target. This eliminates most of the clutter.
2. **Authored, or the expansion of something else?** Generated files must stay out even
though they look like config.
3. **Does another repo or installer already write it?** If a script or package owns the
file, tracking it here creates a second owner that loses every argument.
4. **Could a secret ever live in it?** If yes: template + quarantine, or leave it out.
## Gate 2 is the one people get wrong — prove generation from mtime
A generator walking the app list writes dozens of files in the same sub-second window.
Compare timestamps across unrelated programs:
```bash
cd ~/.config && stat -c '%y %n' */themes/noctalia* */noctalia.* */colors/noctalia* 2>/dev/null | sort
```
Identical seconds + microsecond progression across ten different programs = generated.
Re-check one file days later: a new mtime with no human edit proves regeneration, so the
tracked file must be the *source* (palette/config the generator reads), never its output.
The correct pattern is a tracked config that *imports* the generated path.
## Classify the whole tree per file, not per directory
The tracked unit is the **file symlink**, so a directory can be "real" while containing only
tracked symlinks. Never classify by directory:
```bash
cd ~/.config && for d in */; do d=${d%/}; tr=0; rl=0; rlfiles=""
while IFS= read -r f; do
case "$(readlink -f "$f" 2>/dev/null)" in
*/.omp/dotfiles/*) tr=$((tr+1));;
*) rl=$((rl+1)); rlfiles="$rlfiles ${f#./}";;
esac
done < <(find "$d" -maxdepth 2 \( -type f -o -type l \) 2>/dev/null \
| grep -vE 'Cache|cache|Cookies|storage|sessions|Crashpad|Default|thumbnails|extensions')
[ $((tr+rl)) -eq 0 ] && continue
printf '%-22s tracked=%-3s box-only=%-3s %s\n' "$d" "$tr" "$rl" "$(echo $rlfiles | cut -c1-90)"
done
```
Then read the headers of suspicious files — installers often confess ("Installed by
`infra/setup-dev.sh` into `~/.config/containers/systemd/`") and unit files name another
project's `WorkingDirectory`/`ExecStart`, which means that project owns them.
## Ownership before adoption
```bash
git -C ~/.omp ls-files dotfiles # packages deploy.sh sees
grep -rl "<unit-or-file-name>" ~/Projects/<proj> # does a project repo own it?
systemctl --user is-active <unit> # dead config is not load-bearing
```
## Secret-gap check (do this before adopting anything env-shaped)
The ignore list may cover only `.env`, so `frank-job-hunt.env` / `postgres.env` fall
through, and a token-shape secret gate (`sk-`, `ghp_`, PEM, JWT) does not match a
low-entropy `POSTGRES_PASSWORD=…`. Test both explicitly:
```bash
cd ~/.omp && git check-ignore -v --no-index 'dotfiles/x/postgres.env' || echo "NOT IGNORED"
sed -n 's/^patterns=//p' .githooks/pre-commit
```
Fix = add `*.env` to the quarantine block **and** a password-assignment pattern to the gate,
with a red test that stages a fake `POSTGRES_PASSWORD=` file.
## Land it
Packages are home-relative trees under `dotfiles/<pkg>/`; per-host overrides go in
`zz-$(hostname)` (stowed last). `dconf` is loaded via `dconf load /`, `systemd-user` gets
`daemon-reload` + conditional `enable --now`. Sequence: `scripts/deploy.sh --check` (dry run,
reports divergents) → adopt → `deploy.sh` → confirm live paths are symlinks into the repo →
`bats tests/` (includes the real-bundle `kb-lint`/coverage gate) → branch off `origin/main`,
PR to `main`, never merge without Frank's OK.
@@ -0,0 +1,76 @@
---
name: dotfiles-deployment-truth-audit
description: "Answer \"is my X config actually tracked and deployed?\" per package: classify every file by readlink, expose tracked-then-ignored debt, and re-derive census numbers from the command printed on the page."
---
Answer to "does this repo still track my <pkg> settings?" must be a measurement, never a remembered count. 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.
Run in the deployment checkout (the checkout IS the deployment).
## 1. Tracked set, per package, both tiers
```bash
cd ~/.omp # or the repo that owns dotfiles/
git ls-files dotfiles/<pkg> # base tier
git ls-files dotfiles/zz-$(hostname) | grep <pkg> # host override tier — counted separately
```
The `zz-*` tier overrides base files by the same relative path, so a per-package count
that ignores it under-reports what a machine actually runs.
## 2. Live wiring — classify every tracked file (never by directory)
```bash
for f in $(git ls-files dotfiles/<pkg>); do
rel=${f#dotfiles/<pkg>/}; live="$HOME/$rel"
if [ -L "$live" ]; then printf 'link %-50s -> %s\n' "$rel" "$(readlink "$live")"
elif [ -e "$live" ]; then printf 'REAL %-50s (NOT a link)\n' "$rel"
else printf 'absent %s\n' "$rel"; fi
done
```
- `REAL` = the repo copy is a fiction: live state diverged, deployment never reached it.
- `absent` = tracked relic, nothing consumes it (candidate for `git rm --cached`).
- Spaces in filenames break the `for` loop (`palettes/Gruber Red.json` splits into two
rows). Use `git ls-files -z | tr '\0' '\n'` + `while read -r` when names can hold spaces.
- Reverse direction finds untracked-but-load-bearing files:
`find ~/.config/<pkg> -maxdepth 2 \( -type f -o -type l \) | while read -r p; do case "$(realpath "$p")" in $PWD/dotfiles/*) ;; *) echo "not from repo: $p";; esac; done`
## 3. Live state that must stay untracked
Generated palette output and app state are expansion, not authorship. Distinguish by
mtime cluster, not by name: `find ~/.local/state/<app> -type f -printf '%TY-%Tm-%Td %p\n'` —
a same-second mass write is a generator; single later re-stamps are re-runs. Pointers a
switcher script writes (e.g. `cfg/display.kdl -> desk-1-home.kdl`) are machine state too.
Then check the hazard this creates: grep the entrypoint for includes of those generated
files. A **non-optional** include of a correctly-untracked generated file bricks a fresh
box (`include "noctalia.kdl"` with no `optional=true` = niri cannot start until the theme
pipeline has run once). Report it as a fresh-box failure, not a style note.
## 4. Tracked-then-ignored debt (invisible to every normal check)
```bash
git ls-files dotfiles | grep AGENTTRASH # trash committed inside the tree
git check-ignore --no-index -v <that path> # does an ignore rule match it?
```
`.gitignore` patterns without an interior slash (`.AGENTTRASH/`) match at **any depth**, so
the rule was fine all along — the file simply entered history *before* the rule existed, and
an ignore rule never reaches an already-tracked path. Only repair: `git rm --cached <path>`
plus moving the working copy to the root `.AGENTTRASH/` (never `rm`). `git check-ignore`
without `--no-index` prints nothing for tracked paths and reads as "not ignored" — that
false negative is the trap.
## 5. A census number is only valid with its command
If a page prints a classifier command, every number on it must be reproducible by running
that command. A row that the command does not reproduce was written from memory — wrong, not
merely stale. Fix the row, and state the correction in the commit message rather than
silently editing the number.
## Verification before landing
`bats tests/`, `scripts/kb-lint.sh omp-docs`, `scripts/kb-coverage.py omp-docs`, and
`scripts/deploy.sh --check` **from the worktree** — `--check` is read-only; never run the
real deploy from a worktree, it repoints live symlinks into the worktree the heartbeat reaps.
@@ -0,0 +1,80 @@
---
name: dotfiles-tier-live-audit
description: "Audit the command-center dotfiles tier before running deploy.sh: prove per-file tracked/deployed state, refuse generated theme output, catch stale zz overrides, wrong-box dconf keys, and unresolvable systemd ExecStart specifiers."
---
# Deploy-tier audit (`~/.omp` command center)
Run **before** any real `scripts/deploy.sh` on a box with a live session, and whenever a
symlink, palette, wallpaper or unit behaves oddly. Facts and laws live in
`omp-docs/tools/dotfiles-deploy.md` + `omp-docs/inventory/home-config-census.md` — this page
is only the probe order. Do not restate the laws in new files; the vault owns them.
## 0. Classify per file, never per directory
```bash
cd ~/.omp
for f in $(git ls-files dotfiles/<pkg>); do t=$(readlink -f "$HOME/${f#dotfiles/<pkg>/}")
printf '%-50s %s\n' "${f#dotfiles/<pkg>/}" "$([ -L "$HOME/${f#dotfiles/<pkg>/}" ] && echo LINK || echo real)"
done
```
A live directory can be real and still fully tracked (every file a symlink). `niri` 8/2,
`gtk-3.0` 3/0. `zz-$HOST` files count in the `zz` tier's row, not the base package's.
## 1. The plan, and how to read it
```bash
./scripts/deploy.sh --check # writes nothing; read the last line, not the RELINK lines
```
`--check` prints `RELINK` for **every** path the `zz` tier carries even when the live link
already points at the override (base is walked first, intermediate owner change reported).
Only `actions=0` is a real summary; confirm a pending change with `readlink`.
## 2. Generated theme output must never be tracked or deployed
```bash
git ls-files dotfiles | grep -Ei 'noctalia\.(kdl|toml|json|conf|css)|themes/noctalia'
```
Hit = generated palette output (same-millisecond mtime cluster proves it). Untrack
(`git rm --cached` + `.gitignore` patterns); `deploy.sh` refuses the class (`SKIP … generated
theme output`). Tracking one means the next sync quarantines the user's live palette and
restores the committed colours.
## 3. Stale `zz` overrides
For each path present in both base and `zz-$HOST`: `diff` them, then compare each file's
`git log -1 --date=short`. A `zz` copy older than the base change is a time bomb — sync
relinks cross-package owners **silently** (`nothing to quarantine`). Delete the `zz` copy in
the same commit that base absorbed.
## 4. dconf: compare key by key, never with `diff`
`dconf load /` writes only keys present, so a text diff misleads both ways. Parse both sides
into `[section]/key → value` and report: value conflicts, keys the dump would newly write,
and any value containing `/home/` (foreign-box state). File-chooser `last-folder-path`,
`window-state`, `picture-uri*` are state, not preferences — delete them from the dump and
clear the live keys if they were already deployed here.
## 5. Units: state, then resolvability
```bash
for u in <units in dotfiles/systemd-user>; do printf '%s %s %s\n' "$u" \
"$(systemctl --user is-enabled $u)" "$(systemctl --user is-active $u)"; done
```
`is-enabled` exits **1** for `linked` exactly as for `disabled` — read the printed word.
Never start a unit that is already installed: `swayidle-lock` sits `enabled`/`inactive` and
`--now` arms a screen lock mid-session. Specifiers are systemd's, not the shell's: probe
`command -v "${bin//%h/$HOME}"` before believing `ExecStart … absent on this host` — the
literal `%h/…` string made this repo's own heartbeat look foreign on every box.
## 6. Landing it
`bats tests/` (deploy contract runs `scripts/deploy.sh` in a scratch repo with `DEPLOY_REPO`,
`DEPLOY_HOST`, stub `HOME`, stub `systemctl` on `PATH`) + `scripts/kb-lint.sh omp-docs` +
`scripts/kb-coverage.py omp-docs`. Branch off `origin/main`, PR, never merge without Frank's
OK (`gitea-merge-form-encoded`: form-encoded `do=merge`, `merge_style=no-ff`).
Then deploy from the **deployment root** `~/.omp` only — never from a worktree (live symlinks
would point into a checkout the heartbeat reaps in 7 days). Acceptance: `actions=N
quarantined=0`, two consecutive `--check` runs both `actions=0`, no `ENABLE … started` for a
unit that was already installed.
@@ -0,0 +1,34 @@
---
name: e2e-port-squat-triage
description: "When an e2e seam on a fixed port fails weirdly (404s on routes that exist, stale behavior, health probe passes but app seems ancient), audit for leftover dev/debug servers squatting the port before believing any red or green."
---
# E2E fixed-port squat triage
## Symptom classes (all seen with a stale server squatting the seam's fixed port)
- Routes that exist in current `dist`/source return 404 ("Cannot POST /api/...") while unit tests through the same app factory pass.
- A seam that is *green* but exercises old code: its setup curls `http://host:PORT/health` and gets a response from **whoever owns the port** — a leftover server, not the one it just spawned (the spawned one dies or idles with EADDRINUSE, sometimes still showing in `pgrep`).
- Behavior matches an older build (missing mounts/guards) despite a fresh build step.
## Triage recipe (before touching code)
1. Who owns the port:
```bash
ss -tlnp 2>/dev/null | grep :PORT
```
2. List every server process with **start times**:
```bash
for p in $(pgrep -f "path/to/server-entry"); do ps -o pid,lstart,cmd -p $p --no-headers; done
```
3. Compare start times against the build artifact timestamps (`ls -la dist/...`). A server older than the dist you just built is a liar — its in-memory modules predate the code under test.
4. Kill all leftovers, then re-check: `pgrep -f "server-entry" || echo all-dead`.
5. Rerun the seam. Only now is red/red and green/green meaningful.
## Why it happens
- `kill %1` in **non-interactive** bash fails silently (no job control) — use explicit PIDs or `pkill -f <pattern>`.
- Seam setups that "wait for health" cannot distinguish their own server from a squatter on the same port; a pre-flight "port must be free" assertion is the permanent fix (assert *before* spawn).
## Hardening (if you own the seam)
Add to setup, before spawning: fail fast if the port is already listening — turns the silent liar into a loud error:
```bash
ss -tln "sport = :PORT" | grep -q LISTEN && { echo "port PORT busy — kill leftovers first" >&2; return 1; }
```
@@ -0,0 +1,35 @@
---
name: fixture-delay-window-race-test
description: Pin a race/abort bug in a fixture-server test by delaying only the Nth request so the interruption lands inside the in-flight window; assert via a driver-installed unhandledRejection reporter.
---
# Reproducing a timing-window bug against a fixture server
Use when a defect only appears in a window — a promise created before it is awaited, a kill/abort landing mid-flight, a prefetch interrupted — and a fixture HTTP server answers instantly, so the window never opens. Earned in `dankvoice` issue #7 (`[Unhandled Rejection] AbortError` from `/say stop` during a pipelined synthesis).
## Why naive attempts pass vacuously
1. **Uniform delay on the fixture route** (`DV_LATENCY_MS` for every request) makes *all* requests resolve at the same time, so by the time the interruption fires nothing is in flight. The test goes green against the broken code — worse than no test.
2. Asserting on a log line count that also counts interruption noise: `dankvoice`'s driver prints `PLAYS <n>` as the number of **player log lines**, and the stub appends `KILLED` on SIGTERM — so a single playback with one interrupt reports `PLAYS 2`. Count the payload lines instead (`grep -c 'PLAYER .*\.wav$'`).
## The shape that works
- **Delay only requests after the first.** The first response gets the pipeline into playback; the second stays in flight, which is the window:
```ts
const spoken = received.filter((c) => c.url?.endsWith("/audio/speech")).length; // already pushed
const delay = spoken > 1 ? Number(process.env.DV_SYNTH_MS ?? 0) : 0;
if (delay > 0) setTimeout(answer, delay); else answer();
```
- **Make the interruption land inside it.** Player holds long (`DV_HOLD=2`), interruption at a fixed offset (`DV_STOP_AT=400`) with the delayed response far later (`DV_SYNTH_MS=1200`): margins wide enough that the abort is neither before the request nor after the response.
- **Turn unhandled rejections into observable output** in the test driver instead of letting the runtime crash on them — that is also exactly what the real harness does to the user:
```ts
process.on("unhandledRejection", (reason) => {
console.log(`UNHANDLED ${reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason)}`);
});
```
Then assert `[[ "$output" != *"UNHANDLED"* ]]` plus the concrete behavior (segments actually played, player killed).
- **Leak checks measure a delta, not an absolute:** count `dankvoice-*` temp dirs before and after the run and compare; an absolute `== 0` fails on another test's leftovers.
## Order
Write the scenario first and watch it fail **on the rejection assertion specifically** — if it fails on some other assertion (a count, a kill line), the window is wrong, not the bug. Then fix, then confirm red→green on that one assertion before running the full suite.
@@ -0,0 +1,39 @@
---
name: fleet-host-process-service-wiring
description: "Add a host-process (non-quadlet) systemd USER service/exporter to serverAppConfigs — every file that must change together, with the signal-relay precedent and the Prometheus gateway/LAN reach rules."
---
A host-process service (runs `python3`/a binary on the host, NOT a container) is the right shape when the thing must read host-only state (DRM/cgroup sysfs, `shells to nvidia-smi`, signal-cli) that a container cannot reach without passthrough. Precedent to mirror: `scripts/signal-relay.{py,service}` + `install_signal_relay` in `deploy-remote.sh`. A new one landed 2026-10-20: `scripts/gpu-exporter.{py,service}` + `deploy_gpu_exporter`.
## The unit is a USER unit, not a quadlet
- Hand-written `scripts/<svc>.service`: `[Service]` `ExecStart=/usr/bin/python3 /home/dank/serverAppConfigs/scripts/<svc>.py` (runs straight from the git checkout — a pull updates it, no copy step), `Restart=always`, `[Install] WantedBy=default.target`.
- It CAN be `systemctl --user enable`-ed (generated quadlet units cannot on this host's podman generator). With `Linger=yes` for `dank`, `[Install]`+`enable` is the whole boot story.
## Every file that MUST change together (a lone change on one side fails silently)
1. `scripts/<svc>.service` — the unit. Base binds **loopback only**; the LAN address is added by a deploy **drop-in**, so the checked-in unit is never a wildcard.
2. `scripts/deploy-remote.sh`:
- a `deploy_<svc>()` function: `cp` the unit to `~/.config/systemd/user/`, write a `<svc>.service.d/bind.conf` drop-in with the per-host `Environment=..._BINDS=127.0.0.1,<lan>` (map LAN from `$TARGET`: dankclaw=192.168.0.143, dankserver=192.168.0.235 — do NOT hardcode one host if it runs on both), `daemon-reload`, `systemctl --user enable`+`restart`, then a **loop health gate** (`for i in $(seq 1 15); curl -fsS -m5 http://127.0.0.1:<port>/metrics && return; sleep 1`) — a one-shot curl races the bind and once failed a healthy deploy.
- a dispatcher branch `<svc>) deploy_<svc> ;;` in the central `case "$service"` (near the observability branch).
- an **allowlist entry on EVERY target** it runs on: `dankclaw:<svc>` and/or `dankserver:<svc>` (the allowlist `case` around the top of the file).
3. `scripts/deploy-plan.py`: add `"<svc>"` to `SERVICE_ORDER`, to `DANKCLAW_SERVICES`/`DANKSERVER_SERVICES` (whichever it runs on), and a `scripts/<svc>.*` path-map in the `parts[0] == "scripts"` block: `if name in {"<svc>.py","<svc>.service"}: _add(services, "<svc>")`. `target_services` then filters per target.
4. `scripts/test-changed.sh`: a `case` routing `scripts/<svc>.py|scripts/<svc>.service` → its seam + `deployment_scoping.bats` (place before the generic fallbacks). `tests/run.sh` runs all `*.bats` regardless, so the mandatory gate is covered even if this route is missing — but add it for focused runs.
5. `shared/prometheus/prometheus.yml`: one scrape job per host, using the **reach rules** (below).
6. Dashboard JSON + the owning page under `serverAppConfigs-docs/Infrastructure/Observability.md` (one owner per fact).
## Prometheus reach rules (the whole cross-file contract)
Prometheus is a **bridge container**. On this netavark host a bridge container CANNOT dial the host's own LAN IP at a host-bind (only at a *published* port). Therefore:
- **Same host (Dankclaw):** scrape `host.containers.internal:<port>` (netavark delivers it to the host LAN bind). Scraping `192.168.0.143:<port>` reads DOWN forever even though the endpoint is healthy.
- **Different host (Dankserver):** plain LAN `192.168.0.235:<port>` reaches it.
- Never target a `*.franksdank.cloud` vhost — Prometheus cannot solve an Anubis challenge; it is red by construction.
- The exporter must bind BOTH loopback and the host LAN (like `node-exporter`/`cadvisor`), so `host.containers.internal` and the LAN both land.
## Firewall
Leave it LAN-open like node-exporter/cadvisor (unauthenticated metrics on the LAN is the repo's exporter posture). Only signal-relay gets an iptables allowlist (loopback+host+`10.89.0.0/16`+DROP) because it is an unauthenticated message-SENDING webhook — do not copy that lock onto a read-only exporter.
## Seam test (tests/<svc>.bats) pattern
- Black-box the binary with injectable inputs (fixture dirs / stub commands), assert only consumer-visible output; assert **absent means absent** (anchor guards to a sample line `^metric{`, not the always-emitted `# TYPE` header — grepping the bare family name matches the header and false-fails).
- Cross-file contracts (grep the other files): the unit's `ExecStart`/bind posture, the deploy function + allowlist + dispatch + health gate, the planner selection (run `deploy-plan.py --target T --paths scripts/<svc>.py`, assert output == `<svc>` per target), the prometheus targets, the dashboard `json.load` + that its `targets[].expr` reference the emitted families.
## Gotchas hit while building gpu-exporter
- Values are float-normalised (`397.40`→`397.4`); a test pinning the literal fails — assert the normalised value. Derive expected byte counts from real units (MiB×1048576, sysfs µW/1e6, temp1_input/1000), don't guess the multiply.
- `xdg-open <PR-URL>` after opening the PR (repo law) — don't make Frank hunt in Gitea.
@@ -0,0 +1,62 @@
---
name: fleet-service-runtime-census
description: "Answer \"what is service running right now?\" for a dank-fleet service: live endpoint probe plus host units, image ids, live-vs-repo env, and drop-ins."
---
# Fleet service runtime census
Answer the question "what is `<service>` running currently?" with evidence, not from docs. Docs say what *should* run; the host says what *does*. Run all five probes, then report one table.
## 1. Probe the published port (works from any LAN box, no ssh)
```bash
curl -s -m 10 http://<host-ip>:<port>/health; echo
curl -s -m 10 http://<host-ip>:<port>/v1/audio/voices # per-endpoint extras: /v1/models, /metrics
```
The extra route matters more than `/health` for identity claims:
- llama.cpp and radiance **echo the requested model name** — a 200 proves nothing; read `/v1/models`.
- halogen 404s `/completion`, `/ping`, `/props`, `/tokenize`.
- The frank-tts façade answers `/health` **without dialing the engine**, so "façade healthy, engine down" is a real state; the voice list in that same response is the honest census.
## 2. Units + containers on the host (ssh as the deploy user, no sudo)
```bash
ssh <host> 'systemctl --user status <svc>.service <engine>.service --no-pager \
| grep -E "^(●| Active| Loaded)"; \
podman images --format "{{.Repository}}:{{.Tag}} {{.ID}} {{.Created}}"; \
podman ps -a --format "{{.Names}}\t{{.Status}}\t{{.Image}}"'
```
- `active (running) since …` + `Up N hours (healthy)` is the answer; `activating (auto-restart)` with a ~50 ms exit is a **preflight refusal**, not an engine fault.
- **NEVER prefix these podman calls with `sudo`.** Containers live in the deploy user's rootless storage; `sudo podman inspect frank-tts-engine` returns `no such object` while the plain form succeeds. Treating that as "container missing" is a false finding.
## 3. Drop-ins — the override that no health gate sees
```bash
ssh <host> 'systemctl --user show <svc>.service -p DropInPaths; \
systemctl --user show <engine>.service -p DropInPaths'
```
`DropInPaths=` must be empty. A leftover bench drop-in (`Environment=FRANK_<SVC>_ENV_FILE=/tmp/<arm>.env`) makes the next deploy boot a different model at a different shape while `/health` still answers 200 and the e2e gate passes.
## 4. Live env vs repo env — the only truth test
```bash
# repo side (local checkout)
grep -vE '^\s*#|^\s*$' scripts/<svc>.env
# host side
ssh <host> 'sudo cat /home/dank/serverAppConfigs/scripts/<svc>.env' | grep -vE '^\s*#|^\s*$'
```
Diff them. Per repo law the env file beside the serve script **is** the authority; a doc page that disagrees with it is the bug, and host-side drift means an uncommitted edit. Also check `git log --oneline -3 -- scripts/<svc>.env` to confirm the host isn't ahead of git.
## 5. Report
Table of: unit state + since, image repo:tag@short-ID + age, backend/alias actually served (from step 1, not from the name), drop-ins, and whether live env == repo env. State explicitly when a fact came from docs rather than from a probe.
## Traps that produce false findings
- Digest-pinned images: the local image ID's *creation date* answers "is this stale" better than the tag; `latest` + `Pull=never` is a locally built image, not a registry pull.
- `ssh` user context changes what podman sees (root vs rootless) — see step 2.
- Two ports on one host are two providers in `models.yml`; a port mismatch is a rename or drift, not a config typo.
@@ -0,0 +1,56 @@
---
name: fleet-status-daily-report
description: "Answer \"status update / what happened yesterday\" for the dank fleet: git-window record on origin/main plus a live per-host census (endpoints, units, RAM, DNS), with the reboot-attribution check that stops false incident causes."
---
# Fleet status report (Dankclaw + Dankserver)
Use for "what's the state of the fleet", "what happened yesterday", or a post-incident sweep. Read-only.
For one service's live state, prefer `fleet-service-runtime-census`; this is the fleet-wide sweep.
## 1. The day's record (repo is the ledger)
```bash
git fetch origin --quiet
git log --no-merges --since="YYYY-MM-DD 00:00" --until="YYYY-MM-DD+1 00:00" \
--date=format:'%H:%M' --pretty='%ad %h %s' origin/main
git log --merges --since=... --pretty='%ad %h %s' --date=format:'%H:%M' origin/main # PR numbers
```
Merge lines carry the PR number — that is the review trail; the non-merge log is what actually changed.
Group commits into streams (endpoint names lead the subject) rather than listing 20 subjects.
## 2. Live census (parallel: one call per host + one endpoint sweep)
Endpoint sweep from any LAN box — `/health` is honest on all four (archon binds only after full load):
```bash
for u in 192.168.0.143:7770 192.168.0.143:7771 192.168.0.235:7770 192.168.0.143:7772; do
printf "%s %s\n" "$u" "$(curl -s -m 12 -w ' [%{http_code}]' http://$u/health | head -c 160)"; done
curl -s http://192.168.0.143:7770/v1/models # dragoon lists EVERY engine it serves, incl. NPU-lane models
```
Per host: `uptime -s; uptime -p`; `systemctl show <llm units> -p ActiveEnterTimestamp -p NRestarts -p DropInPaths`
(`sudo` for the root LLM units, `--user` for the quadlets); `list-units --failed`; `free -h`;
`journalctl -k --since "<evening>" -p err`; the host's own engine log tail.
## 3. Attribution rules (where reports go wrong)
- **Never call a host reboot from a container's start timestamp.** Check `uptime -s` first; if uptime spans the
event, the trigger was a **container restart** — find it with
`journalctl --user -u <name>.service --since … --until …` + `podman inspect <c> --format '{{.State.StartedAt}}'`.
(This exact mistake mislabelled a 2026-10-19 Pi-hole incident as boot-caused; the law's remedy belongs after
*any* restart, which is a different action item.)
- `podman stats` is a 28-container wall — sort/limit it; RSS of the LLM tenants is what matters.
- This podman's inspect template has **no `.Config.PodName`, no `.Config.GroupAdd`, and `.HostConfig.Devices`
can read `[]` even when a quadlet `AddDevice=` worked**. Prove GPU use from the engine's own log
(`ggml_vulkan: Found 1 Vulkan devices: … Radeon 8060S (RADV GFX1151)`), not from inspect.
- Kernel lines: **count** occurrences (`journalctl -k --since '7 days' | grep -c "NETDEV WATCHDOG"`) before
calling a one-off a trend.
- Failed units: `systemctl show <u> -p ExecMainExitTimestamp -p ExecMainStatus --value` separates
"broken since boot" from "broke yesterday" — and a nightly failure (e.g. logrotate) is a live risk, not noise.
## 4. Report shape
Stream table (what landed) → live-state table (what is true now) → **corrections to my own earlier record** →
open items that need Frank's decision, each with the evidence line and the one action. Flag anything that would
touch host config outside the repo as a decision, never a silent fix.
@@ -0,0 +1,53 @@
---
name: frank-tts-repoint-voice-consumers
description: "Rotate/rename a frank-tts voice across all consumers (repo DEFAULT_VOICE, Open Notebook speaker profiles over REST, docs) after packs change."
---
## When
frank-tts voice packs on `/home/dank/voices` (Dankclaw) changed — packs added, removed, or renamed — and old voice names (e.g. `narrator1/2/3`) no longer resolve. Consumers still sending a dead name now 400 with `unknown voice 'x'; available voices: ...`. This is a maintenance sweep, NOT a bug; confirm with the operator whether the change is intentional before "fixing".
## Facts that make this non-obvious
- frank-tts (:7772) resolves a voice as a **directory**: `<name>/reference.wav` must exist. `model` field is ignored; the voice comes solely from `voice`/`voice_id`.
- Live census: `curl -s http://127.0.0.1:7772/v1/audio/voices` (and `/health` returns the same list). This is the truth; the vault docs may be stale.
- Two consumers carry voice names, in **different stores**:
- **repo**: `scripts/frank-tts.env` `DEFAULT_VOICE=` — answers a voice-less request (the omp harness's normal shape). Deploy via git.
- **app-side**: Open Notebook speaker profiles (SurrealDB) and OpenWebUI `audio.tts` voice — NOT repo files.
## Step 1 — repo: DEFAULT_VOICE
Edit `scripts/frank-tts.env` `DEFAULT_VOICE=<new real voice>`. Trunk-based: this worktree can't `git checkout -b` (shared-workspace policy); commit on HEAD and `git push origin HEAD:refs/heads/<branch>`, open PR vs `main`. No bats test pins the DEFAULT_VOICE value (the tts bats use `narrator1` as a self-contained fixture that builds its own `reference.wav` — mechanism test, leave it).
## Step 2 — Open Notebook speaker profiles (REST, host-side, takes effect immediately)
Voice ID is **free text passed to the TTS request untouched** (no enum/whitelist). The active fleet profile is `dank-duo` (episode profile `dank-daily` → speaker_config). PW is the host `OPEN_NOTEBOOK_PASSWORD`.
```bash
ssh dankclaw bash -s <<'REMOTE'
PW=$(grep -m1 '^OPEN_NOTEBOOK_PASSWORD=' /home/dank/Containers/open-notebook/env | cut -d= -f2- | tr -d '"')
cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: Bearer %s"\n' "$PW" > "$cfg"
base=http://127.0.0.1:5056/api/speaker-profiles
# census: name -> speaker/voice_id
curl -s -K "$cfg" "$base" | jq -c '.[]|{name,speakers:[.speakers[]|{name,voice_id}]}'
# repoint by NAME (GET-by-name returns full record incl id; PUT whole transformed object to /{id})
curl -s -K "$cfg" "$base/dank-duo" > /tmp/s.json
id=$(jq -r .id /tmp/s.json)
jq '(.speakers[].voice_id) |= ({"OLD1":"NEW1","OLD2":"NEW2"}[.] // .)' /tmp/s.json > /tmp/s2.json
curl -s -K "$cfg" -X PUT -H 'Content-Type: application/json' -d @/tmp/s2.json "$base/$id" -w ' HTTP %{http_code}\n'
curl -s -K "$cfg" "$base/dank-duo" | jq -c '[.speakers[]|{name,voice_id}]' # verify
rm -f "$cfg" /tmp/s.json /tmp/s2.json
REMOTE
```
- A duo has only 2 speakers — an extra voice is available-but-unassigned, not missing.
- Upstream's seeded profiles (`business_panel`, `solo_expert`, `tech_experts`) use OpenAI names + `voice_model:null`; they aren't wired to frank-tts — leave them.
## Step 3 — OpenWebUI (check before touching)
`curl -s http://127.0.0.1:<webui-port>/api/config | jq '.audio.tts'` (port from `podman ps`). If `engine`/`openai.*` are null, the active path is `browser-kokoro` (synthesizes in-browser) and the stored voice is **dormant, not broken** — flag, don't edit a live UI unasked.
## Step 4 — docs (point of use, one owner)
Update the census + speaker-mapping lines in `serverAppConfigs-docs/Dankclaw/Frank TTS.md` and `Open Notebook.md` to the live packs. Leave DATED measurement logs (e.g. a `2026-10-19` `/health` probe) as history. Do NOT chase `scripts/voice-packs.env` / `Voice Packs.md` narrator builder defs — ask the operator whether those packs are being rebuilt or retired.
## Verify (behavior, not config text)
Synthesize each new voice and expect 200 + distinct bytes:
```bash
curl -s -o /dev/null -w '%{http_code} %{size_download}B\n' -X POST http://127.0.0.1:7772/v1/audio/speech \
-H 'Content-Type: application/json' -d '{"model":"voxcpm2","input":"test","voice":"<name>"}'
```
`tests/run.sh` EXIT=0 before commit. `DEFAULT_VOICE` only lands after merge + `./scripts/deploy.sh dankclaw --service frank-tts`; run that from the dev `main` checkout (`/home/blank/Projects/serverAppConfig`), NOT the agent worktree (deploy.sh refuses a non-`main` branch and pushes `main` forward — it must run on the dev box, not the target).
@@ -0,0 +1,25 @@
---
name: frank-tts-voice-locate
description: "Answer fleet-voice questions (where voices are stored, add a voice) by jumping straight to the serverAppConfig vault pages and the live 7772 roster probe, instead of re-searching Gitea for a frank-tts repo that does not exist."
---
# Frank-TTS voice facts: skip the search, go to the owners
Questions like "how do I add a voice?", "where are voices stored?", "why is narratorN missing?" are already answered elsewhere. The frank-tts code does NOT live in a Gitea repo named frank-tts (repo search finds nothing; it is `dank/serverAppConfigs`), and the engine repo is upstream qwentts.cpp pinned at `5916bf7c`.
## Resolution order (one grep, no repo search)
1. **Vault pages (canonical, all of it lives here)** in the local checkout `~/Projects/serverAppConfig`:
- `serverAppConfigs-docs/Dankclaw/Voice Packs.md` — pack anatomy, the builder, the 10-second source invariant, "Adding a fourth voice" steps, engine-bump arithmetic.
- `serverAppConfigs-docs/Dankclaw/Frank TTS Engine.md` — registry law (memory-only; warm hook), routes, Vulkan device law, troubleshooting table.
- `serverAppConfigs-docs/Dankclaw/Frank TTS.md` — the 7772 facade contract.
- Code/seam: `dankclaw/voiceservice/`, `scripts/build-voice-pack.sh`, `scripts/voice-packs.env`, `tests/voice_pack_unit.bats`.
2. **Live roster** (authoritative over any doc census): `curl -s http://192.168.0.143:7772/v1/audio/voices` (bare JSON array; `/health` also echoes `voices`). A missing name = its dir lacks `reference.wav` or the name is not lowercase — check `/home/dank/voices/<name>/` via `ssh dankclaw`.
## Facts that cost turns to re-derive
- Voice storage: `/home/dank/voices/<name>/` on Dankclaw (host state, never git; RO mount at `/voices`). Files: `reference.wav` (24 kHz mono, 18–25 s, existence gate), `reference.spk` (8192 B), `reference.rvq` (5456–6864 B), `manifest.json`.
- Roster = facade directory scan; engine registry is in-memory, filled by `podman exec frank-tts-engine /app/warm-voices.sh` (boot `ExecStartPost`; re-register replaces, restart costs 6–8 s/voice).
- Clients (omp-dankvoice, OpenWebUI) store NO voices — they GET `/v1/audio/voices`; only the chosen name persists client-side (`~/.omp/cache/dankvoice.json` `voice`).
- Crop law: past first 10 s (hard invariant), 20–25 s preferred, floor 19.84 s for `.rvq`; sources pinned by sha256 in `scripts/voice-packs.env`; sample media live on workstations (`/home/blank/audiosample*`) and must be copied to `/home/dank/voice-sources` before a build.
- Engine `GET /v1/audio/voices` returns `{}` at the pinned commit — always ask the 7772 facade, never 8880, for the list.
@@ -0,0 +1,47 @@
---
name: frank-tts-voice-pack-audit
description: Census live frank-tts voice packs against scripts/voice-packs.env to catch name-drift (renamed/retired packs) and untracked CLI-built packs; use after any voice add/rename when reconciling the builder recipe with what is actually on disk.
---
# frank-tts voice-pack audit (builder ↔ live census)
Read-only census answering: *does `scripts/voice-packs.env` still describe the packs that actually exist, and is every live pack reproducible from the repo?* Run it after anyone adds or renames a voice. The runtime being healthy (all voices synthesize) does NOT mean the builder is honest — the gap is reproducibility, not serving.
## Why this exists
Packs are host state under `/home/dank/voices` (never in git). A voice can be:
- **built by the env** (a `VOICE_PACK_<NAME>_*` block exists), or
- **built by CLI override** (`build-voice-pack.sh <name> <dir> --source --offset --duration --sha256` with NO env block) — then its only provenance is `/home/dank/voices/<name>/manifest.json`, and `--all` silently skips it.
Renaming a pack directory (e.g. `narrator1` → `wilwheaton`) preserves the crop bytes and its source sha256, so the env recipe stays *correct but keyed under a dead name*. `build-voice-pack.sh --all` then rebuilds the dead names and skips the live ones.
## Procedure (on Dankclaw)
1. **Live set + which builder made each** — the manifest `kind` field tells them apart:
- `kind: "voice-pack"` → built by `scripts/build-voice-pack.sh` (env or CLI override).
- `kind: "voice-reference-pack"` → the older trial `pipeline.sh`.
```
for d in /home/dank/voices/*/; do n=$(basename "$d"); [ -f "$d/reference.wav" ] \
&& printf '%-16s %8sB %s\n' "$n" "$(stat -c%s "$d/reference.wav")" "$(stat -c %y "$d/reference.wav" | cut -d. -f1)"; done
```
2. **Per-voice provenance** — `manifest.json` is the sole record for CLI-built packs. Read `name/voice`, `kind`, `source.path`, `source.sha256`, `crop.offset_seconds`, `crop.duration_seconds`. Use `python3 -c 'import json,...'`, NOT a `jq '{a,b,c}'` object — the brace form gets brace-expanded by the remote zsh and errors.
3. **Builder tracked list**:
```
grep -E '^VOICE_PACKS=|^VOICE_PACK_[A-Z0-9_]+_(SOURCE|OFFSET|DURATION|SHA256)=' /home/dank/serverAppConfigs/scripts/voice-packs.env
```
Diff the names in `VOICE_PACKS` against the live dirs. Three findings to report:
- env keys that have **no live dir** (renamed/retired),
- live dirs with **no env block** (CLI-built, unreproducible from repo),
- matching source **sha256** between a dead env key and a live dir = that live pack is the renamed crop (safe to re-key the env, don't recompute).
4. **Distinguish CLI-built from uncommitted env drift** — check the deployed env matches the repo:
```
cd /home/dank/serverAppConfigs && git diff --stat HEAD -- scripts/voice-packs.env # empty = no drift
```
Empty diff + a `kind: "voice-pack"` manifest with no env block ⇒ built via CLI overrides (correct, just untracked), NOT a rogue remote edit.
## Repoint the consumers (separate concern)
If voices were renamed/retired, the runtime consumers still point at dead names — see the `frank-tts-repoint-voice-consumers` skill. Consumers that name a voice: `scripts/frank-tts.env` `DEFAULT_VOICE`, Open Notebook `dank-duo` speaker profile (`voice_id`, app-side REST), OpenWebUI `audio.tts.voice` (config DB). A voice-less request to frank-tts 400s naming `DEFAULT_VOICE`, so a dead default is a live break, not just cosmetic.
## Gotchas
- **A published port is reachable at `host.containers.internal` too.** OpenWebUI's docs say use the LAN IP because that name "doesn't resolve on the bridge" — true only for *non-published* ports. frank-tts 7772 IS published, so `host.containers.internal:7772` answers 200 from the open-webui container. Verify before calling a URL the cause; the real break is usually the dead voice name.
- **Source custody is split**: `audiosample*.mp3/m4b` live on the workstation (`/home/blank`), newer sources on Dankclaw (`/home/dank/voice-sources`). `--all` is only reproducible once each env SOURCE path points at where the file actually is.
- Do NOT edit `voice-packs.env` to add CLI-built voices without confirming each source path + offset + sha against its manifest, then run `./tests/run.sh` (the pack-list invariant in `voice_pack_unit.bats` walks `VOICE_PACKS`).
@@ -0,0 +1,50 @@
---
name: franksign-demo-inspection-stack
description: "Boot franksign's prod-shaped stack, seed all assignment lifecycle states through the real API, and open a headed inspection tour (platform + storybook) without squatting the e2e port."
---
Visual-inspection recipe for the franksign repo (Dank boxes). Proven 2026-10-20; use when asked to "show/open the platform" or tour a new slice.
## Boot (do NOT squat :3100 — the e2e seam curls whoever owns that port)
```bash
node --env-file=.env apps/api/dist/cli.js # binds :3000 by default; use named service
cd apps/web/storybook-static && python3 -m http.server 6007 # gate seam 12 already built it
curl -s http://127.0.0.1:3000/health # {"status":"ok","db":"up"}
```
## Seed lifecycle states through the real API (cookie jar, NOT bearer)
better-auth sign-up returns a bearer `token`, but the org routes resolve sessions
from **cookies only** (`Authorization: Bearer …` → get-session null → 401). Use
`-c /tmp/jar` at sign-up and `-b /tmp/jar` after; state-changing calls need an
`origin:` header. Keep auth headers out of argv via `curl -K <file>`.
1. `POST /api/auth/sign-up/email` `{"email","password"(>=8 chars),"name"}` → save cookies (`-c`)
2. `POST /api/orgs` `{"name"}` → `org.id`
3. `POST /api/orgs/:org/templates` `{"name","baseTemplateId":"sample"}` → `template.id`
4. `POST /api/orgs/:org/templates/:tpl/assignments` `{"email"}` → `{token, signUrl}` (repeat per demo signer)
5. signed state: `POST /api/releases/:token` `{"values":{"name":…,"date":…,"agreement":true},"signatureName":…}` — no auth needed (token seam)
6. voided state: `POST /api/orgs/:org/assignments/:assignmentId/void` (cookie session)
Remaining pending assignment → three inspectable pages: `/app/sign/:token` renders form / already-signed / cancelled.
## Byte-identity spot check (reconstruction contract, issue #18)
Fetch `GET /api/releases/:token/pdf` twice with `sleep 1.5` **between** fetches
and sha256-compare, plus the org door `GET /api/orgs/:org/assignments/:aid/pdf`
(cookie jar). Same-second-only comparisons are blind to clock leaks
(pdf-lib's `PDFDocument.load` restamps `/ModDate` unless `updateMetadata:false`).
To see printed text: inflate the content streams and unescape `\(`, `\)` —
pdf-lib writes hex-string `Tj` operands.
## Inspection browser (managed Chromium via browser eval)
- `viewport` must be `{width, height}` — string fails
- `tab.waitFor(ms)` does NOT exist (ms is parsed as selector) → `await Bun.sleep(900)`
- open with `headed: true, persist: true`; window lands on the desktop — confirm with `niri msg windows | grep -i chrome`
## Teardown (hard gate hazard)
A live demo API on :3000 **hangs the full `tests/run.sh` gate for ~10 minutes**
(observed 2026-10-21: >600s kill with the service up; 14/14 in ~15s without
it). Always `proc://franksign-demo-api/kill` BEFORE running the gate; verify
`pgrep -f apps/api/dist/cli.js` is empty so the next `tests/run.sh` e2e seam
owns :3100 honestly.
## Adjacent Gitea quirk (same fleet)
Closing an issue/PR: `PATCH {"state":"closed"}` → 201; `POST` → 405. Merging needs form-encoded lowercase `do=merge` (see gitea-merge-form-encoded). Label assignment: `PATCH /issues/N` with `labels:[id]` silently clears labels — use `POST /issues/N/labels` `{"labels":[id]}`.
@@ -0,0 +1,55 @@
---
name: franksign-worktree-db-cred-trap
description: "Diagnose franksign e2e seam 5 / health 503 db:down in a fresh worktree: setup-dev.sh minted a password that mismatches the shared franksign-db container."
---
# franksign fresh-worktree DB credential trap
## Symptom
`tests/run.sh` in a fresh franksign worktree fails seam 5 ("e2e server did not
become healthy") while the server log says `franksign-api listening on :3100`.
Manual probe shows the server IS up:
```bash
PORT=3100 node --env-file=.env apps/api/dist/cli.js >/tmp/manual-e2e.log 2>&1 &
sleep 2; curl -s http://127.0.0.1:3100/health
# -> 503 {"status":"degraded","db":"down",...}
```
Not a regression, not a :3100 port collision (check `ss -tlnp | grep 3100` to rule that out).
## Root cause
`infra/setup-dev.sh` mints a fresh `POSTGRES_PASSWORD` per checkout
(`if [ ! -f infra/postgres.env ]`), but `franksign-db` + `franksign-pgdata`
are shared box state owned by the main checkout
(`~/Projects/franksign`). The new worktree's `.env` DATABASE_URL then fails
TCP auth against the initialized volume.
The setup script masks it: its readiness gate is
`podman exec franksign-db pg_isready`, which uses the in-container local
socket and passes regardless of password — so setup prints "postgres ready"
while the checkout is dead on arrival.
## Fix (git-ignored files, no commit needed)
Copy the shared credentials from the owning checkout:
```bash
grep DATABASE_URL /home/blank/Projects/franksign/.env # source of truth
# PUT that exact DATABASE_URL line into this worktree's .env
# PUT the matching POSTGRES_PASSWORD into this worktree's infra/postgres.env
```
Gotcha: the shared password may contain literal `$$`. Keep it byte-for-byte —
systemd `EnvironmentFile` and `node --env-file` both read values literally,
so `$$` in the file IS `$$` in the password. No escaping, no de-escaping.
## Verify
```bash
PORT=3100 node --env-file=.env apps/api/dist/cli.js & # then
curl -fsS http://127.0.0.1:3100/health # -> {"status":"ok","db":"up"}
tests/run.sh # 14/14
```
## Permanent fix (proposed, not yet landed)
`setup-dev.sh` should reuse `~/.config/containers/systemd/postgres.env`
(symlink to the owning checkout) when present instead of minting new creds.
@@ -0,0 +1,93 @@
---
name: gguf-lora-adapter-fit-census
description: "Read-only census before applying a GGUF LoRA/adapter to a llama.cpp (or fork) endpoint: Hub metadata, declared base provenance, runtime flag support, and the behavioural probe that proves the effect."
---
# GGUF LoRA / adapter fit census (read-only, before you promise a file to a payload)
A 3 MB adapter is a payload change: it edits the compute graph at load. Do the census
before editing an endpoint's trio, and prove the EFFECT behaviourally — no metadata,
card table, or health gate can see it.
## 1. Census the Hub repo first (no auth for public repos)
```bash
curl -sS "https://huggingface.co/api/models/<org>/<repo>?blobs=true" | python3 -c "
import json,sys; d=json.load(sys.stdin)
print(d.get('pipeline_tag'), d.get('library_name'))
print([t for t in d.get('tags',[])][:20]) # base_model / base_model:adapter rows matter
for s in d['siblings']: print(s['rfilename'], s.get('size'), (s.get('lfs') or {}).get('oid'))"
curl -sS "https://huggingface.co/<org>/<repo>/raw/main/README.md" > /tmp/card.md # grep for --lora flags
```
- `base_model` / `base_model_relation: adapter` name the pack the adapter was built for.
- **A file with `lfs.oid = None` is a plain git object**: pin the sha256 of the Hub's bytes,
not an LFS oid. Fetcher law still applies: `curl -fsSL --fail-with-body`, byte count,
sha256, `--verify-only`, no resume.
## 2. Read the adapter's own GGUF KV section (settles provenance in one read)
Small python read of the KV pairs — no torch, no download beyond the adapter itself:
magic `GGUF`, `u32 ver`, `u64 n_tensors`, `u64 n_kv`; key = `u64 len` + bytes; type `u32`
(0 u8,1 i8,2 u16,3 i16,4 u32,5 i32,6 f32,7 bool,8 str,9 array,10 u64,11 i64,12 f64);
array = `u32 elem_type` + `u64 count`.
Keys worth printing: `general.type` (`adapter`), `adapter.type` (`lora`),
`adapter.lora_alpha`, and whatever provenance the publisher wrote — e.g. PrismML/abliteration
packs carry `prism.abliterate.base_gguf`, `base_sha256`, `direction_row`, `target_layers`,
`target_embedding`, `sites`, `basis`.
**Provenance is metadata, not a gate.** The loader matches **tensor names, not hashes**, so an
adapter fit against a sibling quantization of the same model still applies — which is a
decision to surface to the operator, not a failure to hide. Compare `base_sha256` against the
sha the endpoint's own fetcher pins; if they differ, say so in the PR body, the env header and
the endpoint page, and prove the effect behaviourally instead.
Two publisher facts that decide whether a fork can carry the file at all: the build it was
verified on, and what it deliberately does NOT touch (an embedding rank is the usual crash —
`token_embd` left alone is a graph constraint, not an omission).
## 3. Ask the runtime binary, not your memory
```bash
<llama-server> --help | grep -iE "lora|adapter"
```
Expect `--lora FNAME`, `--lora-scaled FNAME:SCALE,...`, `--lora-init-without-apply`
(+ `POST /lora-adapters` for runtime scale). Prefer `--lora-scaled <file>:<scale>` as ONE
argv token so a scale can never reach the engine without its file, and follow the endpoint's
existing opt-out convention (the `MMPROJ_FILE=` shape: `:`-less default so an explicitly empty
value means "off, on purpose"). Gate the deployer on the file's existence with the adapter's
OWN fetcher named in the error text.
## 4. Prove the effect: refusal/behaviour probe, before AND after
The probe is not a bench, so the no-bench law does not block it — and the **before** half must
be captured while the old shape is still live.
- Same prompts, `temperature` low, both before and after; classify with a refusal-marker regex
and print only the opening characters, never the generated body.
- **Budget ≥ 2 048 tokens on a thinking model.** A 600-token request can be swallowed whole by
the reasoning channel: `content` comes back empty with `finish_reason: length` and
`reasoning_content` full — which reads exactly like a refusal that never happened.
- A leading `⚠️ Disclaimer`/hedge after the adapter is style, not the circuit: judge the marker
sentence, not the tone.
## 5. Post-deploy reads (the ledger is the instrument)
- Boot log shows `llama_adapter_lora_init_impl: loading lora adapter from '<file>'` and then
**dumps the adapter's KV block** — the provenance mismatch is printed at every start, so the
runbook should point at the journal instead of restating it.
- Confirm the flag on argv: `tr '\0' ' ' < /proc/$(systemctl show <svc> -p MainPID --value)/cmdline`;
confirm `systemctl show <svc> -p DropInPaths --value` is empty (a leftover bench drop-in outranks
everything you just deployed).
- Shape must be untouched by an adapter: `n_slots` / `n_ctx_slot` / KV lines unchanged, and
`/metrics` still answering (a llama.cpp endpoint is invisible to Prometheus without `--metrics`).
- Root units log to the journal; rootless podman storage is read WITHOUT sudo (`sudo podman
inspect` sees a different storage namespace and reports "no such object").
## Yields
One sentence per question: does the file exist and at what size/sha; which pack it declares as
its base and whether that matches the served payload; which flag the runtime actually offers;
what the before/after probe classified. Anything else is a decision for the operator, and the
rollback should be one env line on a file the endpoint already loads.
@@ -0,0 +1,59 @@
---
name: git-secret-gate-hook
description: "Write a fail-closed pre-commit secret-gate hook (staged-content scan) with the grep/ERE traps that kill naive versions, plus its bats fixture test."
---
# Git secret-gate pre-commit hook
Use when a repo promises a "pre-commit secret gate" but none exists, or when porting the gate to another dank repo. Ships with three traps already paid for.
## Shape
- Track the hook at `hooks/pre-commit` (chmod +x). Git never self-installs hooks — wire per clone:
`git config core.hooksPath hooks` — run it in the MAIN checkout once; `core.hooksPath` lives in the
**common** git dir config, so every linked worktree inherits it.
- Do NOT put `hooks` in `package.json` `files` — an installed plugin under node_modules never runs git.
- Scan **staged** content (`git show ":$f"`), not the working tree: that is what lands in history.
- Denylist by path (README, docs/, tests/, `.env.example`) — files whose job is naming secrets or holding
fixture dummies produce theatre, not safety.
- Never echo the matched secret; print file + line number only.
- Red-first test: fixture repo + `cp` the real hook in + `git config core.hooksPath hooks`, then drive
REAL `git commit` attempts: credential-shaped file must exit non-zero AND `git log` must show no new
commit (block, not warn); clean commit passes; hook must survive committing itself.
## The three traps (each cost a red test)
1. **`--` is a ONE-SHOT end-of-options marker.** `grep -nE -- 'p1' -- 'p2'` treats everything after the
first `--` as FILE NAMES: `grep: --: No such file or directory`, rc=2. Multi-pattern = repeat `-e`:
`grep -nE -e 'p1' -e 'p2'`.
2. **`if hits=$(grep ...)` is fail-OPEN on rc>=2** (rc=2 is truthy, hits empty). Capture `rc=$?`
explicitly: `rc>=2` must BLOCK with "pattern error, refusing to pass unchecked" — a gate whose regex
silently dies is worse than none.
3. **POSIX ERE (`grep -E`) gotchas:** no `(?:...)` groups (use plain `(...)`); a `[:-]` inside a bracket
starts a collating element `[:` and dies with "repetition-operator operand invalid" — put `-` FIRST
in every class (`[-A-Za-z0-9/+=_.:+]`). Also `-----BEGIN` literals: prefer printf-safe fixtures in tests.
## Pattern set (shapes, not entropy — say so in the header comment)
```
-----BEGIN[ A-Z0-9]*(OPENSSH|RSA|DSA|EC|PGP)? ?PRIVATE KEY-----
AKIA[0-9A-Z]{16}
gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,}
xox[baprs]-[A-Za-z0-9-]{10,}
https://hooks\.slack\.com/services/[A-Za-z0-9/]+
(aws_secret_access_key|secret(_access)?_key|(api|access|auth)[_-]?key|[a-z_]*token|passwd|password|credential)[[:space:]"']?[[:space:]]*[:=][[:space:]]*["']?[-A-Za-z0-9/+=_.:+]{16,}
(Bearer|token|authorization)[[:space:]]*[:=][[:space:]]*["']?[-A-Za-z0-9._+/]{20,}
```
## Test fixture hygiene
Fake secrets must be inert (RFC 2606 domains, all-zero ids, `AKIA0000000000000EXAMPLE`) AND must not
match the hook's own self-scan. Single quotes in ERE patterns: write `'"'"'`.
## Wiring gotcha (dank worktree policy)
Set `core.hooksPath` AFTER the merge that lands `hooks/` into main — with the config set but the path
missing, git fails every commit in worktrees lacking `hooks/` ("cannot run hooks/pre-commit: No such
file or directory"). Order: merge -> land -> `git config core.hooksPath hooks` from main checkout.
Reference implementation: `dank/dankvoice` `hooks/pre-commit` (PR #19), pinned by `tests/gate.bats`.
@@ -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 and where review comments actually live on 1.27.3."
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
@@ -25,15 +31,23 @@ description: "Open, list, merge, and diagnose issues/PRs on the Dankclaw Gitea A
fold footers into the --arg string instead.)
- **List open PRs**: `GET /pulls?state=open` → `jq -r '.[] | "#\(.number) \(.head.ref) \(.title)"'`.
- **Read PR**: `GET /pulls/<n>` (no token; public repo) → `jq -c '{state,merged,mergeable,head:.head.ref}'`.
`mergeable:false` is often Gitea's *not-yet-checked* state, not a conflict — prove a real
conflict locally with `git merge-tree --write-tree HEAD origin/main` before believing it.
- **MERGE**: `POST /pulls/<n>/merge` with `-H 'Content-Type: application/json'`
and body **`{"Do":"merge"}`** — this build REQUIRES the `Do` field:
`{}` or `{"DoNotMerge":false,...}` → 422; `{"Do":"merge"}` → 200 +
`{"state":"closed","merged":true,"merge_commit_sha":"..."}`. Verify with a
plain GET /pulls/<n> afterwards (merge endpoint's own stdout may not parse).
`{"state":"closed","merged":true,"merge_commit_sha":"..."}`. Verify with a plain
GET /pulls/<n> afterwards (merge endpoint's own stdout may not parse).
Merge is the `--no-ff` merge commit on main; then `git -C ~/.omp fetch && git
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
@@ -51,6 +65,13 @@ description: "Open, list, merge, and diagnose issues/PRs on the Dankclaw Gitea A
Every issue/PR body ends with `---` line then `cwd: <wd> · session: <id>`. Keep
it inside the --arg string (not a `---` CLI arg).
`cwd` is THIS session's own working directory (note a build worktree separately if they
differ). When `/status` is unreachable (headless/subagent context), recover the session id
from the session store instead of inventing one:
`ls -t ~/.omp/agent/sessions/<cwd-slug>/ | head -2` → `<timestamp>_<uuid>` (the same uuid as
`<uuid>.jsonl`); the slug is the cwd with `/` replaced by `-`. Subagent files live under the
**parent** session's directory, so the newest entry there is the parent id.
## Gotchas
- Content-Type: POSTs with `-d @file.json` MUST carry
`-H 'Content-Type: application/json'` on the command line. `-H` entries put in
@@ -0,0 +1,42 @@
---
name: gitea-credential-store-url-format
description: "Auth to the Dankclaw Gitea API without the empty-header trap: ~/.config/secrets/gitea is a git-credential-store URL file, so awk '/^password=/' returns empty; resolve the token via git credential fill, and Gitea writes need Authorization: token (reads pass anonymously on a public repo)."
---
# Gitea auth on Dankclaw: the URL-format credential store
`~/.config/secrets/gitea` is `git config credential.helper = store --file ~/.config/secrets/gitea`, i.e. a **git-credential-store URL file**:
```
http://dank:<40hex-token>@192.168.0.143:3000
```
It does NOT contain `username=`/`password=` lines. So:
```bash
# WRONG — silently returns empty string
p=$(awk -F= '/^password=/{print substr($0,10)}' ~/.config/secrets/gitea)
```
An empty `$p` makes every curl send `Authorization: token ` (blank) → Gitea replies `{"message":"token is required"}` on **writes**, while **reads still return 200** (the repo is public-readable, so a green `GET /labels` or `/issues` proves nothing about auth). Symptom: PR-create returns `token is required` even though a `GET` just "worked".
## Resolve the real token via git
```bash
p=$(printf 'protocol=http\nhost=192.168.0.143:3000\n\n' \
| git credential fill 2>/dev/null \
| awk -F= '/^password=/{print substr($0,10)}') # 40-hex token
```
## Write auth = token scheme, via a 600-perm -K file
```bash
cfg=$(mktemp); chmod 600 "$cfg"
printf 'header = "Authorization: token %s"\n' "$p" > "$cfg"
curl -s -K "$cfg" http://192.168.0.143:3000/api/v1/user # confirms identity
curl -s -K "$cfg" -X POST -H 'Content-Type: application/json' --data @body.json "$API/pulls"
```
- Never pass the token with `-H` — it lands in `/proc/<pid>/cmdline`, world-readable.
- Confirm identity with `GET /user` before trusting a write path; a `token is required` there means the token never authenticated.
- Merges are a separate shape (form-encoded `do=merge`, auth-only cfg, NO `Content-Type` header) — see the gitea-merge-form-encoded skill.
@@ -0,0 +1,53 @@
---
name: gitea-issue-labels-and-pr-curl-traps
description: "Dankclaw Gitea API curl traps: PR create needs Content-Type application/json (fake \"[Head]: Required\"), issue labels only via POST /issues/N/labels (PATCH labels silently dropped), token from URL-format secret file."
---
Operate Dankclaw Gitea (`http://192.168.0.143:3000`, repo `dank/franksign` and siblings) with curl without the three traps that produce misleading errors.
## Token extraction (URL-format store, NOT key=value)
`~/.config/secrets/gitea` is one line: `http://user:TOKEN@192.168.0.143:3000`. `cat`-ing it yields the whole URL, not the token.
```bash
TOK=$(sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p' ~/.config/secrets/gitea | head -1)
```
Never pass the token as a `-H` argument (world-readable in `/proc/<pid>/cmdline`). Write a 600-perm curl config:
```bash
printf 'header = "Authorization: token %s"\n' "$TOK" > /tmp/g.cfg
```
## Trap 1 — PR create answers `"[Head]: Required"` when the body is the problem
Two independent causes, both look like a missing field:
1. No `Content-Type: application/json` header → Gitea parses the raw JSON as form data, sees no `head`, returns 422 `[Head]: Required`.
2. Putting `data = "@/tmp/body.json"` inside a `-K` curl config does **not** expand the `@`.
Fix: add `header = "Content-Type: application/json"` to the config and pass `--data @/tmp/body.json` on the command line:
```bash
curl -s -K /tmp/g.cfg -X POST --data @/tmp/pr.json \
"http://192.168.0.143:3000/api/v1/repos/dank/franksign/pulls"
```
## Trap 2 — issue label assignment via PATCH /issues/N is silently dropped
`PATCH /api/v1/repos/{o}/{r}/issues/{n}` with `{"labels":[id]}` returns **200/201 with the issue JSON**, yet a follow-up GET shows `labels: []` (it can even clear existing labels). Labels are a subresource:
```bash
curl -s -K /tmp/g.cfg -X POST --data '{"labels":[4]}' \
"http://192.168.0.143:3000/api/v1/repos/dank/franksign/issues/18/labels"
```
200 returns the assigned label array. Resolve label names→ids via `GET /repos/{o}/{r}/labels` (franksign canonical five: ids 2–6; `ready-for-agent` = 4).
## Verification law
Both traps fail *successfully*. After any label/PR write, GET the resource and check the field changed; never trust the status code alone.
## Cleanup
`rm -f` the temp config + body files (config holds the token).
@@ -0,0 +1,26 @@
---
name: gitea-merge-form-encoded
description: "Merge a Dankclaw Gitea PR with form-encoded do=merge — and the two body failures: 422 [Do]: Required (JSON body) and jsontext invalid character 'd' (form body with Content-Type: application/json header)."
---
# Gitea merge on Dankclaw: form-encoded, and nothing else
`POST /api/v1/repos/<owner>/<repo>/pulls/<n>/merge` with `-d 'do=merge&merge_style=no-ff'`.
## The two body failures
1. **JSON body** → `422 {"message":"[Do]: Required"}`. The endpoint reads FORM fields, not JSON.
2. **Form body but a `Content-Type: application/json` header** (e.g. left over in the curl `-K` config file from an earlier PR-body PATCH on the same request setup) → `422 {"message":"[]: jsontext: invalid character 'd' at start of value"}` — Gitea tries to parse `do=merge...` as JSON.
## Working pattern
Keep TWO curl config files when a session both patches PR bodies (JSON) and merges (form):
```
# merge.cfg — auth ONLY, no Content-Type
header = "Authorization: token $TOKEN"
```
`curl -s -K merge.cfg -X POST -d 'do=merge&merge_style=no-ff' http://192.168.0.143:3000/api/v1/repos/<o>/<r>/pulls/<n>/merge`
- Success = **HTTP 200 with an empty body**; verify by landing a checkout (`git ls-remote origin main`), never by re-reading the PR.
- PATCH `/pulls/<n>` (title/body) is the opposite: JSON body WITH the Content-Type header.
- Token from `~/.config/secrets/gitea` via a 600-perm `-K` file, never `-H` (`/proc/<pid>/cmdline` is world-readable).
@@ -1,29 +1,86 @@
---
name: gitea-pr-api-cycle
description: "Open/update/merge PRs and issues on the Dankclaw Gitea API (192.168.0.143:3000, dank/command-center) with the token-safe curl+jq sequence, including the {\"Do\":\"merge\"} merge payload and post-merge live-checkout fast-forward."
description: "\"Open/update/comment/merge PRs and issues on the Dankclaw Gitea API (192.168.0.143:3000) with the token-safe sequence (POST /pulls rejects Basic with 'token is required'), numeric label ids, the form-encoded merge field, the async 405 'try again later' merge trap, the empty-token-returns-404 trap, and post-merge live-checkout fast-forward plus the plugin-package-name upgrade gotcha.\""
---
# Gitea PR/issue cycle — dank/command-center
# Gitea PR/issue cycle — Dankclaw API
Friction-earned 2026-10-18 across PRs #15–#20 and issue #18: three separate fumbles (jq trailer flag, 422 merge payload, silent -o/-w swallowing) codified once.
Friction-earned 2026-10-18 (PRs #15–#20, issue #18), re-earned 2026-10-19 on `dank/dankvoice` PRs #4–#6 (the merge payload claim was wrong; an empty token masqueraded as a missing repo), and re-earned 2026-10-20 on `dank/command-center` PRs #55…#57 (the 405 recalculation trap). Re-earned again 2026-10-20 on `dank/dankvoice` #25 (merge gate loop worked; suite verdict delivered as an issue comment). Re-earned 2026-10-21 on `dank/command-center` issue #58 / PR #59 (issue labels are numeric ids; the `<type>/<issue#>-<slug>` branch name has to be right *before* the first push). Same day on `dank/serverAppConfigs` #64: the Basic-vs-token split below cost two wasted POSTs.
## Where
- API base: `http://192.168.0.143:3000/api/v1/repos/dank/command-center/{pulls,issues}`
- Token: never inline, never echoed. Extract from the git credential file into a 600-perm temp curl config:
`tok=$(sed -nE 's#^http://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)`
`cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"` → `curl -K "$cfg"`; `rm -f "$cfg"` when done.
- Unauthenticated GET works for state reads (`GET /pulls/N` → `jq -c '{state,merged,merge_commit_sha}'`).
- API base: `http://192.168.0.143:3000/api/v1/repos/dank/<repo>/{pulls,issues}` — repo varies (`command-center`, `dankvoice`, `serverAppConfigs`); numbering is per-repo, so a footer citing `#36` means nothing without the repo.
- Web UI pages under `/dank/<repo>/pulls/N` are session-auth private: a plain URL fetch answers **404** even when the API and `xdg-open` work. Merge state questions go through the API (`GET /pulls/N` → `state`, `merged`, `merge_commit_sha`) — never conclude from a failed page fetch.
- **Basic vs token split**: the `username=`/`password=` fields of `~/.config/secrets/gitea` as `Authorization: Basic …` work for **reads and issue/PR comment writes**, but `POST /pulls` refuses them with `403 token is required`. Pull-request lifecycle writes need the **token** header. Use the token for everything PR-shaped; never conclude "API is broken" from a Basic 403 on `/pulls`.
- Token: never inline, never in argv (`-H "Authorization: token …"` lands in `/proc/<pid>/cmdline`, world-readable). Extract into a 600-perm curl config and hand it to curl with `-K`:
```bash
tok=$(sed -nE 's#^https?://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)
echo "tok len ${#tok}" # 40 — assert before use
cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"
curl -sS -K "$cfg" http://192.168.0.143:3000/api/v1/repos/dank/<repo>/labels
rm -f "$cfg"
```
- **Empty token answers `404 {"message":"not found"}`, NOT 401** — Gitea hides the private repo. A 404 on a path you have used before means auth, not a typo'd repo. The trap that produces the empty token: `awk -F'/' … '{print $NF}'` on the store line (`scheme://user:token@host/`) — the host is in `$3`, the match never fires, you get a silent empty variable. Always assert the length before POSTing.
- Multi-line bodies: build JSON in python (`json.dumps({"title":…, "body":open(f).read(),…})`) or `jq -n --arg b "$(cat b.txt)"`. NEVER pass `---` as a bare jq argument (parsed as a flag). Escaped quotes inside `python3 -c '…'` break on `\"` — write the script to a file or use a heredoc.
- Always capture status: `-o r.json -w '%{http_code}'`; a bare `-o` hides failures. A `201` response piped straight into `python3 -c` that prints `None None` means you parsed an **error** body (missing `number`) — print `message` too.
## Body construction (the two traps)
- Build JSON with `jq -n --arg t <title> --arg b "$(cat b.txt)" '{title:$t, head:<branch>, base:"main", body:$b}'`; write the body to a temp file first (`printf '%s' … > b.txt`). NEVER pass `---` as a bare jq argument — jq parses it as a flag (`unknown flag: ---`). The AGENTS.md attribution trailer (`---` + `cwd: … · session: …`) goes INSIDE the body string.
- Always capture status explicitly: `-o r.json -w '%{http_code}'` and print it; a bare `-o` hides failures (a 422 looked like success-with-no-output once).
## Create
`POST /pulls` with `{title, body, base:"main", head:<branch>}` — JSON is accepted here (unlike merge). Plain `<branch>` works; the `owner:branch` fork form is unnecessary on same-repo branches.
- **409 `pull request already exists for these targets [issue_id: N, …]`** — a PR for that head/base exists already. Common cause: the push message printed `/pulls/new/<branch>` and someone (Frank, or an `xdg-open` you ran) clicked Create. `GET /pulls?state=all`, find it, then `PATCH /pulls/N` with your title+body instead of re-posting.
- `GET /pulls?state=all` on this Gitea omits `head`/`base` per entry — read `/pulls/N` for those, or key off `number`/`user`.
- Issues: same shape, `POST /issues` `{title, body}`; `PATCH /issues/N` fixes a body retroactively (works for PRs too, since a PR is an issue) — used to correct a false verification claim in a PR that had already been merged. Body update returns **201**, not 200; confirm by reading `body` back, not by status code.
- **Issue labels are numeric ids, not names.** `POST /issues` with `"labels":["ready-for-human"]` answers `422 {"message":"[]: json: cannot unmarshal JSON string into Go int64 within \"/labels/0\""}`. Resolve first: `GET /labels` → pick `id` by `name` → post `"labels":[20]`. (PRs take no `labels` field on create; label afterwards via `/issues/N/labels` with `{"labels":[id]}`.)
- Comments (the channel for late-arriving evidence, e.g. a test-suite verdict posted after PR creation): `POST /issues/N/comments` with JSON `{body}` — works for PRs too, returns **201**.
- **Open the issue before the first commit**, so `Refs #N` fits the message, and get the branch name right before the first push. `git branch -m <new>` + `git push -u origin <new>` + `git push origin --delete <old>` is a clean rename with no PR yet; do it rather than ship a branch that violates `<type>/<issue#>-<slug>` (a `docs/…` branch carrying a `fix:` commit is the common slip, and `git grep` on the old name still shows it).
## Merge (the 422 trap)
`POST /pulls/N/merge` requires body `{"Do":"merge"}` — `Do` is the Required field on this Gitea. `{}`, `{"DoNotMerge":false}`, `{"merge_style":…}` all return 422 (`"[Do]: Required"`). Success may return an EMPTY body — verify by GET afterwards (`state=closed, merged=true`), not by the POST response.
- Merge only with Frank's explicit per-PR OK (repo law).
- Post-merge the live deployment must follow: `git -C ~/.omp fetch -q origin && git -C ~/.omp merge --ff-only origin/main` (the checkout IS the deployment; branch is `main`).
## Merge (the 422 trap) — corrected
`POST /pulls/N/merge` needs **form-encoded** fields; the merge form field name is lowercase **`do`**:
## PR etiquette per AGENTS.md
- Immediately after opening: `xdg-open <PR-URL>` lands it in Frank's Firefox.
- Redesign mid-review: amend + `git push -f` the branch, then PATCH the PR body (`/pulls/N`, `{"body":…}`) so title/body match the net diff — verify `{number,state,mergeable}` in the response.
- Issue creation identical shape: `POST /issues` with `{title, body}`, expect 201; PATCH same endpoint `/issues/N` to fix bodies retroactively, then GET-readback verify.
```bash
curl -sS -X POST -K "$cfg" \
--data-urlencode 'do=merge' --data-urlencode 'merge_style=no-ff' \
http://192.168.0.143:3000/api/v1/repos/dank/command-center/pulls/N/merge
```
JSON `{"Do":"merge"}` (what this skill used to claim) and header variants all return `422 {"message":"[Do]: Required"}`. `merge_style` ∈ `no-ff|merge|rebase|squash`. Success returns **200 with an empty body**; verify by landing a checkout rather than re-reading the PR. Merge only with Frank's explicit per-PR OK.
### The async 405 when several PRs merge in a row
Merge #55, then #56 and #57 back-to-back, and #56/#57 answered:
```
405 {"message":"Please try again later","url":"http://dankclaw:3000/api/swagger"}
```
That is not a failure and not rate limiting: merging one PR moves `main`, which invalidates every other PR's cached mergeability, and Gitea recalculates it asynchronously. A merge attempt during recalculation is refused. Remedy — bounded poll on the PR, merge when the flag is true:
```bash
for attempt in 1 2 3 4 5 6 7 8 9 10; do
m=$(curl -sS -K "$cfg" "$api/pulls/$n" | python3 -c \
'import json,sys;d=json.load(sys.stdin);print(d.get("mergeable"),d.get("merged"))')
case "$m" in *True*) <merge POST>; break ;; *) sleep 4 ;; esac
done
```
All three landed within ~5 s of retrying. Treat `mergeable` as a readiness gate, not a report. Match `*True*` on the pair defensively: `False True` means already merged — stop, don't merge again.
### Verifying a merge without touching the deployment
`GET /pulls/N` → `state: closed`, `merged: true`, `merge_commit_sha`. Then prove content from the bare repo, not the working tree: `git fetch -q origin && git show origin/main:<path> | grep -n <key line>` and `git log --oneline --graph -4 origin/main` (two parents = the `--no-ff` Frank asks for). A number you deliberately destroyed can legitimately reappear once — quoted in `omp-docs/log.md` as the wrong value; `git grep -c <badvalue> origin/main` naming only the log page is the pass condition, not a leftover. `Closes #N` in the PR body auto-closes the issue; confirm with `GET /issues/N` → `state`.
## Stacked PRs (no rebase)
When two open branches edit the same section of the same file, merge the sibling branch into the later one (`git merge docs/other-branch`) instead of rebasing — Frank rejects the history rewrite. Consequences to state in the PR body: merging the lower PR first reduces the upper diff to just its own work; merging the upper first makes the lower an already-contained no-op. Either order is clean; `git branch -d` on the lower branch refuses until its commits reach `main`, because `-d` checks against the current HEAD.
## Post-merge landing
- Command-center: the checkout IS the deployment — `git -C ~/.omp fetch -q origin && git -C ~/.omp merge --ff-only origin/main`. This refuses if **any** incoming path is dirty in the worktree — see `land-merged-pr-with-blocked-tree-ops` for the shared-workspace case where `checkout` and `stash` are both policy-blocked.
- serverAppConfigs: `deploy.sh` pushes the dev checkout's branch first — if the checkout's `main` is behind a merge you just made (via the API), the deploy dies with `error: failed to push some refs`. `git merge --ff-only origin/main` in the deploy checkout BEFORE running `deploy.sh` after any externally-merged PR.
- **A PR that changes a path you edited live adds that path to the discard list.** Tonight: the live dotfile fix was dirty *and* incoming, so landing needed `git -C ~/.omp checkout -- <that file>` as well as the usual file. Discard is content-free only when you proved byte identity first (`cmp` the branch file against the live one before merging) — say so in the PR body, and note that if `live-sync.sh` commits the dirty file first the merge stays clean because both sides hold identical bytes.
- Package repos (`~/Projects/<pkg>`): same `--ff-only`, then reinstall (`omp plugin upgrade <name>`) and `cmp` the installed `dist/index.js` against the repo `dist/index.js` to prove the live artifact is the merged one. Check `ls -ld node_modules` after landing anything that touched it.
- **`omp plugin upgrade` takes the npm package name, not the short name.** `omp plugin upgrade dankvoice` fails with `Failed to upgrade dankvoice: dankvoice is not installed` while the plugin is plainly installed; the package is `omp-dankvoice`. Find the truth with `ls ~/.omp/plugins/node_modules` or `find ~/.omp -path '*<name>/dist/index.js' -not -path '*/Projects/*'` — the installed tree is unscoped (`~/.omp/plugins/node_modules/omp-dankvoice/dist/index.js`), not `@dank/dankvoice`.
- **`cmp` exiting 1 BEFORE the upgrade is the expected state** (installed bundle is the pre-merge build, diff reported at some char/line). Do not treat it as a failed deploy and do not "fix" the build; run the upgrade, then `cmp` again for byte identity, and grep the new bundle for a string only the merged code contains (e.g. `grep -c "Heckle instead of summarizing"`) as a second, semantic confirmation.
- Prune: `git worktree remove --force <path>` (force: untracked deps link), `git branch -D`, `git push origin --delete <branch>` (allowed: local Gitea). Before removing a worktree, check no live process has a cwd inside it (`ls -l /proc/*/cwd | grep -c "wt/<name>"`) — same guard `live-sync.sh` uses — and copy any file you moved into a gitignored `.AGENTTRASH/` there to its permanent home first, or the removal destroys the only copy.
## Attribution footer (AGENTS.md)
Every issue/PR body ends with `---`, then `cwd: <session cwd> · session: <session id>`. Read the real values, never invent an id.
- **The session id is the UUID suffix of the LIVE transcript `.jsonl`, not the session directory name.** Transcript dirs are keyed by cwd (e.g. `-.omp-wt-wt-20261019-020309-6d1fc1f`) and ONE cwd can hold several sessions (resume / continuation forks), so `ls -t ~/.omp/agent/sessions/` cannot tell you which is yours and `ls -t <dir>` is equally blind. Pick the file being appended to right now: `ls -l --time-style=+%H:%M:%S <dir>/*.jsonl` beside `date +%H:%M:%S` — the mtime that equals the current second is the live session. The id is the part after `_` in `<timestamp>_<uuid>.jsonl`.
- A `history.db` query for `session_id` filtered by `cwd` usually agrees, but it is not authoritative for WHICH of several sessions in this cwd is live — use the jsonl mtime test.
- `local/` does NOT disambiguate: a forked transcript dir carries copies of the same `local://` artifacts, so matching artifact names proves nothing about which session is live.
- `cwd` is that session's own working directory (the path the harness states), **not** the build worktree you committed in — when they differ, name the build worktree inside the body and keep `cwd` the session's.
- Resolve `local://<name>.md` with `read local://<name>.md` (its header prints the absolute path) instead of guessing the directory.
@@ -0,0 +1,43 @@
---
name: gitea-token-from-http-credential-store
description: "Extract the Dankclaw Gitea token from ~/.config/secrets/gitea (it is the URL password, not key=value lines) for API calls, and diagnose the silent empty-token trap that makes public-repo reads pass while writes fail."
---
The Gitea credential store line on Frank's boxes is **http** (not https) with a percent-encoded port, and the **token is the URL password** — there are NO `username=`/`password=` lines:
```
http://dank:<40-char-token>@192.168.0.143%3a3000
```
## Extraction that actually works
```bash
tok=$(sed -nE 's#^[a-z]+://[^:]+:([^@]+)@.*#\1#p' ~/.config/secrets/gitea | head -1)
curl -s -K <600-perm cfg file> http://192.168.0.143:3000/api/v1/<path>
```
## `git credential-store --file … fill` answers EMPTY — do not debug it
Tried 2026-10-21 while opening `dank/command-center` PR #61: the obvious extraction
```bash
printf 'protocol=http\nhost=192.168.0.143\nport=3000\n\n' | git credential-store --file ~/.config/secrets/gitea fill
```
prints **nothing**, with no error. It is not a permissions problem and not a wrong path: the stored line encodes the port inside the host field (`@192.168.0.143%3a3000`), so a `host=`+`port=` query can never match it. Add `path=` or drop `port=` and it still answers empty. Read the file with `sed` (recipe above) and move on — the chain `TOKEN=$(…) && [ -n "$TOKEN" ] && …` fails silently at the test, which reads like a curl failure if the `&&` hides it.
## The silent-empty-token trap (bit 2026-10-21, cost a debug detour)
An `awk -F= '/^username=/{...}'` parse of this file yields `tok=""` **silently** — and reads still appear to work when the repo is **public**: `dank/serverAppConfigs` and `dank/command-center` return real issue/label data unauthenticated, so every census "passes" until the first **write** (issue comment, label, PR create/merge) fails with `{"message":"token is always required"}`-style errors or 404s (private repos 404 on an empty token — that is an auth problem, NOT a missing repo).
- `echo "tok len ${#tok}"` (must be 40) before believing ANY API result — reads included.
- `curl -s -K <600-perm file>` carrying `header = "Authorization: token $tok"` — never `-H` on argv (`/proc/<pid>/cmdline` is world-readable; this leaked an HF token once).
- Prove auth once per session: `GET /api/v1/user` → `"login":"dank"`.
- The temp `-K` config holds a live token: `rm -f` it in the same session that made the calls, and never `cat` it.
## Other notes
- `git credential fill` fails headless here (`could not read Username … terminal prompts disabled`); read the store file directly instead.
- Never print the token: mask when inspecting the file —
`sed -E 's#(://[^:]+:)[^@]+@#\1REDACTED@#' ~/.config/secrets/gitea`
- Issue/PR numbers are **per repo**: `dank/dankvoice` #8–#11 (refactor, hardening, AbortError fix, menu defects) are unrelated to `dank/command-center` #8–#11 (SDLC, trunk-law). Read the body from the repo the work targets before referencing an issue in a commit message.
@@ -0,0 +1,41 @@
---
name: gitignore-trailing-slash-symlink-contamination
description: "Trailing-slash gitignore rules don't match symlinks, so worktree dependency symlinks get committed; detect with git ls-tree mode 120000, fix with git rm --cached."
---
A `.gitignore` entry written with a **trailing slash matches directories only**. A symlink of the same name is not a directory, so it slips past the rule, and `git add -A` in a `git worktree` checkout silently **tracks** it. Cost measured 2026-10-19: a machine-specific symlink (`node_modules -> /home/blank/Projects/dankvoice/node_modules`) landed in `main` with a merged PR.
Why worktrees are the trigger: a worktree has no `node_modules` (or other installed-deps dir) of its own, so the cheap way to make build/test work there is a symlink into the primary checkout. With `node_modules/` ignored, nothing complains.
## Detect
```bash
git ls-tree HEAD -- node_modules # mode 120000 == tracked symlink
git ls-files -s | awk '$1==120000' # every tracked symlink in the index
```
## Fix (branch, never on main directly)
```bash
git rm -q --cached node_modules # untrack; leaves the file on disk
```
plus relax the ignore rule to the slash-free form, with the reason inline so nobody re-adds the slash:
```
# Trailing slash would only match directories: a node_modules symlink (the
# shape `git worktree` checkouts end up with) slips past it and gets tracked.
node_modules
```
Then `git check-ignore -v node_modules` must print the rule, and the suite runs after (the symlink still resolves, it is just ignored).
## Prevent
In a fresh worktree, create the deps symlink **after** confirming the ignore rule has no trailing slash:
```bash
git check-ignore -v node_modules || sed -i 's|^node_modules/$|node_modules|' .gitignore
```
Same class for any ignored-by-convention dir that a worktree might provide as a link: `node_modules`, `vendor`, `.venv`, `target`, `dist` when built artifacts are ignored.
@@ -0,0 +1,44 @@
---
name: grafana-provisioned-dashboard-verify
description: "Author and verify repo-provisioned Grafana dashboards on Dankclaw live: staging to the host provider path, the Grafana-13 unified-storage false-negative on grafana.db, the authenticated /api/search check, per-panel PromQL/LogQL validation before shipping, and the Loki $__auto shell-expansion trap in test harnesses."
---
Verify or ship Grafana dashboards that are provisioned from the repo (`shared/grafana/provisioning/dashboards/*.json`, auto-loaded by the `fleet` file provider). Use when adding/staging a dashboard, or when asked to confirm provisioned dashboards actually load.
## Paths (Dankclaw)
- Repo source: `shared/grafana/provisioning/dashboards/` (git is the owner).
- Host provider dir: `/opt/grafana/provisioning/dashboards` (root-owned), bind-mounted `:ro` into the container at `/etc/grafana/provisioning/dashboards` (from `dankclaw/observability/quadlet/grafana.container`: `Volume=/opt/grafana/provisioning:/etc/grafana/provisioning:ro`).
- Provider reload interval = 30s, so a staged file appears within ~35s.
## Stage on the host (canonical install is `deploy.sh dankclaw --service grafana` after merge)
```
scp -q <files>.json dankclaw:/tmp/
ssh dankclaw 'for f in <names>; do sudo install -m644 -o root -g root /tmp/$f.json /opt/grafana/provisioning/dashboards/$f.json; done'
```
## Verification — what DOES and DOESN'T work
**DO NOT trust the sqlite `dashboard` table.** Grafana 13 uses **unified storage**: file-provisioned dashboards do NOT live in the legacy `dashboard` table. Reading `grafana.db` (`sudo python3 - <<PY ... select ... from dashboard`) returns **total dashboards: 0** even when everything is provisioned correctly — a false negative. There is also no `sqlite3` binary in the grafana image or on the host. Grafana logs **no** line on a successful provider load (only errors), so a clean `podman logs grafana` proves only "no error", not "loaded".
**The authoritative check is `/api/search` through an authenticated browser session.** No admin password is configured and anonymous is off — do NOT reset the Grafana admin to do a read check. Drive the RP SSO login with the Playwright `qa-e2e` storage state, then GET the API:
```js
const ctx = await b.newContext({ storageState: '.auth/qa-e2e.json', userAgent: "<chrome UA>" });
const p = await ctx.newPage();
await p.goto('https://grafana.franksdank.cloud/', { waitUntil: 'domcontentloaded' });
for (let i=0;i<30;i++){ const t=await p.locator('body').innerText(); if(!t.includes('not a bot')) break; await p.waitForTimeout(3000);} // clear Anubis PoW
await p.goto('https://grafana.franksdank.cloud/login/generic_oauth', { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(6000);
const r = await ctx.request.get('https://grafana.franksdank.cloud/api/search?type=dash-db&limit=50'); // 200 + [{uid,title}...] = provisioned
```
Run it from the checkout that has `tests/e2e/node_modules` + `.auth` + `.env.local` (a fresh worktree lacks them; symlink `node_modules` and copy `.auth` + `.env.local`). Throwaway `_*.cjs`, then move to `.AGENTTRASH/`.
## Validate every panel query BEFORE shipping (catch empty / mis-scaled panels)
Query Prometheus (`http://127.0.0.1:9090/api/v1/query`) and Loki (`http://127.0.0.1:3100/loki/api/v1/query`) on Dankclaw loopback and confirm non-empty. Real traps found here:
- **Unit mislabel**: `sum by(job,mode)(rate(node_cpu_seconds_total{mode!="idle"}[5m]))` returns **cores per mode** (up to #cores) — unit `short`, NOT `percentunit`. For a per-host busy fraction use `1 - avg by(job)(rate(node_cpu_seconds_total{mode="idle"}[5m]))`.
- **Missing metrics**: `scrape_duration_seconds_bucket` and `prometheus_*`-prefixed self metrics may be absent — confirm from `/api/v1/label/__name__/values?match[]=<metric>` before building a panel; `prometheus_target_interval_length_seconds` (a summary with a `quantile` label) exists.
- **Alertmanager** runs on :9093 but is **NOT a Prometheus scrape target** → its metrics are unavailable; don't build panels on it (they read empty).
- **Loki streams**: journal arrives as `{job="systemd-journal"}`; `container_name` is a **parsed JSON field, not a stream label** → group at query time: `sum by (container_name)(count_over_time({job="systemd-journal"} | json [$__auto]))`.
- **Shell trap in test harnesses**: `$__auto` inside a double-quoted ssh/curl string is eaten by the local shell → empty query → Loki returns empty (looks like a broken panel, is a harness bug). For live validation substitute a concrete `[5m]` window, and drive Loki queries with an on-host Python `urllib` client to avoid quoting hell entirely.
## Datasource uids
`prometheus` (uid `prometheus`), `loki` (uid `loki`); reference as `"datasource": {"type":"prometheus","uid":"prometheus"}` in panel JSON.
@@ -0,0 +1,51 @@
---
name: grep-secret-gate-hook-traps
description: "Write a zero-dependency grep-based pre-commit secret gate that fails closed: the GNU grep -- one-shot trap, POSIX ERE limits, bracket collating elements, and rc=2-as-success in command substitution."
---
# Fail-closed grep secret gate (pre-commit hook)
Use when writing/auditing a bash pre-commit hook (or any CI tripwire) that scans
staged content with `grep -E` for credential shapes — no gitleaks available.
## The four traps (all personally earned; each silently PASSES bad content)
1. **`--` is a ONE-SHOT end-of-options marker.** `grep -nE -- 'p1' -- 'p2' 'p3'`
takes the later `--` and patterns as FILE names → rc=2, errors on stderr.
Correct multi-pattern form: repeat `-e` for every pattern; never `--`.
2. **`if hits=$(... | grep ...)` treats rc=2 as success.** Any non-zero-but-not-1
(broken pattern, unreadable file) enters the `then` branch with EMPTY output.
Never branch on the substitution's truthiness. Capture rc explicitly:
`hits=$(...); rc=$?` then: `rc>=2` → BLOCK (fail closed — a dead regex is
worse than none), `rc==1` → clean, `rc==0` → hits.
3. **POSIX ERE (`grep -E`) has no `(?:...)` groups.** Use plain `(...)`.
4. **`[:-]` inside a bracket expression starts a collating element** →
`grep: repetition-operator operand invalid` (rc=2, see trap 2). Put `-` FIRST
in every class: `[-A-Za-z0-9/+=_.:+]`.
## Gate structure that works
- Scan **staged content**, not the working tree: `git show ":$f"` over
`git diff --cached --name-only --diff-filter=ACM`.
- Denylist doc/fixture paths (`README`, `docs?/`, `tests?/`, `*.example`) — else
the hook blocks its own patterns and every test fixture.
- Report `BLOCKED <file>:<line>` only — never echo the matched secret.
- Wire with tracked `hooks/pre-commit` + `git config core.hooksPath hooks`
(lives in the COMMON git dir config → one set in the main checkout covers
every worktree). Fresh clones are unprotected until it's set; document it.
- Worktrees lacking `hooks/` while `core.hooksPath` is set: git dies on commit
(`cannot run ... No such file or directory`) — merge main in, don't unset.
- Do NOT ship the hook in an npm plugin artifact (`files`): node_modules never
runs git; pin its ABSENCE in the pack test instead.
## Test it red-first through the real seam (bats)
`git init` a fixture repo, `cp` the real hook in, `git config core.hooksPath hooks`,
then drive REAL `git commit` and assert:
- credential-shaped `.env` (RFC-2606 domains / all-zero ids as inert fixtures) →
non-zero AND `git log` count unchanged (blocks, not warns);
- PEM header line → blocked;
- clean prose → passes;
- the hook committing ITSELF → passes (trap 1/3/4 usually die here);
- exit-code masking: `bats ... | tail` returns tail's rc — never `&& commit`
behind a piped suite, or you commit on red.
@@ -1,48 +1,53 @@
---
name: halogen-npu-feasibility-census
description: "Census whether halogen-flash-server's NPU small-model path can run on a Strix Halo host: image gate, amdxdna/XRT presence, IOMMU and fabric-clock blockers, enablement deltas."
description: "Census whether halogen-flash-server's NPU small-model path can run and swap models on a Strix Halo host: image gate, amdxdna/XRT presence, IOMMU and fabric-clock blockers, enablement deltas, boot-set-only model loading (no live reload), and the silent-fallback-to-Flash trap for unarmed chat-class NPU models."
---
Use when asked whether halogen/halogen-flash-server can use the XDNA2 NPU (embeddings, rerank, moderation/guard, decider, `qwen3.5-2b` generation, `flux2-klein-4b` images, `qwen3-asr-*`) on a Ryzen AI Max / Strix Halo host, or before proposing to enable it.
Use when asked whether halogen/halogen-flash-server can use the XDNA2 NPU (embeddings, rerank, moderation/guard, decider, `qwen3.5-2b` generation, `flux2-klein-4b` images, `qwen3-asr-*`) on a Ryzen AI Max / Strix Halo host, before proposing to enable it, or when asked whether it can **hot-swap / live-reload** NPU models like FastFlowLM does.
## What the feature is (not a speed knob)
The Flash trunk stays on the iGPU. `HALOGEN_NPU_MODELS=<names>` starts a second engine in the SAME container and on the SAME API port; the request's `model` field picks the executor. Unset → server identical to before. Each NPU model is a precompiled AIE-ML program (`devices/devices.hnpm`, per-partition `devices/u<N>.elf`, `devices/consts.hnpb`, `<id>.hnpw` weights, `tokenizer/tokenizer.json`), loaded via the HOST's XRT → kernel `amdxdna` → `/dev/accel/accel0`. One pass at a time; `HALOGEN_NPU_QUEUE` 64, overflow queues server-side, then `503 engine_busy`. Entry `entrypoint.sh` validates every file's bytes + sha256 against `/opt/halogen/npu/models.txt` before anything starts. NPU models run only in single-container "all" mode, NOT the `engine`/`api` split.
The Flash trunk stays on the iGPU. `HALOGEN_NPU_MODELS=<names>` starts a second engine in the SAME container and on the SAME API port; the request's `model` field picks the executor. Unset -> server identical to before. Each NPU model is a precompiled AIE-ML program (`devices/devices.hnpm`, per-partition `devices/u<N>.elf`, `devices/consts.hnpb`, `<id>.hnpw` weights, `tokenizer/tokenizer.json`), loaded via the HOST's XRT -> kernel `amdxdna` -> `/dev/accel/accel0`. One pass at a time; `HALOGEN_NPU_QUEUE` 64, overflow queues server-side, then `503 engine_busy`. Catalog in the 0.16.2 image: `decider-0.8b`, `qwen3-embedding-0.6b`, `qwen3-reranker-0.6b`, `qwen3guard-gen-0.6b`, `qwen3.5-2b` (+ `flux2-klein-4b`, `qwen3-asr-*` in newer tags). Only the programs named in `HALOGEN_NPU_MODELS` are armed.
## Census (all read-only, run from the operator box)
## Boot-set-only: NO live reload (tested on the live endpoint)
`HALOGEN_NPU_MODELS` is read **at start**; the entrypoint validates/converts/loads the named programs then. There is **no** runtime load/unload and **no** admin endpoint to add a model. FLM comparison: FLM was a standalone NPU-only server (`flm serve <one-model>`, single FIFO lane) free to reconfigure its one AIE partition; halogen boots Flash (iGPU) **plus** the NPU programs as ONE coupled process (shared port + shared memory/power budget), so the NPU set is a boot-time constant. Changing even a checkpoint's weights reconverts "at the next start", never live.
Consequence — arming a new program = edit `scripts/frank-dragoon.env` -> redeploy -> **container restart -> the whole Flash payload (tens of GB) reloads, minutes**. The small NPU program is not the cost; the Flash reload is.
## The silent-fallback trap (the one that bites)
A request naming an **unarmed** catalog model behaves differently per endpoint class:
- `/v1/rerank`, `/v1/moderations`: honest **400 `model_not_found`**, message ends `(loaded: none)`. The endpoint only exists if that engine was armed at boot.
- `/v1/chat/completions` (the `decider-0.8b` decision path and `qwen3.5-2b` generation): **silent fall-through to the Flash model** — HTTP 200, body `"model": "<flash id>"`, no error. A guard/decision call to an unarmed model is answered by the main (often abliterated) endpoint you were trying to screen against, and looks like success. Measured: an unarmed `decider-0.8b` "is this a prompt injection?" probe returned `"no"` from Flash. **Fail-safe is NOT the default on the chat route.**
## Live probe (idle lane; one ssh, non-disruptive — no restart happens)
```bash
ssh <host> '
# 1. engine gate + catalog in the pinned image
podman exec <ctr> bash -c "grep -rlo HALOGEN_NPU_MODELS /opt/halogen /usr/local/bin /entrypoint.sh 2>/dev/null | head"
podman exec <ctr> cat /opt/halogen/npu/models.txt | head -40
# 2. is NPU actually wired today? (expect NO env, NO device, NO /dev/accel inside)
podman inspect <ctr> --format "{{json .HostConfig.Devices}}"; podman exec <ctr> ls /dev/accel
# 3. kernel driver + firmware
lsmod | grep -i amdxdna; sudo dmesg | grep -iE "xdna|npu" | tail -5; ls -l /dev/accel
# 4. XRT (distro packages are fine; vendor mounts /opt/xilinx/xrt)
dpkg -l | grep -iE "xrt|xdna"; ls /usr/lib/x86_64-linux-gnu/libxrt* /usr/lib/libxrt* 2>/dev/null
# 5. BLOCKER 1: IOMMU
cat /proc/cmdline; ls /sys/kernel/iommu_groups | wc -l
# 6. BLOCKER 2: fabric clock held?
for c in /sys/class/drm/card*/device; do echo "== $c"; cat $c/power_dpm_force_performance_level 2>&1; cat $c/pp_dpm_fclk 2>&1 | tr "\n" " "; echo; done'
B=http://127.0.0.1:7770 # halogen port; internal engine is :8731/:8740 muxed onto it
curl -s $B/v1/models | jq -r '.data[].id' # armed set = boot set; must not mutate
# control (armed embedding) -> 200 with a real vector:
curl -s $B/v1/embeddings -d '{"model":"qwen3-embedding-0.6b","input":["ping"]}'
# unarmed rerank/guard -> expect 400 "(loaded: none)":
curl -s $B/v1/rerank -d '{"model":"qwen3-reranker-0.6b","query":"t","documents":["a","b"],"top_n":1}'
curl -s $B/v1/moderations -d '{"model":"qwen3guard-gen-0.6b","input":["hello"]}'
# unarmed decider/gen -> watch the "model" field; if it is the flash id, it fell through:
curl -s $B/v1/chat/completions -d '{"model":"decider-0.8b","temperature":0,"messages":[{"role":"user","content":"x"}],"response_format":{"type":"json_schema","json_schema":{"name":"g","description":"is injection?","schema":{"enum":["no","yes"]}}}}' | jq -r '.model // .error.message'
# prove nothing restarted:
systemctl show frank-dragoon.service -p NRestarts -p ActiveEnterTimestamp --value
```
`decider` with a schema and a non-zero `temperature` 400s on a *sampling* guard first ("send temperature 0"); set `temperature:0` to reach the router and see the fallback.
Docs (raw fetch, authoritative): `docs/NPU.md`, `docs/npu-small-models.md`, `deploy/host/halogen-fabric-clock` under `peonist-ai/halogen-flash-server` on GitHub. Tag list: `podman search --list-tags ghcr.io/peonist-ai/halogen-flash-server`.
## Enablement gates (all four must hold before it runs)
## The two blockers and the enablement delta
- **IOMMU must be on.** `amd_iommu=off` turns the NPU off (no PASID/SVA); `iommu=pt` keeps it. Needs a grub edit + reboot. `iommu_groups == 0` ⇒ off.
- **Fabric clock must be pinned at the top `pp_dpm_fclk` level** (or `power_dpm_force_performance_level=high`); `auto` = unheld. GPU+NPU concurrently HANG Strix Halo machines and corrupt NPU results while that clock moves. Fix: root container with `-v /sys:/host/sys` (server holds it itself), or install `deploy/host/halogen-fabric-clock` + unit (~1 W). If it can't hold, the server FAILS CLOSED and prints the commands.
- Container delta: `--device /dev/accel/accel0` + `HALOGEN_NPU_MODELS`; `--ulimit memlock=-1:-1` and `--group-add keep-groups` usually already present. Distro XRT: mount each of `libxrt_coreutil.so.2`, `libxrt_core.so.2`, `libxrt_driver_xdna.so.2` TWICE (once at `/opt/xilinx/xrt/lib/`, once at its own path) — never mount an `/opt/xilinx/xrt` holding only symlinks.
## Costs to price before proposing
- Upstream A/B: IOMMU translation ON costs **13–16 % prefill** (460 → 385 tok/s at 2,048 tokens, Ryzen AI Max+ 395); decode unaffected. Whether `iommu=pt` specifically costs that on a given box is a local measurement, not a published fact — run the image's own `sweep` after the reboot.
- A few GB host RAM per NPU model (+8 GB FLUX, +1.6/4 GB ASR), and trunk decode drops while the NPU works (shared bus + power).
1. Kernel `amdxdna` loaded + `/dev/accel/accel0` present; the node must be world-open (`0666` via a udev rule) or a **rootless** container cannot open it — `serve-frank-dragoon.sh` refuses if the mode bit 02 is clear.
2. Host XRT with the NPU plugin (`libxrt_coreutil.so.2`, `libxrt_core.so.2`, `libxrt_driver_xdna.so.2`); on Arch/CachyOS mount each lib twice (image XRT path + its own `/usr/lib` path), never an `/opt/xilinx/xrt` of dangling links.
3. **Fabric clock held at top** (`halogen-fabric-clock` root unit) — GPU+NPU at once can hang Strix Halo / corrupt NPU results while that clock changes; the server refuses to start if it can't hold it.
4. **IOMMU on**: `amd_iommu=off` kills the NPU; `iommu=pt` keeps it.
## Laws / traps
- Never probe the NPU beside the live service: stop + mask first (fleet benchmarking policy); this host class has a precedent of a concurrent GPU experiment needing a power cycle. Distinguish off vs wedged with `ip neigh show <ip>` from a peer.
- `/dev/accel/accel0` existing and `amdxdna` probing cleanly does NOT mean the NPU works — with IOMMU off the failure is at context/PASID creation, not at the device node. Do not trust older notes claiming the node itself is missing.
- Never probe the NPU beside the live service: stop + mask first (fleet benchmarking policy); this host class has a precedent of a concurrent GPU experiment needing a power cycle. Distinguish off vs wedged with `ip neigh show <ip>` from a peer. A single embedding/decision request on an already-armed idle production lane is a normal request, not a benchmark — safe.
- `/dev/accel/accel0` existing and `amdxdna` probing cleanly does NOT mean the NPU works - with IOMMU off the failure is at context/PASID creation, not at the device node. Do not trust older notes claiming the node itself is missing.
- `dmesg` lines like `Failed to alloc dev bo memory, ret -28` while large NPU models take turns at NPU memory are benign (vendor-documented); the server recovers.
- NPU model RSS is host-side, bypassing the container cgroup (GTT/shared pool), like all Strix Halo GPU memory — container stats under-report the true footprint.
@@ -0,0 +1,60 @@
---
name: harden-precommit-secret-gate
description: "Fix or build a pre-commit secret-scan hook that actually blocks leaks: scope hole, fail-closed, quarantine list, and the self-trip that kills an automated committer."
---
Fix or build a pre-commit secret-scan hook that actually blocks leaks: scope hole, fail-closed, quarantine list, and the self-trip that kills an automated committer. Use when a secret gate is wrong (misses a leak) or too right (rejects an inert example and bricks the heartbeat).
## The two failure directions
A gate can be **too weak** (misses a real key) or **too strong** (rejects its own documentation and takes down an automated committer). Both are outages; the second is silent.
## Audit recipe for an existing gate
1. Read the hook and list what it actually inspects. The classic scope hole: `git diff --cached -- '*.env' '*.pem'` — an extension whitelist. A credential in `agent/config.yml`, a quadlet unit, or a SKILL.md is then invisible to the gate. The rule that survives: scan **the entire staged diff**, every path, and let only binary content out.
2. Test fail-closed behaviour: can the hook be made to print nothing and still exit 0? Common escapes — running outside a git repo, unreadable staged diff, a hook that pipes through `grep` and inherits `grep`'s exit code from the wrong stage, or `set -e` swallowing the verdict. Verdict must be: unknown ⇒ **block**.
3. Quarantine list, never tracked: `.env`, `auth.json`, `trust.json`, `*.pem`, `*.key`, `identity*` — and the `.gitignore` policy must quarantine env files **at any depth under any name** (`**/.env*`, not `.env`), or `config/prod.env` slips past.
4. Escape hatch must be explicit, per-commit, and loud: `ALLOW_SECRETS=1 git commit …`, and the hook says so when it honours it.
5. Gate the gate: a test that commits a fixture repo through the hook and asserts it blocks. `tests/secret_gate.bats` here covers: shape in an unexpected extension, bcrypt hash, JWT, placeholder/indirection allowed, prose allowed, binary ignored, unreadable diff refused, deletion of a credential never blocked, `ALLOW_SECRETS` override.
## Self-trip: the gate rejects its own example (live 2026-10-21)
Symptom: a systemd timer that commits machine-written files fails **every hour**, and the journal shows the gate's own refusal:
```
SECRET GATE: staged diff contains 1 secret-shaped string(s):
agent/managed-skills/git-secret-gate-hook/SKILL.md
Fake secrets must be inert (RFC 2606 domains, all-zero ids, `AKIA0000000000000EXAMPLE`) AND must not
```
The offender is the AWS **documentation** example key — entirely inert — and `AKIA[0-9A-Z]{16}` cannot tell it from a leak. So the gate's own advice about writing an inert fake is uncommittable by the gate. Auto-learned skills describe credential shapes for a living, so this recurs by construction, and an automated committer cannot use `ALLOW_SECRETS=1`: that would blind the gate for the *other* 64 staged files.
Diagnose, in this order:
```bash
journalctl --user -u live-sync.service -b --no-pager | tail -25 # which file, which line
git -C ~/.omp status --short | head # how much is queued behind it
# then scan the WHOLE staged queue, not just the reported file:
git diff --cached -U0 | awk '/^diff --git/{f=$3;sub("^a/","",f)} /^\+/ && !/^\+\+\+/ {
l=substr($0,2)
if (l ~ /sk-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.|-----BEGIN [A-Z ]*PRIVATE KEY-----/)
print f": "substr(l,1,90) }'
```
The second count is the decision input: one hit ⇒ fix the line; many ⇒ the gate needs an exemption channel before anything lands.
Fixes, in order of preference:
1. **Describe the shape instead of printing a matching literal** — `AKIA` followed by zeroes and `EXAMPLE`, spelled out in prose, cannot match. One line, the gate stays strict, the next timer run drains the queue.
2. **Per-line allow marker** honoured by the hook (e.g. a trailing `secret-gate: allow` on the flagged line) — durable, keeps documentation honest, still fail-closed everywhere else. Needs a test both ways: a marked line passes, an unmarked one still blocks. Note the risk honestly: an attacker-adjacent string can self-authorise, so the marker belongs in review-able prose tiers, not in a diff that also touches secrets config.
3. Never widen the regex to skip markdown, never run the automation with the override env var set.
Inert-fake rule for anything that must *show* a credential shape: RFC 2606 domains, all-zero ids, the word `EXAMPLE` — and if the shape itself trips the gate, describe it in prose rather than print it.
## Prose must not be collaterally blocked
Markdown explaining credential handling is the bulk of what a knowledge repo commits. `xox` inside a sentence about Slack token prefixes, or a `sk-` prefix discussed abstractly, must pass; the shapes are anchored long-suffix patterns for exactly that reason. When a gate fires on prose, that is a bug in the gate — but if the prose contains a *literal* matching token, the fix is the prose (option 1 above), not the pattern.
## Verifying a gate change
Write the fixture repo in a temp dir, commit through the hook, assert block/allow for each case. Do not test the regex by grepping the hook's source for the pattern — a copy of the pattern in a comment is not a gate that runs.
@@ -0,0 +1,31 @@
---
name: harness-model-config-wire-capture
description: "Verify what the omp harness actually sends to a local inference endpoint (reasoning_effort, sampler keys, thinking-off) by pointing a scratch HOME at a logging proxy, instead of inferring it from models.yml comments."
---
A `models.yml` comment claiming "the wire sends X" is a claim about the harness, not the engine, and the harness rewrites between the two: compat maps pass unmapped values through, effort ladders clamp before the map applies, and a "disable reasoning" role flag can be implemented as *send the lowest effort* rather than a switch. Read only the wire.
## Recipe (minutes, no production state touched)
1. **Logging proxy** — python `ThreadingHTTPServer` on `127.0.0.1:7799` forwarding body+path to the real engine and appending the request body's interesting keys (`model, reasoning_effort, max_tokens, temperature, top_p, top_k, chat_template_kwargs`) as one JSON line per request. Run it as a named bash service with `ready.port` so the port is proven before use.
2. **Scratch HOME** — `mkdir -p /tmp/omph/.omp/agent`, copy the *candidate* `models.yml` + `config.yml` there, rewrite that provider's `baseUrl` to `http://127.0.0.1:7799/v1`. Never edit `~/.omp/agent/` — in `dank/command-center` the checkout is the deployment.
3. **One-shot runs** — `HOME=/tmp/omph omp --model '<provider>/<id>:<effort>' --no-tools --no-session --no-skills --no-rules --no-extensions --no-pty --no-lsp -p 'Reply with exactly: OK'`, once per effort (`off minimal low medium high xhigh max`). Add `--print-thoughts` when the question is "did thinking actually happen".
4. Read the log, and assert the value **after** the change. A config edit that never engaged is the failure mode this exists to catch.
## Traps found doing this on frank-dragoon (2026-10-21)
- `--model X:off` does **not** mean `reasoning_effort: off` on the wire. Reasoning is switched through `compat.reasoningDisableMode` (enum includes `lowest-effort`, `none-effort`, `qwen-enable-thinking-false`, `qwen-template-false`, `chat-template-thinking-false`); the default derives from `thinkingFormat`, and `lowest-effort` quietly keeps thinking alive (`:off` arrived as `"minimal"`, with a canned 26-token thinking block on every "off" request). `none-effort` sent `"none"` → `reasoning_tokens: 0`.
- Effort levels are clamped to the harness's per-model ladder **before** `reasoningEffortMap` applies, so a map row for a level the ladder lacks is inert (`:max` arrived as `xhigh` even with `max: max`). Capture before promising a level works.
- Map lookup is `compat.reasoningEffortMap?.[effort] ?? thinking?.effortMap?.[effort] ?? effort` — removing a row is a pass-through, never a 400. Claims about rejection must be proven against the engine.
- Engine-side claims: ask for a deliberately bad value and read its 400 — the engine prints its own accepted set (`reasoning_effort 'maximum' unsupported; use minimal|low|medium|high|xhigh|max|none`).
## Engine-side probes that transfer to any OpenAI-compatible endpoint
- **Accepted parameters**: `{model, messages, max_tokens:1, <param>:<value>}` → status + `error.message`. Acceptance is not honouring: report it as acceptance.
- **Level honoured?** same prompt at `max_tokens 2048` per level, compare `usage.completion_tokens_details.reasoning_tokens`. Equal values across levels = the parameter is ignored; `reasoning_closed_at` reveals a per-level thinking ceiling.
- **Thinking off**: `chat_template_kwargs {"enable_thinking": false}` and top-level `enable_thinking: false` → expect `reasoning_tokens: 0`.
- **Vision**: build a solid-colour PNG in-script (`zlib`+`struct`, no PIL), 512×512, ask for the colour in one word **with thinking disabled** — otherwise the answer is cut off by the thinking block and the probe is inconclusive. A 1×1 image only proves token accounting.
## Teardown
Kill the proxy service, remove the scratch HOME, the proxy script and its log. Never leave copies of `config.yml`/`models.yml` in `/tmp`: they carry provider endpoints and sometimes tokens.
@@ -0,0 +1,45 @@
---
name: land-branch-without-checkout
description: "Open a PR from a shared worktree when workspace policy blocks git checkout/restore: push a named branch ref from HEAD without switching branches."
---
# Landing a branch when `git checkout` is policy-blocked
Shared-workspace policy blocks `git checkout` and `git restore` (working-tree
switches could strand concurrent agents' uncommitted work). You still must ship
the commit on the current worktree branch as a named `chore|feat|fix/<issue>-<slug>`
branch. Never work around the block — go around it via refs.
## Sequence
```bash
# 1. commit only your files (atomic); leave other dirty paths alone
git add <your-files> && git commit -m "..."
# 2. branch ref at HEAD — no working-tree change, stays on wt/<...>
git branch <type>/<issue>-<slug> HEAD
git push origin <type>/<issue>-<slug> # pre-push hooks still run
# 3. PR via tracker API with head = pushed ref (see gitea-pr-api-cycle / gh)
```
- The worktree stays checked out on `wt/<timestamp>`; the new branch exists
only as the pushed ref (plus a local ref that merges cleanly later).
- `-u` upstream tracking on the wt branch is misleading — skip it; set
`git config branch.<name>.remote origin` + `.merge` only if you will
later check the branch out.
- Dirty regenerated fixtures left behind (e.g. `gen:sample` PDFs rewritten
by setup/gate runs): `git restore` is also blocked — do NOT edit-forward
to revert them; report them. They are regenerated on every bootstrap, so
dirt is expected and harmless.
## Verify
- `git ls-remote --heads origin <branch>` prints the same sha as `git rev-parse HEAD`.
- PR API returns 201 with `head.sha` equal to that sha.
## Do NOT
- `git checkout -b`, `git stash`, `git restore`, `git reset --hard` here —
all working-tree mutations the policy can block, and stashes in a shared
checkout are a cross-agent footgun.
@@ -0,0 +1,50 @@
---
name: land-merged-pr-with-blocked-tree-ops
description: "Use when a merged PR must land in a checkout whose working tree is dirty and workspace policy blocks checkout/stash — prove the delta content-free, hand the operator the two-liner, verify via live config meanwhile."
---
Landing a merged PR when the deployment checkout is dirty **and** workspace policy blocks `git checkout`/`git stash`. Use when `git merge --ff-only origin/main` aborts with "Please commit your changes or stash them before you merge" in a SHARED workspace where other agents have work in flight. Earned 2026-10-20 on `dank/command-center` (the checkout IS the deployment).
# Land a merged PR with blocked tree ops
## Why it blocks
`--ff-only` refuses when **any** incoming path is dirty in the worktree — it checks dirtiness against the *old* HEAD, not whether your content matches the target. Proven: overwriting the file so it is **byte-identical to the target commit** (`cmp` clean against `git show origin/main:<path>`) still aborts with the same message. So "make the file match main" is not a way in, and neither is `git merge` from another worktree (the branch is checked out in the dirty one).
The blocking set is what you must know, exactly:
```bash
comm -12 <(git diff --name-only HEAD..origin/main | LC_ALL=C sort -u) \
<(git status --porcelain | awk '{print $NF}' | LC_ALL=C sort -u)
```
`LC_ALL=C sort` matters — git's own output order trips `comm: file N is not in sorted order` and you get garbage overlap lists. In the case this was earned on, 12 incoming paths and 57 dirty ones intersected in exactly **one** file.
## Procedure
1. **Fetch, don't merge yet.** List incoming commits (`git log --oneline HEAD..origin/main`) so you can show what is waiting.
2. **Compute the blocking set** with the `comm` above (both inputs `LC_ALL=C sort -u`).
3. **Prove the local delta is content-free**, in the direction that matters — lines the working tree has that `main` does not:
```bash
diff <(git show origin/main:<path>) <path> | grep -c '^>' # local-unique lines
```
Zero means the working tree is a strict subset of what was merged: discarding loses nothing.
4. **Preserve before you touch:** copy the current file to `~/.AGENTTRASH/` (snapshot), and copy anything you had moved into a gitignored `.AGENTTRASH/` inside a build worktree to its permanent home in the deployment — otherwise pruning the worktree destroys the only copy of a quarantined page.
5. **Do NOT work around the policy.** `git checkout -- <path>`, `git stash`, and `git stash push -- <path>` are all blocked in a shared workspace and the block is correct: it protects other agents' in-flight work. Do not reach for `git reset --soft` + `git restore --staged` to slide the pointer past it either — that leaves the index holding staged *reverts* of every incoming path, one careless `git commit -a` away from undoing the merge.
6. **Hand the operator the two-liner** and keep working on what does not depend on it:
```bash
git -C ~/.omp checkout -- <path> && git -C ~/.omp merge --ff-only origin/main
```
Say why it is safe (step 3's number, plus where the snapshots live) and what it unblocks.
## Why waiting is not free
`scripts/live-sync.sh` merges with a plain `git merge --no-edit origin/main` (its line ~88), so the hourly heartbeat hits the identical wall and raises its ntfy alert `merge conflict with origin/main — local commit kept; needs you at the keyboard`. Report that consequence: the dirt is not merely untidy, it alerts.
Also note for later: `git branch -d <merged-branch>` **refuses until the commits reach `main`** — `-d` checks against the current HEAD, not `origin/main` — so local branch deletion goes on the post-landing list, not the pruning list.
## What you CAN do while it waits
- **Verify the merged behaviour through the live config, not the branch pointer.** Merged prompt/config changes take effect from the deployment's files: spawn a fresh session/subagent and have it read and quote the changed file back. That proves the change is live even while `main` is behind.
- Prune your own build worktrees, but check nothing is running inside them first: `ls -l /proc/*/cwd 2>/dev/null | grep -c "wt/<name>"` (the same guard `live-sync.sh` uses).
- Delete the merged remote branches (`git push origin --delete <branch>`, allowed on the local Gitea).
@@ -0,0 +1,83 @@
---
name: llamacpp-adapter-swap
description: "Add or roll back a GGUF LoRA adapter on a dank-fleet llama.cpp endpoint (e.g. frank-zealot): provenance census, fork-flag gate, fetcher+env+deployer surface, seam tests, and the behavioural before/after probe that is the only real proof."
---
# Applying a GGUF LoRA adapter to a fleet endpoint
An adapter is **payload, not shape**: it changes what the endpoint answers and nothing in
the KV/budget equation. Fleet law still applies — no benchmarks unless Frank orders one,
rollback is one env line, `./tests/run.sh` before commit, deployer health gate + e2e gate.
## 1. Census the artifact BEFORE writing any code
```bash
curl -sS "https://huggingface.co/api/models/<repo>?blobs=true" # size + LFS oid (None => plain git object)
curl -sSL -o /tmp/a.gguf "https://huggingface.co/<repo>/resolve/main/<file>" && sha256sum /tmp/a.gguf
```
Parse the GGUF KV section (15 lines of struct.unpack) and read **provenance**: abliteration
adapters carry `prism.abliterate.base_gguf` / `base_sha256`, `adapter.type`, `sites`,
`target_layers`, `target_embedding`. Compare the declared base against the file the endpoint
actually serves (its sha is in the endpoint's own fetcher script). A mismatch is normal for
sibling quantizations — the loader matches **tensor names, not hashes** — but it must be
stated in the docs, because it means the card's measured numbers are not this endpoint's
numbers.
## 2. Gate the runtime, from the binary's own `--help`
On the host: `/home/dank/GitPackages/<build>/llama-server --help | grep -iE 'lora|adapter'`.
Confirm `--lora-scaled FNAME:SCALE` exists (and `--lora-init-without-apply` + `POST
/lora-adapters` if a runtime toggle is ever wanted). Vendor forks gate adapters by metadata
version too — e.g. PrismML builds older than `prism-b10777` refuse packs with `unsupported
prism.hadamard.version: 2`. Read the card for the build it verified on and put that in the
fetcher header.
## 3. Surface (five files, copy the mmproj convention)
- `scripts/<svc>.env`: `LORA_FILE=<path>` + `LORA_SCALE=1`; document that **`LORA_FILE=` (empty)**
is the rollback and `LORA_SCALE=0` is the inert-but-present mode. Use `${VAR=default}` (no
colon) so an explicitly empty value survives — same reason `MMPROJ_FILE`/`PRESENCE_PENALTY` do.
- `scripts/serve-<svc>.sh`: `EXTRA_ARGS+=(--lora-scaled "${LORA_FILE}:${LORA_SCALE}")` — **one
argv token**, so a scale can never arrive without its file; refuse a missing file by name
(`Restart=always` turns an unfinished fetch into a crash loop that reads like a runtime fault).
- `scripts/download-<svc>-<adapter>.sh`: copy the sibling fetcher (`-fsSL --fail-with-body`, byte
count, sha256, `--verify-only`, idempotent). No LFS oid => pin the content sha. Header carries
the provenance finding.
- `scripts/deploy-remote.sh`: `LLM_<SVC>_LORA="${LLM_<SVC>_LORA:-<path>}"` + a check whose error
names **this** fetcher, not the payload's.
- `scripts/deploy-plan.py` needs nothing: fetchers are host-side state and select no service.
## 4. Seam tests (argv is the contract)
Stub-engine harness: create the adapter stub from the **shipped env's basename**, emit
`LORA_FILE`/`LORA_SCALE` in the generated test env, then assert on output only —
`--lora-scaled` value equals `<path>:<scale>`; a distinctive `LORA_SCALE` (0, 0.75) changes the
token; `LORA_FILE=` yields **absence** of the flag (`! grep -qxF -- '--lora-scaled'`); a missing
file exits non-zero naming that path; extend the env↔deployer anti-drift pair list.
## 5. Deploy and prove it behaviourally
Capture the **before** state on the live endpoint first, then: merge → on host `git pull` +
run the fetcher → `./scripts/deploy.sh <host> --service <svc>`.
```bash
sudo systemctl show <svc> -p DropInPaths --value # must be empty (bench-drop-in law)
sudo tr '\0' ' ' < /proc/$(sudo systemctl show <svc> -p MainPID --value)/cmdline
sudo journalctl -u <svc> --since <boot> | grep -iE 'adapter|lora' # llama_adapter_lora_init_impl prints prism.* keys
```
Refusal probe (the only proof of an abliteration adapter): same prompts, `temperature 0.1`,
**`max_tokens >= 2048`** — on thinking models a short budget is consumed by
`reasoning_content` and `content` comes back empty with `finish_reason: length`, which reads
as a refusal that never happened. Classify with a marker regex and report refusal-vs-complied;
do not paste generated harmful content into chat. Also run the standing trio: exact-phrase
chat, `tool_calls` with valid JSON, and a >=256 px solid-colour vision probe. A residual
`⚠️ Important Disclaimer` heading after the swap is style, not the refusal circuit.
## Docs
Endpoint page (`serverAppConfigs-docs/LLM/<svc>.md`): Adapter row, Fetch row, Flags row, one
Era row whose rollback is the env line, and a measured before/after paragraph. Laws worth
adding: provenance is metadata not a gate; an `token_embd` rank would crash, so an adapter is
not inert; the runtime pin may gain a second reason. Never restate the card's benchmark table.
@@ -0,0 +1,69 @@
---
name: locale-c-multibyte-awk-silent-loss
description: "Debug generated output that silently loses data under LC_ALL=C — a regex quantifier on a multibyte character in awk/grep, plus the locale-dependent red test it produces."
---
# LC_ALL=C kills multibyte regex quantifiers — silently
Symptom: a generator emits a **complete, exit-0, missing-a-third-of-the-page** artifact in
cron / systemd user units / agent shells, and the same command looks perfect in the user's
terminal. Companion symptom: a repo test is red for the agent and green for the operator.
## Probe (do these in order, they are 5 seconds apart)
```bash
echo "LANG=[$LANG] LC_ALL=[$LC_ALL]" # LC_ALL overrides LANG — en_US.UTF-8 + LC_ALL=C is byte-wise
locale -a | grep -iE 'utf-?8' | head # which UTF-8 locale actually exists on this box
```
Then test the suspect pattern alone, under both locales, with the literal character:
```bash
printf '%s\n' ' // ─── Applications ───' | awk '/─+ / { print "QUANT MATCHED" }'
printf '%s\n' ' // ─── Applications ───' | awk '/─/ { print "LITERAL MATCHED" }'
printf '%s\n' ' // ─── Applications ───' | LC_ALL=C.utf8 awk '/─+ / { print "QUANT MATCHED under UTF-8" }'
```
Literal matches everywhere; the quantifier matches only under a UTF-8 locale. That asymmetry IS
the bug: gawk under `LC_ALL=C` matches bytes, so `─+` means "the last byte of U+2500, repeated" —
a byte sequence that never occurs. mawk/busybox/Go regexp have the same class of failure
(quantifier or bracket class spanning a multibyte char).
## Fix
Match and strip the multibyte literal with no quantifier:
```awk
/^[[:space:]]*\/\/[[:space:]]*─/ { # literal gate
s = $0; sub(/^[[:space:]]*\/\/[[:space:]]*/, "", s); sub(/[[:space:]]+$/, "", s)
if (s ~ /─ .*─/) { gsub(/─/, "", s); gsub(/[[:space:]]+/, " ", s); sub(/^ /, "", s); ... }
next
}
```
**Never pin `LC_ALL=en_US.UTF-8` to "fix" it** — that locale often does not exist (a CachyOS box
here shipped only `C.utf8`), so the script then breaks on another machine. Make the matcher
byte-safe instead.
## Pin it with a test
`run env LC_ALL=C "$GEN" "$IN"` + assert the lost content is present. The operator's UTF-8 terminal
can never catch this class of bug, so the explicit C-locale run is the only durable guard.
## Suite-verification law (how this got missed)
NEVER judge `bats tests/` from `tail` output — a failure at test 28 is invisible in a 25-line
window ending at test 109. Always:
```bash
out=$(bats tests/ 2>&1); echo "$out" | grep -cE '^ok'; echo "$out" | grep -E '^not ok' || echo GREEN
```
Run it under `LC_ALL=C` too: that is the environment every automated caller inherits. Same rule
for `| grep -c` on a multi-line JSON field — count what you claim to have counted.
## Related, do not confuse
- `bats-assertion-trust` — vacuously-passing assertions (green that proves nothing). This skill is
the mirror case: correct assertions that are only red in one locale.
- `gitea-pr-api-cycle` — unrelated; landed here only because the PR work is where this surfaced.
@@ -0,0 +1,48 @@
---
name: monolith-split-under-dist-pin
description: "Use when cutting a monolithic source file into modules behind a black-box test suite whose committed build artifact (dist) must stay byte-identical to src; one verified commit per cut, red-proof against the previous bundle. Not for feature work or doc-only edits."
---
# Monolith split under a committed-bundle pin
Procedure that landed a 1354-line `index.ts` as 9 modules + 301 lines of wiring across 9 commits with zero red commits. Assumes: black-box test suite (tests drive the built bundle, not internals) and a pin test asserting the committed `dist/` equals a fresh build of `src/`.
## Per-cut loop (one commit per cut, always in this order)
1. `bun run build` (or project equivalent) **before** staging — commit `dist/` in the same commit as the moved `src/`, otherwise the pin test fails ("committed dist is byte-identical to a fresh build") and you must amend. Amending is fine only while the branch is unpushed.
2. `tsc --noEmit`, then run the full suite async (`bats tests/ > /tmp/x.log 2>&1; grep -E '^1\.\.'`).
3. **Never write to `src/` while the suite runs.** `setup()` rebuilds the bundle from `src/`, so a concurrent edit attributes someone else's half-written state to your run. Read-only prep (planning the next cut's exact line ranges) is the correct thing to do while waiting.
4. Gate green → next cut. Gate red → fix, rebuild, amend, re-gate.
## Cutting mechanics
- Cut leaf/pure modules first (grammar, transcript rules, URL resolution, pure segmenter), then stateful ones (config, IO, pipeline, UI). Never let a module import `index` — if the new module needs something from `index`, that thing is a leaf and must move too (this is what forced `segment.ts` out early).
- Extract in **verbatim** bodies: read the exact ranges, write them into the new file, then delete the same ranges. Only module docs, export keywords, and injected dependencies change. Keeps each commit reviewable as a move.
- Edit tools that require "displayed lines" from the latest read snapshot: re-read the exact ranges under the current tag, then issue one edit call with all ops listed **bottom-up** (original line numbers throughout). Adjacent ranges merge into one `CUT a=b`; leaving a doubled blank line is the usual scar — one extra `CUT` next turn.
- Mutable config must reach a long-lived object as **getters**, not a construction-time snapshot: menu handlers mutate `cfg` after construction, and a snapshot freezes the settings the UI exists to change.
- After moving code, grep the old file for the moved names: stale call sites compile under bundlers that skip typechecking (`stop()` vs `speaker.stop()` was caught only by `tsc`, not by `bun build`).
## Proving a new assertion is red-capable (before trusting it green)
Build the old bundle and point the test harness at it — no stash, no worktree juggling:
```bash
git show HEAD:dist/index.js > /tmp/old-dist.js # bundle is self-contained
# rebuild the throwaway harness by extracting the driver the suite writes
sed -n "<heredoc-body-range>p" tests/behavior.bats > "$W/driver.ts"
DV_EXT=/tmp/old-dist.js <same env as the test> bun "$W/driver.ts" <scenario> # expect RED
DV_EXT="$PWD/dist/index.js" <same env> bun "$W/driver.ts" <scenario> # expect GREEN
```
Copy the project's real player/server stub verbatim — a simplified stub (e.g. one that skips writing the log file the driver counts) prints a false `PLAYS 0` and looks like a failing fix. Pin the scratch dir to a fixed path; `ls -dt /tmp/tmp.*` picks up another session's directory.
## Black-box test hygiene learned the hard way
- A test that counts artifacts in the **shared** temp directory observes every other process on the box (live agent sessions synthesize into the same `dankvoice-*` dirs). Give the run a private `TMPDIR="$WORK/scratch-tmp"` and assert there; the assertion's sensitivity is proven by the fact it caught a real leftover while scoped globally.
- Let preseeds reference the ephemeral fixture port with a placeholder (`__PORT__`) substituted by the driver, so a test can preseed a URL pointing at its own fixture server.
- Refactor-only commits get **no new test**: re-asserting unchanged labels/order after a commit whose point is that they are unchanged is a tautology; the existing suite is the gate. New tests belong to behavior changes, red-first.
- Tests must observe **output**, never internals: assert `PLAYS`/`CALLS`/`CALL <url>`/`NOTIFY <text>`, not that a variable was set.
## Docs after a split (same branch, own commit)
Move each explanation to the file that now owns it (knob list → the module that reads env; state-machine prose → the module that implements it) and leave the entry point a one-line-per-module map. Then grep for pointers into the old file — a README saying "see header of `src/index.ts`" becomes a lie the moment the knobs move.
@@ -0,0 +1,36 @@
---
name: new-fleet-service-checklist
description: "Add a new deployable service to serverAppConfigs: the four registration touch-points (two allowlists, planner, router) whose drift breaks the first-ever deploy."
---
# Adding a new service to the serverAppConfigs deployer
A new `--service <name>` target must be registered in **four** files or the first deploy fails at a gate. Two fleet services (cadvisor, gpu-exporter) shipped with the client gate missed within a day of each other; the failure mode is `Service 'X' is not deployed on <target>` from `deploy.sh`, exit 2 — before SSH.
## The four touch-points (per target host)
1. `scripts/deploy.sh` → `valid_service()` case: add `<target>:<svc>`. **Client gate** — the one people forget.
2. `scripts/deploy-remote.sh` → `service_is_allowed()` case: add `<target>:<svc>`, **and** the dispatcher `case` branch `<svc>) deploy_<svc> ;;`.
3. `scripts/deploy-plan.py`: the name in the target's service set (`DANKCLAW_SERVICES` / `DANKSERVER_SERVICES`) + `SERVICE_ORDER`, plus a path map so edits to the service's own files select it (for `scripts/<svc>.*` host processes, map the exact filenames like the signal-relay/gpu-exporter maps do).
4. `scripts/test-changed.sh`: route the service's files to its seam, else focused test runs skip it (full `tests/run.sh` still covers).
Sanctioned exception: `dankclaw:searxng-network` is client-only (network unit, no remote handler) — the drift test exempts it.
## Enforcement + local verification
`tests/deployment_flow.bats` test "the client and remote allowlists agree per target" diffs the two extracted lists. Verify before pushing:
```bash
grep -oE 'dank(claw|server):[a-z0-9-]+' <(sed -n '/^valid_service()/,/^}/p' scripts/deploy.sh) \
| grep -v dankclaw:searxng-network | sort -u > /tmp/cli
grep -oE 'dank(claw|server):[a-z0-9-]+' <(sed -n '/^service_is_allowed()/,/^}/p' scripts/deploy-remote.sh) \
| sort -u > /tmp/rem
diff /tmp/cli /tmp/rem # must be empty
```
Also: a `# comment` after a trailing `\` inside a bash `case` pattern list **breaks line continuation** (syntax error) — annotate on its own line above.
## Gotchas
- Merge ≠ deploy: `deploy.sh <host> --service <svc>` must still be run per host; a merged service is dead code until then (GPU stats "not showing" was exactly this).
- `deploy.sh` refuses to run anywhere but a clean `main` checkout: the canonical dev checkout is `/home/blank/Projects/serverAppConfig` (not the session `~/.omp/wt/...` worktree, not the build worktree).
@@ -0,0 +1,64 @@
---
name: niri-monitor-flap-refresh-triage
description: "Diagnose niri/amdgpu monitor failures on blankpad — panel dropping, wrong refresh rate, black/flickering output — and fix the refresh without reloading the compositor or logging Frank out."
---
# niri monitor triage: flaps, phantom modes, 60 Hz fallback
Frank's reaction to a broken display is immediate and loud; the fix must never log him out.
Box: `blankpad` (Cezanne APU, `card1`, s2idle only), compositor `niri --session` as a user unit.
## Step 1 — separate three different diseases (do this before touching anything)
```bash
niri msg outputs # name, mode, position, full mode list per output
for c in /sys/class/drm/card*-*/status; do echo "$(basename $(dirname $c)) $(cat $c)"; done
systemctl --user show niri.service -p ExecMainStartTimestamp -p NRestarts # did niri restart?
journalctl --user -b --no-pager | grep -iE 'disconnecting connector|new connector|Page flip commit failed|DMCUB|falling back to preferred'
journalctl -k -b --no-pager | grep -iE 'amdgpu|PM: suspend exit|\*ERROR\*'
```
Reading it:
- `disconnecting connector: "X"` then `new connector: X` → **link-level drop** (cable/dock/monitor/driver). Count them: `grep -c 'disconnecting connector'`.
- `Page flip commit failed … EINVAL` bursts + kernel `dc_dmub_srv_log_diagnostic_data: DMCUB error` right after `PM: suspend exit` → **amdgpu display-engine state broken by s2idle resume**. Only a reboot clears it; never reboot without an explicit per-run OK.
- `configured mode M could not be found, falling back to preferred` → **config asks for a mode the panel does not offer**; the panel silently runs at EDID-preferred (usually 60 Hz). This is the one you can fix instantly.
- Config mtimes older than the symptom + no `niri` restart ⇒ the config did not cause it. Say so explicitly; agents get blamed for display problems they didn't cause.
## Step 2 — the phantom-mode trap (niri fails soft, so it looks like hardware)
`output { mode "…" }` with a refresh the EDID never advertised does **not** error. niri logs a WARN and drops to `preferred` on every connect → janky 60 Hz ultrawide, blamed on the monitor.
Compare the requested mode against the panel's real list:
```bash
niri msg outputs | sed -n '/<monitor name>/,/^$/p' | grep -E '^\s+[0-9]+'
```
## Step 3 — apply the mode WITHOUT a config reload (the no-logout path)
```bash
niri msg output "<EDID output name from niri msg outputs>" mode "WxH@refresh"
niri msg outputs | grep -A1 '<monitor>' # confirm the new mode
pgrep -c niri # compositor still alive, same PID
```
`niri msg output …` is documented as temporary ("not saved into the config file") — it changes one output and cannot log the session out. Avoid `niri action reload-config` when Frank is working: it reloads his whole config.
## Step 4 — persist, and check you edited the file that is actually live
Live niri config is a symlink chain: `~/.config/niri/config.kdl` → `cfg/display.kdl` → `cfg/desk-<profile>.kdl`, and `cfg/desk-*.kdl` are symlinks into the repo:
```bash
ls -l ~/.config/niri/cfg/display.kdl # -> desk-1-home.kdl
grep -n '239\|180' ~/.config/niri/cfg/display.kdl # prove YOUR edit is in the live chain
niri validate # parses the live config; touches nothing
```
`dotfiles/` is on the `live-sync.sh` heartbeat allowlist, so an edited tracked dotfile stays dirty until the hourly heartbeat commits it — that is the designed state, do not commit it on `main` yourself.
Desk config law (its own header): panels keyed by **EDID identity**, not connector name — but connector-keyed leftovers (`DP-1`, `DP-2`) survive in `desk-1-home.kdl`, and a reconnect that comes back without an EDID string hits those instead. Align them to the same mode, or a flap silently changes your refresh rate.
## Escalate honestly
Reboot clears DMCUB/EINVAL state → schedule it, don't take it. Persistent drops after a reboot → cable / dock / monitor DP-version setting, i.e. hardware, not config. Report which of the three diseases you found and which you did **not** fix.
@@ -0,0 +1,45 @@
---
name: observability-stack-live-census
description: "Census the Dankclaw observability stack (Prometheus/Grafana/Loki/Alloy/Alertmanager/signal-relay) live and audit per-endpoint alert coverage; reconcile against the owner page and rule-file comments, which go stale."
---
# Observability stack — live census + alert-coverage audit
Use when asked "status of the observability stack" on Dankclaw, or before claiming a service is/isn't monitored. Everything runs rootless on Dankclaw: `ssh dankclaw bash -s <<'EOF' … EOF`. Verify live; the owner page (`serverAppConfigs-docs/Infrastructure/Observability.md`) and `shared/prometheus/rules/llm-fleet.yml` comments go stale.
## Loopback ports (do NOT probe the wrong one)
- Prometheus `127.0.0.1:9090` · Alertmanager `127.0.0.1:9093` · Loki `127.0.0.1:3100` · Alloy `127.0.0.1:12345` · signal-relay `192.168.0.143:18093`
- **Grafana host port is `127.0.0.1:3002`** (→ container :3000). Host `:3000` is a `rootlessport` for some *other* rootless container and answers 200-on-`/` but 404-on-`/api/health`. Probing :3000 and seeing 404 is a false alarm — always use 3002 for Grafana. `GF_SERVER_ROOT_URL=https://grafana.franksdank.cloud`.
## Units + containers
`for u in prometheus grafana loki alloy alertmanager node-exporter blackbox signal-relay; do systemctl --user is-active $u.service; systemctl --user is-enabled $u.service; done`
- Quadlet units report `generated` (healthy), not `enabled`; signal-relay is a real host unit (`enabled`).
- `blackbox` = `not-found`/inactive → the known gap (no blackbox exporter).
- `podman ps` for `Up N days (healthy)`.
## Prometheus — targets / rules / alerts
- Targets: `GET :9090/api/v1/targets` → `.data.activeTargets[] | .health, .labels.job, .lastError`. Expect ~8 up / 4 down. **The 4 downs are structural, not outages:** `comfyui` + two `services` (HTML content-type, no real /metrics) and `gitea` (404, metrics disabled). Root remedy = deploy a blackbox exporter (not yet present).
- Rules: `GET :9090/api/v1/rules` → count `.data.groups[].rules`. The `llm-fleet` group is **4 alert + 2 recording** (6 total). **AGENTS.md saying "5 alert + 1 recording" is wrong** — trust the rule file.
- Firing: `GET :9090/api/v1/alerts`; Alertmanager: `GET :9093/api/v2/alerts` and `/api/v2/silences`.
## Alert-coverage per runtime — the sharp step (catches real blind spots)
The `llm-fleet` perf alerts (`LlmRequestsQueueing`/`LlmPrefixCacheCollapse`/`LlmSpecDecodeOff`) key on the **`llamacpp:*`** family. Which endpoints actually emit it:
```
GET :9090/api/v1/query?query=count by (job)({__name__=~"llamacpp:.+"})
GET :9090/api/v1/query?query=up{job=~"frank-.*"}
GET :9090/api/v1/query?query=count by (__name__)({job="frank-archon"}) # what archon really exports
```
Ground truth (2026-10-20):
- **frank-zealot** (llama.cpp) → `llamacpp:*` (17 series) → perf alerts cover it.
- **frank-dragoon** (halogen) → still emits `llamacpp:*` (10 series; halogen re-uses the names) → perf alerts cover it.
- **frank-archon** (radiance) → exports the **`vllm:*`** family (`vllm:num_requests_waiting`, `vllm:gpu_prefix_cache_hit_rate`, `vllm:num_preemptions_total`, **`vllm:time_to_first_token_seconds_bucket`** = a real latency histogram), **not `llamacpp:*`** → **NO perf alert fires on archon**; only `LlmEndpointDown` (`up{job=~"frank-.*"}`) catches it. Up/down yes, perf/queueing/prefix/p95 = collected but unalerted.
- The rule-file header comment claims "frank-archon LEFT the scrape set 2026-10-05, FreeToken exports no /metrics" — **stale/false**: archon (radiance) is `up` and exports `vllm:*`. `LlmEndpointDown`'s description repeats the same stale claim. Never assert coverage from a comment; query the families.
## Log pipeline emptiness test
Loki is "configured, healthy, empty": `GET :3100/loki/api/v1/labels` → `{"status":"success"}` with **no `data` array** = 0 label names = 0 streams. Confirm: `find /opt/loki/data -name '*.tsm' -o -name 'chunks-*' | wc -l` → 0 chunks (≈8 WAL/bookkeeping files only). Alloy ships 0 lines (journal counter 0, both file globs refused per the page — a config bug, not capacity). No Loki datasource in Grafana. Report the log half as non-functional, not merely "healthy."
## Reporting posture
- Metrics half works (up/down for all 3 endpoints + node-exporter + self-scrape); log half dead-on-arrival; blackbox + archon-perf-alerts + Loki-datasource + Alertmanager-vhost are the open gaps.
- Grafana provisioning is file-based — check `podman exec grafana ls /etc/grafana/provisioning/{datasources,dashboards}` (expect `prometheus.yml`, `llm-fleet.json`) rather than fighting the API for creds.
- Alertmanager has no persistence: a reboot re-stamps `startsAt` on long-firing alerts.
- Flag stale facts (AGENTS.md rule split, archon-rejoin comments) but let the user decide whether to fix docs.
@@ -0,0 +1,41 @@
---
name: omp-background-job-deadline-debug
description: "Diagnose a background bash job/service killed mid-run (\"Command timed out after Ns\") in omp: find which deadline owned it (tools.maxTimeout cap vs async default), prove it from the bundle, and launch long jobs uncapped as ready-log services."
---
# Omp background-job deadline forensics
Symptom: an async `bats`/build job dies with `[Command timed out after 300s|600s]`
while the same launch once ran 18+ min ("no deadline").
## Cause map (proven against the pinned bundle)
Resolver in `dist/cli.js`: `let v=n??300, R=v===0, k=R?void 0:ep("bash",v,x)` where
`qx.bash={default:300,min:1,max:3600}` and `x` = config key `tools.maxTimeout`
("Maximum timeout in seconds the agent can set for any tool", default 0).
- Request absent → `qx.bash.default` 300 s.
- Request > 0 → clamped by `tools.maxTimeout` when set (e.g. 600 caps a 1800 request).
- Request 0 → meant "no deadline", but `0` sometimes arrives as missing (falsy drop
upstream of the resolver) → silently 300 s. Filed as tool issue; treat any
async job over ~5 min as untrustworthy.
## Diagnose in three moves
1. `grep -o 'qx={bash:{[^}]*}' <omp bundle>` — policy table (`command -v omp` →
`readlink -f` → `dist/cli.js`).
2. `read cfg://tools` — live `maxTimeout` (value ≠ 0 explains any hard cap).
3. Compare kill notices: the number in "killed once it has run Ns" is the resolved
deadline; 300 = default reached (request dropped), other values = your request
or the config cap.
## Launching jobs longer than the cap
- Named **services are not deadline-capped**: `bash(name:"x", pty:false, command:"…; echo DONE >> log", ready:{log:"DONE", timeout:900})`.
Readiness fires on the log pattern; result does NOT auto-deliver — `wait` on it or read `proc://x` after the supervisor notice.
- NEVER end the service command with `echo "EXIT $?" >> log` and trust the
supervisor's exit code — the trailing echo masks it (supervisor reports 0 while
the log says EXIT 1). The log line is the truth; grep the log for the recorded
code and `^not ok` before believing any "exit code 0".
- Do not raise `tools.maxTimeout` unasked: session-scoped `write cfg://tools/maxTimeout`
needs user approval and may be declined — respect it, use the service route.
## Multi-suite hazard
Suites that rebuild shared artifacts per test (e.g. dankvoice bats rebuilds `dist/`)
must not run concurrently — one suite at a time, or per-worktree copies only.
@@ -0,0 +1,38 @@
---
name: omp-bundle-implementation-port
description: "Extract and port a working implementation from omp's minified dist/cli.js bundle (copy-not-invent requests); anchors, python slicing, regex-constant sweep, trace-driven test prediction."
---
# Porting built-in omp behavior out of the minified bundle
Use when Frank says "copy the omp implementation / work off known-working code" — i.e. port a built-in feature (vocalizer segmentation, retry logic, formatting rules) into our own file instead of hand-rolling a heuristic. For merely *auditing behavior claims*, use `omp-bundle-claim-audit`; this skill is extraction-for-porting.
Bundle: `~/.bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js` (minified; single-letter names, private `#x` fields survive).
## Extraction recipe
1. **Find the setting/entry strings first.** `grep -o 'ce({id:"<area>[^"]*"[^}]\{0,200\}' cli.js` — setting descriptions state semantics verbatim (e.g. `speech.enhanced` = "Rewrite assistant output into natural spoken prose with the tiny/smol model"). Descriptions are quotable ground truth.
2. **Anchor to code with a distinctive token**, not guesses: a method name (`pushDelta`), a state init, or a string. Then slice generously with python (bash heredoc), NOT grep -o which truncates:
```python
src = open('cli.js').read()
i = src.index('pushDelta'); print(src[i-1500:i+3000])
```
3. **Follow the constructor**: pipeline classes instantiate collaborators there (`this.#t ??= new fHe` → `fHe` is the real worker; `#g()` returned `{speakable:new fHe, pending:[]…}`). Dump `class fHe{` +4000 chars.
4. **Regex/helper sweep**: minified code declares shared constants in a trailing `var a,b,c,…` list and initializes them elsewhere (`Xyr=`, `Pwc=function`). Loop the names with `re.search(rf'{name}=', src)` and print ~400 chars each — the actual boundary/cleanup rules live in these one-liners.
5. Write slices to `/tmp/*.txt` and `read` them; don't eyeball megabyte greps.
## Port rules
- Rename fields/methods to readable names but keep logic **1:1** — the value is behavioral fidelity (Frank's "known-working set of code").
- Private `#x` fields port cleanly: TS classes in Bun extensions support `#`.
- Global-regex `.lastIndex=0` before each scan loop — minified code relies on it; keep it.
## Verification shortcut
Before running tests, **hand-trace** each test input through the ported rules (boundary indices, state transitions) and predict exact segment counts/strings; then run — predicted-vs-actual mismatch localizes port errors faster than test output alone. Worked example (2026-10-19, dankvoice PR #26): traced `"First stop here. Second one goes!!! Really? Third ... done"` → predicted 3 segments incl. `"Third ... done"` merged via flush (10-char boundary < 24-char started-min) → bats green first run.
## Known anchors (2026-10-19 bundle)
- Speak-aloud vocalizer pipeline: class `I6o` (gating on `speech.enabled`, suspend counter, enhancer, `#g()` per-message state).
- Utterance segmenter: class `fHe` + helpers `Pwc` (line-head classify), `Iwc`/`_wc`/`b6o`/`w6o` (boundary search), `Cwc`+`y6o` (cleanup: links→label, URL→host, path→basename). Regex consts: `Xyr` sentence-end runs, `pHe` weak breaks, `mwc` abbreviations, `hwc` undecided line-heads, `g6o` horizontal rules.
- Port already living in `agent/extensions/dankvoice.ts` (`SpeechSegmenter`) — when the bundle updates, re-extract `fHe` and diff against it before assuming our copy still matches.
@@ -0,0 +1,73 @@
---
name: omp-extension-event-probe
description: "Prove which lifecycle events the omp harness actually dispatches to extensions, with a throwaway timestamping probe plugin, instead of inferring delivery from side effects."
---
# Prove harness→extension event delivery with a probe plugin
Use when an extension handler seems never to fire, or when you must know *when* an
event arrives relative to another (mid-run vs settle). Never infer dispatch from
the feature's side effects: async work in the handler (LLM rewrites, synthesis,
network) lags seconds and destroys the timing signal.
## Shape (no build step needed)
`/tmp/probe/package.json` — the installer validates the declared entry on disk:
```json
{
"name": "event-probe",
"version": "0.0.0",
"type": "module",
"exports": "./index.js",
"pi": { "extensions": ["./index.js"] }
}
```
`/tmp/probe/index.js` — append arrival times, not counts:
```js
import { appendFileSync } from "node:fs";
const LOG = "/tmp/probe.log";
const stamp = (line) => appendFileSync(LOG, `${new Date().toISOString().slice(11, 23)} ${line}\n`);
export default function probe(pi) {
pi.on("agent_start", () => stamp("agent_start"));
pi.on("turn_end", (e) => stamp(`turn_end turn=${e.turnIndex} toolResults=${(e.toolResults ?? []).length}`));
pi.on("agent_end", (e) => stamp(`agent_end messages=${(e.messages ?? []).length} willContinue=${String(e.willContinue)}`));
}
```
## Run
1. `cd ~/.omp/plugins && omp plugin link /tmp/probe` (link, never edit
`node_modules`), then `omp plugin doctor` → expect a `✔ plugin:event-probe` line.
2. Launch a **fresh** TUI session in a pty (`omp --cwd /tmp/probe-run`, `pty: true`).
Extensions load at startup only — an already-running session never sees the probe.
3. PTY gotcha: input sent before the splash clears is swallowed. Read the screen
first, then submit text and finish with a separate write containing a bare
`\r` (LF alone left the text sitting in the composer).
4. Drive a task with ≥2 tool rounds so turns and settles separate in time
(sequential reads work and need no approval; ask for steps "strictly one
after the other" or the model parallelises them into one turn).
5. Wait for a terminal line with an async bounded loop (`grep -q agent_end`,
`sleep 1` inside one backgrounded bash call) — never poll across turns.
## Read
`turn_end` timestamps strictly earlier than `agent_end` ⇒ per-turn delivery
works. `willContinue=true` on `agent_end` marks a non-terminal settle (harness
maps `isTerminal === false` to it) — handlers that `return` on it never speak.
## Teardown (mandatory)
`omp plugin uninstall event-probe`; `omp plugin doctor`; delete `/tmp/probe*`.
The link writes into `~/.omp/plugins/package.json` (tracked, live tier) —
uninstall restores it; confirm with `grep` for the probe name returning nothing.
## Ground truth read for free
Event union + payloads: `~/.omp/plugins/node_modules/@oh-my-pi/pi-coding-agent/dist/types/extensibility/shared-events.d.ts`;
the session→extension mapping lives in `src/extensibility/extensions/lifecycle-mirror.ts`
(`extensionEventFromSessionEvent`). Extension-level `turn_end` carries
`turnIndex`/`message`/`toolResults` but **no `willContinue`** — that field exists
only on `agent_end`, so "is this the last turn" cannot be decided from `turn_end`.
@@ -0,0 +1,48 @@
---
name: omp-extension-stale-module-audit
description: "Diagnose omp extension misbehavior (handler timeouts/silence, cross-instance arbitration failures like simultaneous speech) when the on-disk fix is already deployed: running sessions hold pre-upgrade in-memory bundles; compare ps lstart against artifact mtimes and merge dates."
---
# Stale in-memory omp extension bundles
Symptom class: an extension behaves as if unfixed even though the disk artifact
contains the fix — recurring handler timeouts, silence, or (multi-instance
coordination features) arbitration failures that look like code bugs. Root
cause: **omp loads the bundle at session start; `omp plugin upgrade` on disk
does not touch the code already running in live sessions.**
## Recipe 1: date the sessions against the artifact
```bash
date
stat -c '%y %n' ~/.omp/plugins/node_modules/<pkg>/dist/index.js # last upgrade
ps -eo pid,lstart,etime,args | grep 'omp' | grep -v grep # each session's boot
```
Any session whose START predates the artifact mtime holds an older bundle —
for it, the "fix" never shipped. When the feature landed in git, compare
against the merge date, not the last upgrade (`git log --format='%h %cI %s'
main | grep -i <feature>`): a session can predate the feature *existing*.
Fix is always: restart (`/quit`, then `omp --resume <id>`) — resume is
lossless. It is the user's live session; never kill it yourself.
## Recipe 2: cross-process arbitration — a bypasser explains "simultaneous X"
When a feature serializes across sessions via files (e.g. dankvoice stage:
`~/.cache/dankvoice/stage/` with `lease.json` = `{holder,pid,expires}`, one
`<ISO>-<session>.json` entry per queued item, FIFO = filename order, lease TTL
6 s), overlap between two actors proves **at least one actor never consults
the directory** — the mechanism can't serialize against a bypasser. Census the
queue live (`watch -g -d 'ls <dir>; cat <dir>/lease.json'`): a live, renewing
lease proves the new sessions coordinate; then Recipe 1 finds the bypasser.
Known residual hazard to note, not silently fix: a lease heartbeat is a timer
on the holder's own event loop; a stall longer than the TTL lets a peer steal
the lease mid-action. Mitigation = pid-liveness check in the steal path.
## Verification
After restarts, the symptom must vanish while the queue shows traffic. If it
persists, look for actors on OTHER hosts (file arbitration is per-box), or
the TTL-steal hazard above.
@@ -0,0 +1,27 @@
---
name: omp-lifecycle-event-selection
description: "Pick the right omp harness lifecycle event for narration/telemetry: agent_end is a settle, turn_end waits for tool results, message_end is earliest with content, after_provider_response carries no content."
---
# Choosing an omp lifecycle event (narration, telemetry, hooks)
Re-derived twice from the installed typings/sources (`@oh-my-pi/pi-coding-agent`, `@oh-my-pi/pi-agent-core`) while fixing dankvoice. Read this before wiring a handler; verify against the installed version's `src/extensibility/extensions/types.ts` and `pi-agent-core/src/agent-loop.ts` when precision matters.
| event | payload you can use | when it fires | traps |
|---|---|---|---|
| `after_provider_response` | `status`, `headers`, `requestId`, `metadata` **only** | response headers arrive, **before the stream body is consumed** | No message content at all: useless for anything that speaks or summarizes text. Right for latency telemetry, non-200/abort detection, `requestId` correlation. |
| `message_end` | full `message` (assistant, user, tool-result, gate-stop, error) | the moment that message is built (`agent-loop.ts`, assistant case) | Earliest event carrying assistant text. Fires for non-assistant messages too → gate `message.role === "assistant"`. Payload is cloned for extensions. |
| `turn_end` | `turnIndex`, `message`, `toolResults` | after that turn's **tool results** come back | Stale by the tool duration: a 14 s tool delays a `turn_end` trigger 14 s. Extension event has **no `willContinue`** (the `turnIndex` is numbered by the lifecycle mirror), so you cannot tell the terminal turn from here. |
| `agent_end` | `messages`, `willContinue`, `awaitingAsyncWork` | once per user prompt, when the loop settles | `willContinue: true` = the harness scheduled a continuation (auto-retry / empty-stop), not a user-visible settle — narrating it speaks an answer the model is about to redo. Silence-prone on long-running sessions. |
| `agent_start` | — | run begin | Use to reset per-run dedupe state, so a later answer with identical text is not swallowed. |
## Narration cadence pattern (what dankvoice ships)
- `turn_end` (or `message_end` if the tool-time lag matters) for mid-run commentary; `agent_end` only for the settled answer.
- Drop a mid-run trigger while audio/IO is busy — never queue, never interrupt.
- Dedupe the terminal text per run (`agent_start` resets), because the last turn and the settle carry the same string.
- Skip `ctx.mode === "print" | "json"` so benches stay silent.
## Prove delivery, don't infer it
Audio/UI timing cannot show which events arrive. Link a throwaway extension that appends timestamps per event and run one real session: `omp plugin link /tmp/probe` (package.json with `"pi": { "extensions": ["./index.js"] }`), prompt a task with a slow tool, read the log, then `omp plugin uninstall probe`. Confirmed live 2026-10-19: extensions receive `agent_start`, `turn_end`, `agent_end`; `turn_end` landed seconds before `agent_end`.
@@ -0,0 +1,40 @@
---
name: omp-local-artifact-path
description: Resolve a local:// artifact to a real filesystem path (for scripts/curl) without guessing the session-slug directory.
---
`local://<name>.md` artifacts live in a per-cwd, per-session directory whose name you cannot guess. Three invented paths failed before this worked (2026-10-19).
Actual layout:
```
~/.omp/agent/sessions/<cwd-slug>/<timestamp>Z_<session-id>/local/<name>.md
```
`<cwd-slug>` is the working directory with `/` replaced by `-`, **keeping the leading dash** — `/home/blank/.omp/wt/wt-20261019-125509-7c8a99d` under `/home/blank` becomes `-.omp-wt-wt-20261019-125509-7c8a99d`. Dropping the leading `-.` is the mistake that produces `FileNotFoundError`.
## Get the real filesystem path
Ask the tool, don't reconstruct it — a ranged read of the artifact reports the resolved path in its header:
```
read local://pr-body-msgend.md:1-1 -> [~/.omp/agent/sessions/-.../local/pr-body-msgend.md#TAG]
```
Or locate it by basename (needs enough depth; `maxdepth 5` misses it — the file sits 6–7 levels down):
```bash
f=$(find ~/.omp/agent/sessions -maxdepth 7 -name 'pr-body-msgend.md' | head -1)
cp "$f" /tmp/prbody.md # stable path for the python/curl step
```
Copy to `/tmp` before handing the content to a script: one cheap command, and every later tool call stops depending on session-scoped paths.
## Related lookups in the same directory tree
- Session id for the current cwd — `~/.omp/agent/history.db` (prompts table has `session_id`, `cwd`; the column is `prompt`, **not** `text`, and raw `?q=` queries cannot be combined with a table selector or pagination):
```
read history.db?q=SELECT session_id, cwd, substr(prompt,1,50) AS p FROM history WHERE cwd LIKE '%wt-20261019%' ORDER BY rowid DESC LIMIT 3
```
Use the real id in issue/PR attribution footers; never reuse an id remembered from a compacted transcript.
- Transcript dir for the same session sits beside `local/`.
@@ -0,0 +1,78 @@
---
name: omp-search-provider-fallback-debug
description: "Diagnose why the omp harness web_search silently uses a fallback provider (e.g. Kagi) instead of the configured primary (SearXNG): the fallbackChains masking pattern, the searxng.endpoint base-URL scheme/guard-chain trap, and the cli.js + content-type probes that prove it."
---
# omp web_search silently using the wrong provider
Symptom: agent searches return results from a fallback provider (e.g. **Kagi**) even though
`modelRoles.web` is set to the intended primary (e.g. `web/searxng`). The primary errors
every time and the failure is invisible because a fallback answers successfully.
## The masking mechanism (always check this first)
`~/.omp/agent/config.yml`:
- `modelRoles.web:` is the primary (e.g. `web/searxng`).
- `retry.fallbackChains.web:` is an ordered list tried when the primary throws. The FIRST
entry silently serves whenever the primary errors. In this fleet it is `web/kagi`, so any
SearXNG error == Kagi answers and you never see an error.
So "searches go through Kagi" almost always means "SearXNG is failing", not a routing mistake.
## The SearXNG endpoint trap (the usual root cause here)
`searxng.endpoint` in config.yml must be a **full base URL pointing at the direct JSON port**,
used **verbatim** by omp (no scheme auto-added, no normalize — confirmed in `cli.js`). Two ways
it breaks:
1. **Bare host** (e.g. `search.franksdank.cloud`, no scheme): omp does `new URL(\`${endpoint}/search\`)`
→ **throws** immediately (URL needs a scheme). SearXNG never contacted.
2. **The guarded vhost** (`https://search.franksdank.cloud`): returns Anubis' **"Making sure
you're not a bot!"** challenge page at HTTP **200 `text/html`**. omp calls `o.json()`
unconditionally regardless of content-type → throws on the leading `<`. There is
deliberately NO API path through the guard chain (JS challenge + UA denylist).
**Correct value** (direct published port, http scheme preserved — reachable from the LAN only):
```yaml
searxng:
endpoint: http://192.168.0.143:8081 # Dankclaw; NOT the vhost, NOT schemeless
```
The published port source (192.168.x/loopback/LAN) is in SearXNG's `pass_ip`, so it bypasses
the JSON rate limiter (hardcoded `API_MAX=4`/IP/hr, not settable except via `pass_ip`).
## Prove it (don't guess)
1. Content-type probe, both paths — the decisive check:
```
curl -sk -o /tmp/vh -w "vhost ct=%{content_type}\n" "https://search.franksdank.cloud/search?q=test&format=json" # expect text/html (Anubis)
curl -s -o /tmp/dp -w "direct ct=%{content_type}\n" "http://192.168.0.143:8081/search?q=test&format=json" # expect application/json
```
vhost = `text/html` + direct = `application/json` confirms the trap.
2. Code path, in the installed omp dist (NOT a worktree):
`/home/blank/.bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js`
(find install: `dirname $(readlink -f $(which omp))`). Grep the SearXNG provider:
- `searxng.endpoint` config def → description "Base URL of a self-hosted SearXNG instance".
- the request builder `...=e.replace(/\/+$/,""),new URL(\`${r}/search\`)...` → endpoint is
verbatim, scheme NOT added.
- `X2c`/`J2c` → `if(!o.ok)throw` then `return await o.json()` → HTML 200 passes `.ok` and
blows up in `.json()`.
## Apply + verify
- Fix is a harness config edit (`cfg://searxng/endpoint` prompts the user, or edit config.yml).
Confirm with the user first — it is their live harness config.
- Verification after applying: fire a real `web_search` and confirm it returns
`provider:"searxng"` (not `"searxng"`-then-fallback / not `"kagi"`).
## Related gotcha (same masking, different cause)
If SearXNG answers JSON but returns empty `results` with `unresponsive_engines` (VPN-exit-IP
CAPTCHA/429 on google/brave), omp's `vcs` throws a 503 → Kagi again. That's an engine-health
problem, fix in `[[SearXNG]]`/`[[SearXNG Providers]]`, not the endpoint.
## Hardening option
Drop or reorder `web/kagi` / `web/perplexity` in `retry.fallbackChains.web` so primary failures
surface instead of silently spending a paid Kagi credit. Flag to user before editing.
@@ -1,49 +1,22 @@
---
name: omp-worktree-path-gotchas
description: "Use in ~/.omp (or any multi-worktree repo) when an edit lands in the wrong checkout, git checkout is policy-refused, or an omp session id is needed — relative-path tool resolution, shared-worktree branch law, session-id lookup, edit auto-prefix trap."
description: "Multi-worktree edits/tests land in the wrong checkout: bash persistent-cwd drift, phantom file reverts, which tree a tool really touched"
---
# Multi-worktree gotchas in ~/.omp-style repos
## Purpose
Answer "why did my file/tests revert or behave differently than what I just wrote?" in repos with several worktrees (e.g. `~/.omp`, `~/Projects/wt-*`, `~/.omp/wt/*`).
Measured 2026-10-18 building PR #12/#14 in `dank/command-center` worktrees.
## Failure modes and fixes
## 1. Relative paths resolve to the SESSION worktree — always edit by absolute path
`edit`/`read`/`write` with a relative `path` resolve against the session CWD
(the harness worktree you were spawned in), NEVER the worktree you `cd`'d into
via bash. Identical file content in sibling worktrees yields identical
`[path#tag]` hashes, so an edit can "succeed" against the WRONG checkout with
no warning; only a rejected stale-hash edit reveals it.
1. **Bash tool persistent-cwd drift** (the expensive one): the persistent shell keeps its cwd across calls; a call *without* an explicit `cwd` runs in whatever the *previous* call left, which after interleaved work may be a DIFFERENT worktree. Result: reads/greps/appends hit tree A while `git`/`bats` hit tree B — symptoms are "file keeps reverting to HEAD", duplicate test names at shifting line numbers, scenario `CALLS 0` for scenarios that exist, mismatched `grep -n` vs tool-reported line numbers.
- **Diagnose:** `pwd` in the same call as the failing command; `wc -l <file>` in BOTH candidate worktrees; compare md5s. The file is not flapping — two files exist and commands alternate between them.
- **Fix/prevent:** pass an explicit `cwd` on EVERY bash call once two or more checkouts are in play; never trust a read taken without one as evidence about "the" file.
- When working in a second worktree, ALL file-tool paths: absolute.
- Symptom seen: edit rejected "hash #X is not from this session" while the
bash-side `git status` showed the file clean — two different files, two
worktrees. Recovery: revert the stray edit (CUT the inserted lines) in the
wrong tree, re-read the right tree, redo with absolute header.
2. **Symlinked or stale `git` state**: `git status` in a worktree shows a clean tree while the disk file differs from what you intended — the corruption is already committed. Audit committed blobs (`git log --oneline -- <file>`; per-commit `git show <c>:<path> | grep -c <sentinel>`) instead of re-reading the disk copy repeatedly.
## 2. Branching: shared worktrees refuse git checkout
Workspace policy blocks `git checkout` (branch switches AND `--` discards) in
harness-managed worktrees — concurrent agents depend on them. To start a
branch off fresh trunk:
3. **Same-name files across worktrees** (`tests/behavior.bats` exists in each): a `find`/`grep` rooted above the worktrees mixes copies; anchor the search path to one worktree root.
```bash
git -C ~/.omp worktree add -q -b <type>/<issue#>-<slug> ~/.omp/wt/<name> origin/main
```
Never force past the refusal; the new-worktree route is strictly safer.
Custom worktrees not matching `wt/wt-*` are invisible to the live-sync reaper —
prune them by hand after merge (`git worktree remove`, `git branch -d`).
## 3. omp session id for attribution footers
Fleet law requires `session:` in issue/PR bodies. Authoritative source:
`~/.omp/agent/sessions/<cwd-slug>/<timestamp>_<UUID>.jsonl` — the `<UUID>` is
the session id. Pick the newest file whose first `{"type":"title"}` line names
this conversation to prove the id is yours. The `wt/<ts>-<hash>` dir suffix is
NOT the session id (it persists across sessions per cwd).
## 4. edit-tool auto-prefix trap (recurring)
A body row written bare (intending it as the "original line" anchor or as
context) is AUTO-PREFIXED with `+` and INSERTED — leaving the old line plus a
duplicate. `PUT n.=n:` therefore behaves like insert, not replace, whenever
the anchor row lacks an explicit marker. After any `PUT` where the response
warns "Auto-prefixed bare body row(s)", immediately `CUT` the stale line number
shown in the response (descending order for multiple CUTs).
## Repair recipe for a corrupted-but-committed test file
- Inventory both versions: `grep -o '@test "[^"]*"' | sort` and `grep -o 'case "[a-z]*":' | sort -u`; `comm` the sets to find lost scenarios and duplicate tests.
- Rebuild from the committed copy (`git show HEAD:<path> > <path>`, no checkout), then re-apply only the true deltas: restore missing scenario blocks from the last clean base, dedupe imports, rename scratch-dir mkdtemp prefixes that collide with a test's own orphan-census glob (e.g. `dankvoice-stage-` matching `dankvoice-*`).
- Re-run the targeted tests, then the full suite single-process.
@@ -0,0 +1,57 @@
---
name: open-notebook-provision-model-rest
description: "Provision or repair an open-notebook model (LLM/TTS/embedding) over its REST API on Dankclaw: credential→model→defaults order, the defaults-store-the-ID trap, the /api/embed 500-that-succeeds, and the two-note ranking proof for embeddings."
---
# open-notebook model provisioning over REST
Use when adding/repairing a model row (embedding, LLM, TTS) in open-notebook on Dankclaw, when semantic
search is dead, or when an embed error appears in the worker log. Config lives in the **database**
(encrypted with `OPEN_NOTEBOOK_ENCRYPTION_KEY`) — no repo file mirrors it, REST or the UI are the only doors.
## Access (never put the bearer in argv)
The API bearer IS `OPEN_NOTEBOOK_PASSWORD`; the API publishes on loopback `127.0.0.1:5056` only, so run
from Dankclaw over ssh. `ssh dankclaw 'curl -H "Authorization: Bearer …"'` leaks the secret into
`/proc/<pid>/cmdline` — use a mode-600 curl config:
```bash
PW=$(grep -m1 '^OPEN_NOTEBOOK_PASSWORD=' /home/dank/Containers/open-notebook/env | cut -d= -f2- | tr -d '"')
cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: Bearer %s"\n' "$PW" > "$cfg"
B=http://127.0.0.1:5056 # not 5055: seerr holds the host's 5055
```
## Order, and the address rule
`POST /api/credentials` → `POST /api/models` (`credential: <credential id>`) → `PUT /api/models/defaults`
→ any profile that names a model (speaker `voice_model`, episode `speaker_config`/`outline_llm`).
- Credential provider for any in-house OpenAI-compatible server: **`openai_compatible`** (not `openai`),
with a dummy `api_key` when the upstream does not authenticate.
- The app's URL guard refuses base URLs resolving in `169.254.0.0/16`, so `host.containers.internal`
cannot be stored; a **container name on `dankclaw_default`** (e.g. `http://frank-dragoon-halogen:8731/v1`)
or another machine's RFC1918 IP both pass.
## The two traps that eat the most time
1. **`PUT /api/models/defaults` takes the model ID, never the name.** `"default_embedding_model":
"qwen3-embedding-0.6b"` is accepted and echoed back, then every job dies in the worker with
`Model with ID qwen3-embedding-0.6b not found`. Use the row's `model:…` id (GET defaults first, change
one key, PUT the whole object back — the other keys in that response are ids and must be preserved).
2. **`POST /api/embed` answers 500 *after* the work succeeds.** Upstream `EmbedResponse.command_id`
declares `str` and the worker returns a `RecordID`, so FastAPI raises in serialization only. The truth is
`podman logs open-notebook | grep "Successfully embedded"`, never the HTTP code. Note saves
(`POST /api/notes`) submit `embed_note` themselves; `/api/embed` is only for backfills.
## Proof it actually works
- Per row: `POST /api/models/<id>/test` → `Embedding dimensions: 1024` (embeddings) or a real completion.
- Embeddings, discriminating test: create **two** unrelated notes, then
`POST /api/search` `{"query": "...", "type": "vector", "search_notes": true, "limit": 5}` — the enum is
`text|vector` (not `semantic`) and the score field is `similarity`. Each query must rank its own note
high and the other low (measured 0.659/0.292 vs 0.906/0.318). One note proves nothing: `total_count` is 1
either way. Delete the probe notes afterwards (`DELETE /api/notes/<id>`).
- Confirm which engine served it: `podman logs <engine container> | grep "POST /v1/embeddings"`.
Owner pages: `serverAppConfigs-docs/Dankclaw/Open Notebook.md` (app-side shapes), and the endpoint's own
page for whatever serves the model.
@@ -0,0 +1,41 @@
---
name: parallel-suite-shared-db-flake
description: "Diagnose test suites that pass isolated but fail only under concurrent/uncached runs against a shared database: cross-file teardown wipes, FK-cascaded sessions, wildcard LIKE deletes."
---
# Flaky only under concurrency on a shared DB
Symptom: a suite passes `vitest run <file>` alone, fails under `turbo run test --force`
(or any concurrent uncached runner) with impossible errors — 401 where 404 was
expected, rows vanishing mid-test, sessions dying between two awaited requests.
## Procedure
1. **Reproduce the concurrency, not the file.** Isolated runs prove nothing.
`bun x turbo run test --force --output-logs=errors-only` (or run the two suspect
files simultaneously). Never conclude "flake" from one green isolated run.
2. **Map the impossible state to a vanished row.** 401/auth failures where the
session was created milliseconds earlier = the session/user row was DELETED
under the test, not expired. Look for writers, not readers.
3. **Grep every suite's teardown for shared-namespace deletes:**
`grep -rn "TRUNCATE\|DELETE FROM\|LIKE" <pkg>/src/*.test.*`
The classic killer: two test files sign users up under the same domain
(`%@studio.test`) and each `afterAll` runs a wildcard `DELETE ... LIKE '%@...'`.
With parallel files on one DB, whichever file finishes first cascades away the
other's live rows (user FK cascades kill sessions mid-flight).
4. **Fix = scope, never coordinate.** Each suite deletes only rows it created:
per-file email/id prefixes (`auth*` vs `rel*`), or track created keys and
`WHERE email = ANY(...)`. Timing tricks (sleeps, retry) hide the collision.
5. **Prove stability:** 3 consecutive `--force` uncached runs green, then the
full gate. Cache makes everything look green; only uncached concurrent runs
re-exercise the collision window.
## Gotchas
- Error signatures lie: `expected 401 to be 404` reads as an auth bug but is a
deleted-row cascade.
- Supertest `cookieOf`-style helpers that THROW on missing cookies prove signup
succeeded — so the session died after creation; keep hunting for the deleter.
- Shared dev DBs (one Postgres for all workspaces/worktrees) make every wildcard
teardown a cross-suite weapon. Suite teardowns on a shared box MUST scope to
rows the suite created.
@@ -1,68 +1,34 @@
---
name: parallel-worktree-path-hazard
description: "\"Use when several agents edit the same repo in different git worktrees at once, or when an edit/read may have landed in the wrong checkout: which path forms bind where, how to detect with git -C status, and the repair protocol (cp + git show HEAD blob write-back) because git checkout/restore is policy-blocked in a shared workspace.\""
description: "Use when several agents edit the same repo in different git worktrees at once, or when an edit lands in the wrong checkout: relative tool paths resolve to the session cwd, not the shell cwd."
---
# Concurrent-worktree path hazard (shared repo, many agents)
Use when several agents (or you across two checkouts) edit the same repo in different git worktrees at once, or when an edit/query lands in the wrong checkout.
Measured 2026-10-18 on `serverAppConfigs` with two agents live at once. The
failure is **silent**: the tool reports success, the diff looks right, and the
change lives in a *different* worktree.
## The silent failure: relative tool paths resolve to the SESSION cwd
## Which path form binds where
- **Parent/top-level session**: a **relative** `path` resolves against the
session CWD — its own worktree — never against the worktree named in the
prompt. This is how a change meant for `wt-tts-trial-docs` landed in
`wt-20261017-…`.
- **Subagent**: an **absolute** path to its own assigned worktree can resolve
into the parent's worktree when the same *relative* path exists there
(`edit` twice onto `dankclaw/.env.example`). Its `write` with the same
absolute path was fine — the bug is `edit`/`read` path resolution, not `write`.
`read` / `edit` / `write` / `grep` resolve a **relative** `path` against the **session working directory** — never against the `cd` inside a neighboring `bash` call, and not against a `../other-worktree/...` prefix either (that gets normalized back into the session tree). So after `cd ../wt-B && grep …` you can `read path="docs/page.md"` and get **wt-A's** copy.
Rules that hold:
1. A subagent passes paths as `../<its-worktree>/<rel>` (relative to the parent
worktree) for `read` **and** `edit`; the parent passes **absolute** paths to
any worktree that is not its own CWD.
2. After every edit batch, prove the landing site, do not trust the tool's echo:
`git -C <intended-worktree> status --short` must list the file. A file you
edited that is missing from that list went somewhere else.
3. Keep each work item on its own branch/worktree, and keep ONE integration
owner (the parent) for shared files — `scripts/deploy-remote.sh`,
`scripts/deploy-plan.py`, `tests/`, `serverAppConfigs-docs/index.md`,
`AGENTS.md` are the collision hot spots in this repo. Never rebase a
sibling's branch.
Why it survives "I just read it to check": the returned path is printed workspace-relative and the file name is identical in every checkout, so the output looks like a correct read of the tree you meant. Only content that differs between the checkouts betrays it.
## Repair protocol (no `git checkout`, no `git restore`)
`git checkout -- <path>` and `git restore <path>` are **policy-blocked** in a
shared workspace ("overwriting working-tree files … discards changes other
concurrent agents rely on"). Unstaging (`git reset [<path>]`,
`git restore --staged <path>`) is allowed but does not undo content. So:
Rules:
- **Absolute paths for every tool call that targets a worktree other than the session's.** No exceptions for quick reads.
- Confirm the checkout from content you know differs (a heading, a status line), not from the echoed path.
- A `read` error like "Did you mean .AGENTTRASH/<name>" is a signal the relative path resolved into the session tree — re-issue absolute.
- Same trap for `bash`: it starts each call in the session cwd, so `cd` twice per call or use absolute paths there too.
```bash
F="path/with spaces if any"
# 1. move the change to the worktree that should own it (both at the same base commit)
cp "<wrong-wt>/$F" "<right-wt>/$F"
# 2. undo in the wrong worktree by writing the committed blob back
git -C "<wrong-wt>" show "HEAD:$F" | tee "<wrong-wt>/$F" > /dev/null
# 3. prove both sides
md5sum <(git -C "<wrong-wt>" show "HEAD:$F") "<wrong-wt>/$F" "<right-wt>/$F"
git -C "<wrong-wt>" status --short # must be empty for that file
git -C "<right-wt>" diff --stat # must show the intended delta
```
## Landing a change when the session worktree is stale
Never fix it blind: if the wrong worktree had its own uncommitted edits to that
file, `cp`/blob-write-back would destroy them — check
`git -C <wrong-wt> status --short` FIRST and reconcile content, don't overwrite.
`git diff --name-only HEAD origin/main` first. If main moved a file you are editing, your edit's base is wrong even though the file "looks fine" — do not commit on the stale base.
## Tidy after a cancelled sibling
A killed subagent can leave a worktree plus an empty branch. Removing them is
safe only when verified empty: `git -C <main> log --oneline -3 <branch>` (no
unique commits) and `git -C <wt> status --short` (clean), then
`git worktree remove <wt>` + `git branch -D <branch>`. An orphan worktree at a
stale commit is the same class of trap as a leftover bench drop-in: the next
agent reads it as unfinished work.
1. `git worktree add ../wt-clean -b <type>/<slug> origin/main`; copy the new files in.
2. Re-apply the edit against the clean tree's version **with absolute paths**, then read it back absolutely to prove which tree changed.
3. Run the suite inside the clean worktree; commit there.
4. Remove your own duplicate edit from the session worktree by editing it away (`CUT`/`PUT`). `git checkout -- <path>` and branch switches are policy-blocked in shared workspaces and should stay blocked — they discard edits other agents rely on.
5. Never carry the same uncommitted edit in two worktrees at once; if you must keep both trees, keep a task that reconciles them.
## Report it
File the misresolution to `xd://report_issue` (false positives are fine) and
record the incident in the repo worklog — a repair nobody wrote down looks like
a hijack later.
## Multi-agent hazards in one shared worktree
- Another agent can stage, commit, or check out files under you; re-read a file immediately before editing it if any tool result hinted at intervening change.
- Untracked scratch you drop in a shared tree (reports, notes, `.AGENTTRASH/` entries) is visible to everyone and can be committed by them. Put throwaway artifacts outside the checkout.
- Never `git clean -f`, `rm -rf`, or branch switching to "get a clean state" in a tree another agent is using.
@@ -0,0 +1,37 @@
---
name: pdf-lib-determinism-moddate-leak
description: "Diagnose pdf-lib output that isn't byte-identical across fetches: PDFDocument.load restamps /ModDate; fake-clock determinism test; inflate object streams to diff."
---
# pdf-lib byte-identical output: the /ModDate clock leak
## Symptom
A "reconstruct-on-read, byte-identical forever" PDF endpoint passes its back-to-back refetch test but returns **different bytes when fetches cross a wall-clock second**. Same-second fetches match; a fetch 1s later differs (often a different stream `/Length`, e.g. object stream 411→420 bytes).
## Root cause
`PDFDocument.load()` has its own `updateMetadata` option (**defaults true**) and stamps `/Info /ModDate` from the wall clock **at load time**, not just at save. `doc.save({updateMetadata:false})` on its own is not enough (and in pdf-lib 1.17 `updateMetadata` isn't even in the `SaveOptions` type — only in `PDFOptions` on `load`); passing it to `save` also fails typecheck.
## Fix
```ts
const doc = await PDFDocument.load(templateBytes, { updateMetadata: false });
// ... fill, stamp print layer ...
return doc.save(); // save never stamps when load didn't
```
Output becomes a pure function of (template bytes, values).
## Determinism test law
A determinism test MUST straddle a time boundary. Back-to-back fetches/fills are structurally blind to per-second stamping.
- Unit: fake the clock (`@sinonjs/fake-timers` or `vi.useFakeTimers`) → fill → jump +2 months → re-fill → `expect(Buffer.from(second)).toEqual(first)`. No sleeps, no flake window. Faking `Date` outside a test framework requires patching `globalThis.Date` BEFORE the module import that uses it.
- Prove it red first against the old code (expect the byte/length drift).
## Byte-diff forensics (when lengths differ)
1. Dump the differing region raw: `Buffer.subarray(idx-60, idx+60)` rendered latin1 with non-printables stripped — locate the exact field (`D:YYYYMMDDHHmmss` = clock leak).
2. If the diff is inside a compressed object stream, inflate and compare streams:
```ts
import { inflateSync } from "node:zlib";
// find /Type /ObjStm ... stream chunks, inflateSync each, string-compare
```
3. pdf-lib escapes text-show operands — inflate + unescape `\\(\\[()\\\\]\\)` before substring-asserting printed values.
## Live proof recipe
Seed a submission, then fetch both retrieval doors at t, t+1.5s, t+3s; `sha256sum` all three → one hash. Same-second comparisons prove nothing. And kill any long-running dev service holding the app port first — a live :3000-style stack can hang or false-fail an e2e gate.
@@ -0,0 +1,60 @@
---
name: pdf-widget-layer-census
description: "Census a real-world \"flat\" PDF before assuming it needs field injection: Quartz/Preview exports often already carry an AcroForm widget layer under underscore rules; also audits pdf-lib flatten residue."
---
# Real-PDF census (widget-first, then judge)
A PDF that *looks* flat (`pdftotext` shows `____` fill rules) may already contain a
full AcroForm widget layer — macOS Preview/Quartz exports write widgets with
transparent rects under drawn underscores. Injecting fields into such a file
duplicates/shadows widgets. Always census before touching the pipeline.
## Steps
1. **Surface facts** (host):
```bash
pdfinfo file.pdf # "Form: AcroForm" hints at a widget layer
pdftotext -layout file.pdf - # label lines + underscore rules = intended fields
```
2. **Widget census** — authoritative check. With the franksign sidecar image,
the image ENTRYPOINT *is* `inject_fields.py`; override it:
```bash
podman run --rm --entrypoint python -v ~/Downloads:/in:z \
localhost/franksign-pdf-field-sidecar:local -c "
import pymupdf
d = pymupdf.open('/in/file.pdf')
for p in d.pages():
for w in p.widgets():
print(w.field_name, w.field_type_string, [round(v) for v in w.rect])
print('widgets:', sum(1 for p in d.pages() for _ in p.widgets()),
'annots:', sum(1 for p in d.pages() for _ in p.annots()))
"
```
3. **Fill test**: if widgets exist, `fillTemplate()` (pdf-lib) consumes the file
AS-IS — spec.json + sidecar are unnecessary for it. Flatten-verify below.
## Flatten audit gotchas
- `pdfinfo` prints `Form: AcroForm` even on correctly flattened pdf-lib output —
pdf-lib `flatten()` removes every *widget/annotation* but leaves the field
*dictionary*. Truth = MuPDF widget enumeration (0), not the pdfinfo line.
- `describeForm(bytes, specJson)` in `packages/pdf` parses the SPEC, not the
PDF, for the field list — pass a spec or skip it; calling it spec-less throws
`JSON Parse error`.
## Decision rule
- widgets > 0 → the widget layer IS the spec (names/types/rects, flip y to
bottom-left origin); NEVER run `inject_fields.py` over it.
- widgets = 0 → genuine flat form: sidecar injection with a spec (position
detection = issue #14 territory: underscore-run rects, label-derived names).
- Missing signature widget is normal ("please sign" is prose); franksign
decision 19: the typed full name is the signature.
## Privacy
Real documents are personal: scratch copies in /tmp, placeholder fill values,
destroy scratch (`rm /tmp/*` is fine OUTSIDE the repo; repo deletions still go
to `.AGENTTRASH/`). Never commit the document, a spike script touching it, or
its extracted text into the vault.
@@ -0,0 +1,61 @@
---
name: plugin-git-install-committed-artifact
description: "Use when shipping or landing an omp/pi plugin (omp plugin install/upgrade git): install validation fails on a missing build artifact, a stale bun git-mirror cache is reused, the live bundle needs proof it is the merged build, or upgrade says \"not installed\"."
---
# Plugin install from git: committed artifact + fresh mirror
`omp plugin install git:<url>` (and `pi install git:`) **clone the repo and validate the manifest's declared extension entry WITHOUT running a build step**. Two failure classes seen live (dank/dankvoice, 2026-10-19):
## 1. Declared entry missing from the clone
Symptom: `Plugin <name> extension validation failed: ./dist/index.js: declared extension entry not found on disk`.
Cause: build output (`dist/`) is gitignored; the manifest points at it; the clone has only `src/`.
Fix — ship the artifact:
- Remove `dist/` from `.gitignore`, `bun run build`, commit `dist/`.
- Prove build determinism first: build twice, `cmp` the outputs (bun esm builds are byte-reproducible without sourcemaps).
- Pin the contract in a small bats suite so it can't rot:
1. the manifest's declared entry is tracked in git (`git ls-files -- "${entry#./}"` non-empty — this is the installer's exact contract, not a config assertion);
2. committed bytes equal a fresh build (`bun run build` then `git diff --name-only -- dist` is empty → red on drift).
## 2. Stale bun git-mirror cache
Symptom: fix pushed, retry **still** fails identically, and the retry is suspiciously fast (seconds).
Cause: bun keeps bare mirrors in `~/.bun/install/cache/<hash>.git` and may reuse them at an old commit — even after the fix is pushed and the default branch is correct.
Diagnose + fix:
```bash
find ~/.bun/install/cache -maxdepth 2 -iname "*<repo>*"
git --git-dir ~/.bun/install/cache/<hash>.git log --oneline -3 --all # stale HEAD?
rm -rf ~/.bun/install/cache/<hash>.git # it is a cache; safe
```
Prove the fetch path independently first (also validates credentials for private Gitea repos):
```bash
GIT_TERMINAL_PROMPT=0 git clone -q <url> /tmp/x && git -C /tmp/x log --oneline -1 && ls /tmp/x/dist
```
## Order of investigation (don't skip)
1. Gitea default branch correct? (`GET /api/v1/repos/<o>/<r>` with token — private repos 404/None unauthenticated.)
2. Plain unattended clone contains the entry? If yes, the installer's view is cached → purge mirror.
3. Only then suspect the installer itself (validation code lives in the minified bundle; `grep -oE '.{600}<error string>' "$bunfile"` on `readlink -f $(which omp)` shows the exact check).
## Landing a merged PR on the live box
After the merge and the `--ff-only` on the dev checkout (`ls -ld node_modules` after landing anything that could have touched it):
- **`omp plugin upgrade <name>` takes the PACKAGE name, not the install alias.** `omp plugin upgrade dankvoice` fails `dankvoice is not installed` while the live copy sits in `~/.omp/plugins/node_modules/omp-dankvoice/`. Read the directory name (or `package.json` `"name"`) and upgrade that.
- Then close the chain of custody with one command — the deployed bytes are the merged bytes:
```bash
cmp ~/Projects/<repo>/dist/index.js ~/.omp/plugins/node_modules/<pkg>/dist/index.js
```
If only docs/tests changed, `dist/` is untouched and this should already match; if `src/` changed, the plugin must be upgraded first or this fails with `char <n>` — which is a real finding (stale live bundle), not a test bug.
- The session that did the merge keeps the OLD bundle in memory: it is still running pre-merge code. Never conclude "the change does not work" from the merging session.
## After install — proof without a TUI
- `omp plugin doctor` → per-plugin ✔ line.
- Audible/behavioral chain when TUI input is unusable (update-banner modal freezes pty submits): run `omp --mode=rpc --no-session` with the feature env armed (dankvoice: `DANKVOICE_AUTO=1`, gate is print/json-only) and watch for objective side effects, e.g. PipeWire: `pw-dump | grep -q "say-<ms>.wav"` proves synth+playback end-to-end.
- When pty submits DO work (no modal), the strongest instrument is a stub player plus a clip count, not ears or pixels: `DANKVOICE_PLAYER=/tmp/stub.sh` where the stub appends `$1` to a log and exits 0. Launch the DEPLOYED bundle by path (`omp --no-extensions --no-session -e ~/.omp/plugins/node_modules/<pkg>/dist/index.js`) in a neutral cwd with `DANKVOICE_CONFIG` pointed at a scratch file so the operator's settings are untouched.
- **Count clips per narration EVENT, not per turn.** One turn can fire several narrations (dankvoice: `message_end` carries prose+thinking, `agent_end` the settled answer; the dedupe only skips byte-identical text, so thinking-on means both speak). A "too many clips" result is usually correct behaviour — check the harness transcript for the turn count before believing a bug. The player deletes each temp wav, so the log line count is the only surviving evidence: park it in `~/.AGENTTRASH/<date>-<topic>/` if it becomes evidence for an issue.
@@ -0,0 +1,51 @@
---
name: radiance-decode-prefill-interference-census
description: "Diagnose decode collapsing while another session prefills on a radiance endpoint: phase-ratio step counters, lag test, metric-prefix and scrape traps, and which knob acts on the measured mechanism."
---
Read `core/config.cpp` (help table + validator) in the PINNED image source before writing any radiance flag, enum member, or metric name into a runbook: `config_parse` refuses unknown enum values at boot, so a remembered name can make the recorded arm undeployable. Enumerate members from the validator's error string, never from memory.
**Run the census on the pinned build's own metric set.** An older pin ranks the wrong mechanism: on radiance 1.0.13 the bucket table put a "collapse" on a shape whose counters did not exist yet, because every counter resets at a restart and the metric set changes with the build. Prove the window is one shape (`up`, or the first sample of a counter only the current build exports) before ranking buckets.
## Step ledger first, knobs second
Three counters decide which mechanism you are looking at, and none of them is a rate:
- `radiance:engine_step_seconds_decode_total / engine_steps_decode_total` = mean **decode-only** step.
- `(engine_step_seconds_total - engine_step_seconds_decode_total) / (engine_steps_total - engine_steps_decode_total)` = mean **chunk step** (a chunk alone, or a chunk with decode rows beside it).
- `engine_steps_held_total` — if it is zero, nothing is protecting decoders.
If decode-only steps stay ~15 ms while the rate collapses, the mechanism is **step-time sharing** (a decode row in a chunk step waits the chunk's whole time), and any eviction/residency story is worth ~10 % of one step. The residency half is not censusable: the arena / expert slab / heat engine / prefill stager have **no metric at all** — they are read from the boot ledger, `/stats`, and `/diagnostics`.
## Bucket, then lag-test, before believing a cause
Bucket `radiance:decode_tokens_per_second` by `radiance:prefill_tokens_per_second` from the *same* scrape, step ≤ 30 s (a 15 s scrape against a 120 s step samples one in eight). Per-stream rate = decode gauge ÷ `vllm:num_requests_running`; the tps gauges are ~1 s decayed batch-pooled windows.
Then shift the prefill series by lags and re-bucket:
- lag-0-only ⇒ step-time sharing — the chunk is holding the device.
- a lagged tail (2–14 min) ⇒ something evicted (arena loan, expert slab, KV).
- bursts in `vllm:num_preemptions_total` ⇒ a preempted sequence stopped and is re-prefilling from token 0: a stall, not sludge.
Also bucket TTFT (`histogram_quantile` over `vllm:time_to_first_token_seconds_bucket`) by the same series: the decode gauge only sees sequences already running, so an arriving request pays the same steps and shows in no decode number.
## Metric traps
- **Latency is `vllm:`, cadence is `radiance:`.** TTFT / inter-token / per-output-token / e2e / decode-time histograms and the running/waiting gauges exist only under the vLLM compatibility prefix. Preemption is `vllm:num_preemptions_total` (counter); `radiance:num_requests_preempted` is the *instant* depth. A `radiance:`-only query finds no latency and no preemption and reads as "the endpoint never stalls anyone".
- **A 2-minute mean step time dilutes the chunk** — if ~5 % of steps carry a third of the step time, `step_seconds/steps` cannot move. Use the phase ratio.
- **`prefix_hit_cut_tokens_total` is prefill load you created yourself**: on a hybrid (linear + paged) model a hit reuses only to the last linear-state checkpoint, so cached tokens replay as prefill. A high prefill rate is not always a client sending new tokens.
- Radiance's own step-detail histograms (`prefill_steps_duration`, `prefill_step_tokens`, `token_queue_wait`) may be exported but unscraped; per-request forensics then mean `curl localhost:7770/stats` on the endpoint's host, and `/diagnostics` answers "is the *box* the limit" in one call (configuration drift, VRAM split, per-card link chain and clocks, findings with fixes).
- Rootless container logs: read as the deploy user (`podman logs <svc>`), never `sudo podman logs`; check where the serve script redirects before trusting a log file.
- In a temporary/remote shell, zsh aborts on a non-matching glob — quote or guard `/sys/...` wildcards.
## Knob semantics (radiance 1.8.x, GUIDE §4.5; pinned source is referee)
- `--prefill-interleave N` — the **decoder dial**: N decode-only steps follow any chunk step taken beside decoders; held steps admit nothing. Costs the prefilling prompt's TTFT and a queued request its first step. Counts as `engine_steps_held_total`.
- `--busy-prefill-chunk` — a **prefill-speed dial**, not decoder protection: `auto` reserves 1+spec rows per decoding sequence and 1/8 of the budget per waiting request, and the source refuses to halve for decode rows alone. Never below the prefill stager's smallest pass (named on the boot line) — below it every routed layer reads its pooled experts synchronously.
- `--prefill-span N` — needs `--max-num-batched-tokens` = N intervals; re-funds a *lone* prefill only, so it cannot buy back held steps while decoders run, and its extra intervals land on the arena that the expert slab loses.
- `--checkpoint-policy` — retention only, three names (`geometric-backoff` | `tip-only` | `keep-all`); snapshot writes happen at every interval whatever the policy says (only `--prefill-span` skips them), so `keep-all` buys a bounded replay for pinned host RAM and no extra PCIe. Shortening `--checkpoint-interval` does NOT fix deep divergence: the geometric ladder's coarsest scale is the largest `interval × 2^k` under the depth, so the deepest gap stays ≈ half the prompt while every cut multiplies the writes.
- `--checkpoint-slots` is a **total**, `auto` = policy `held()` + 1, per `--max-num-seqs`, within an eighth of host RAM; `--max-num-batched-tokens` is the blunt rung — it raises the arena the slab loses and its resonance on an expert-tiered payload can be violent.
## Verify an arm, don't assume it
Flags may be passed conditionally by the serve script, so prove the flag reached argv (`tr '\0' ' ' < /proc/$(pidof <engine>)/cmdline`), then the counter the knob moves (`engine_steps_held_total` must leave zero), then the bucket pair. A deploy is not evidence a knob engaged. Rollback for each of these is its env line, not a rebuild.
@@ -0,0 +1,52 @@
---
name: radiance-flag-truth-from-pinned-source
description: "Before writing a radiance flag, value, or metric name into a runbook, prove it against the pinned image's source and the engine's own boot ledger — catches invented option names and stale defaults."
---
# Radiance flag truth from the pinned source
Use when a radiance (`codeberg.org/StillDeadcode/radiance`) flag, its admitted values, a resolved
default, or a `/metrics` name is about to go into a vault page, env comment, or arm proposal — or when
a claim about one is being repeated from memory. Docs and old vault pages go stale; the pinned tree and
the boot ledger do not.
## Procedure
1. **Get the tree at the running version, not at master.** Read the pin:
`grep -E 'FRANK_ARCHON_IMAGE' scripts/frank-archon.env` (digest), then
`ssh dank@192.168.0.235 'podman images --no-trunc | grep radiance'`.
`git clone --branch <ver>` (or checkout the tag) into `/tmp/rad-<ver>`. A `--depth 1` clone makes
every `git log -S` report its own tip — never date a flag's introduction from a shallow clone.
2. **Flag table is the admitted set.** `core/config.cpp` holds the help strings (each option's legal
names/units live in the value column) and the validator branch refuses anything else at boot, e.g.
`--checkpoint-policy` → `geometric-backoff | tip-only | keep-all`. `core/rad_core.h` carries the
struct with the compiled-in defaults. `tests/config_test.cpp` asserts the admitted set — it is the
fastest oracle for "is `lru` a policy?".
3. **Live argv beats the repo env.** `sudo tr '\0' '\n' < /proc/$(pidof radiance)/cmdline` — proves the
knob actually reached the engine (an env var typo is invisible from the repo).
4. **Boot ledger is the referee for every resolved number.** The engine prints its own arithmetic:
`ssh dank@192.168.0.235 'podman logs frank-archon 2>&1 | grep -iE "checkpoint|chunk geometry|pools|arena|stager"'`
(root unit, but podman storage is dank's — **no sudo**, sudo reads empty root storage). Lines like
*"checkpoint slots: auto: 20 … geometric-backoff keeps 9 for a 200000-token session at a 2048-token
interval"* are exact; quote them.
5. **Reproduce the engine's arithmetic to check you understood it.** Reimplement the policy/geometry
function in a throwaway Python eval (`select()` / `held()` in `core/mem/prefix.cpp`, `plan_chunk` in
`core/sched/scheduler.cpp`) and confirm the numbers match the ledger line from step 4. Agreement
means the rule is understood; disagreement means the read is wrong. Delete the scratch after.
6. **Metric names: query the live target, not the guide.** Latency histograms and preemption live under
the **`vllm:`** compatibility prefix, cadence under **`radiance:`**; preemption is the counter
`vllm:num_preemptions_total` while `radiance:num_requests_preempted` is an instant gauge. Enumerate
with `curl -s localhost:7770/metrics | grep '^# HELP'` on Dankserver, or
Prometheus `/api/v1/label/__name__/values?match[]={job="frank-archon"}`.
## Traps that produced real errors here
- A policy name invented from prose (`--checkpoint-policy floor`) would be a boot refusal; a stale
resolved default (`auto` = 512) had been copied between pages. Both survived only in the vault.
- `--checkpoint-policy` governs **retention only**; snapshots are written at every interval regardless
(only `--prefill-span` skips writes). Read the call site (`retain()` in `core/mem/prefix.cpp`), not
the flag help.
- Guide/`GUIDE.md` inside an image can be newer or older than the binary; `/diagnostics`
`configuration` and the ledger print the running truth.
- Facts belong to the vault page that owns the endpoint (`serverAppConfigs-docs/LLM/frank-archon.md`) —
this skill owns the *procedure*, not the numbers.
@@ -0,0 +1,59 @@
---
name: radiance-knob-arm-live-verify
description: "Arm a radiance scheduler/cadence knob on a live endpoint and prove it engaged: per-knob /metrics meter, argv-vs-default truth, the three behaviour probes, no bench."
---
# Arming a radiance knob on a live endpoint (no bench)
Use for a one-flag change on a radiance endpoint (e.g. `PREFILL_INTERLEAVE=0→1`,
`BUSY_PREFILL_CHUNK`, `CHECKPOINT_POLICY`) under the "swap, don't instrument" law:
edit the trio → deploy the one service → prove the flag reached the engine, the
engine's own counter moved, and behaviour survived. Never a bench unless Frank orders one.
## Before you set a value
- **Unset means default, and the engine prints what it resolved.** Grep
`/proc/$(pidof radiance)/cmdline` (`sudo tr '\0' ' ' < …`) *and* the container ledger
(`podman logs <svc>` as the deploy user — rootless storage, `sudo podman` finds nothing).
The ledger line names the resolved value and whether a budget clamped it
(`--checkpoint-slots 20 [auto] … of a budget of 31.5 GiB, bound by None`).
- **Prove the flag exists in the pinned build** before promising it: `--help` from the
running container image, not from a vendored tree. Inventing a name (`floor`) cost a
doc rewrite; `config_parse` refuses unknown enums at boot.
- Some flags are conditional in the serve script (e.g. `--prefill-interleave` is passed
only when the env value is `> 0`), so a zero→nonzero arm adds argv; re-check argv after deploy.
## Meter map (`/metrics`, names carry the `radiance:` prefix)
| change | meter that must move |
|---|---|
| `--prefill-interleave` | `engine_steps_held_total` (read 0 for a 34.7 h boot while the knob sat at 0; 126 within 4 min of arming) |
| `--busy-prefill-chunk` | `engine_prefill_chunks_capped_total` |
| step-time contention generally | decode-only `engine_step_seconds_decode_total/engine_steps_decode_total` vs `(step_seconds_total − step_seconds_decode_total)/(steps_total − steps_decode_total)` |
| prefix retention | `prefix_hit_cut_tokens_total` (labels: `no_snapshot`), `vllm:num_preemptions_total` |
Trap: **latency lives under `vllm:`, cadence under `radiance:`.** A `radiance:`-only query
finds no TTFT/ITP and no preemption and reads as "nothing stalls". The arena / expert slab /
heat engine / prefill stager have **no metric at all** — that half is boot ledger + `/stats`.
## Post-deploy proof, in order
1. argv contains the flag (or the ledger shows the resolved value).
2. `/health` 200 (`in_flight`, `queue_cap` come back in the body).
3. Three probes on the endpoint itself: exact-reply chat (`temperature 0`, expect the
literal word — reasoning tokens are normal in `reasoning_content`); a `tools` call
expecting a real `tool_calls` object; a **solid-colour PNG via `image_url`** (never the
legacy top-level `images` field) with `max_tokens ≥ 512` or thinking eats the budget and
a thinking model reads as blind.
4. The knob's meter left zero-state. If it didn't move, the arm did nothing — say so.
5. Effect is judged later from real traffic buckets, not from a synthetic load.
## Deploy cycle gotchas that cost the most time
- `deploy.sh` must run from the dev checkout literally on `main`, tree clean including
untracked files; it pushes its own `main`. Never from an agent worktree or the target.
- Name the service (`--service <svc>`); a bare `deploy.sh <host>` is a fleet-wide drain.
- Health binds only after the full load (~100 s here, gate budget much larger) — 503 during
load is not a fault.
- Post-deploy for a recently-benched LLM service: `systemctl show <svc> -p DropInPaths`
must be empty before believing anything.
## Rollback
One env line back to the previous value + redeploy the same service. Write the meter and
the rollback into the endpoint's vault page (that page is the owner), never into a new page.
@@ -1,111 +1,39 @@
---
name: radiance-kv-cache-resize
description: "Resize the KV cache / VRAM pair on a radiance (StillDeadcode) endpoint: engine-budget-table ceiling, pool contract arithmetic, slab-slope law, ledger-first verification, free token-count probes"
description: "Resize a radiance endpoint's KV cache/VRAM: the resolver's funding formula, which knob actually moves the carve, the pinned-RAM ledger to recompute, and the boot-ledger lines that referee the result."
---
# Radiance KV / VRAM pair resize
Use when changing `--vram-kv-mib`, `--vram-weights-mib`, `--max-num-seqs`,
`--max-model-len` or `--kv-cache-dtype` on a radiance endpoint
(frank-archon pattern), or when asked "what can we do with the free VRAM".
## 1. The ceiling is the engine's, never a hand-picked reserve
`rocm-smi` free space is NOT spare capacity. The boot prints a `VRAM budget,
per card` table; read it:
## Method
- The endpoint is sized by ONE env file (`scripts/<svc>.env`) plus the serve script; the boot ledger prints the split before allocating anything: `VRAM budget, per card` → `claimable` → `elastic` → `experts / kv cache`, then `kv: the cache can grow to X a card -- N tokens of context, M sessions at W`.
- **`--vram-weights-mib` / `--vram-kv-mib` no longer exist (1.8.1).** The engine REFUSES to start on them (`core/config.cpp`), so this skill has no direct KV knob; the budget resolver derives everything from the cache bound (`--max-num-seqs` x `--max-model-len`), `--gpu-headroom-mib` and `--host-pool-mib`.
## The funding formula (radiance 1.8.1, source-cited — re-read the pinned tree before trusting it against another build)
```
card total 31.86 GiB
vram_weights 25.00 GiB stated by --vram-weights-mib
vram_kv 5.00 GiB stated by --vram-kv-mib
unclaimed 477.30 MiB the headroom, the unusable above, rounding
W VRAM budgets total 30.00 GiB of the card's 31.86 GiB, so 1.86 GiB is neither
budgeted nor accounted. The driver's context, the activation arena and
--gpu-headroom-mib come out of it; anything past those is throughput on the floor
W card 0 settled at 2.01 GiB free against 209.19 MiB (the 96.00 MiB
--gpu-headroom-mib and 113.19 MiB kept for the static region)
capacity = card_total - already_held - drift - headroom - arena - step_staging - hw_queues - prefetch
elastic = capacity - static_weights
expert_floor = min(plane+spare, max(plane + 2*unit_max - host_room, 0)) # planner.cpp expert_vram_floor
host_room = host_pool_mib * MiB (less the file tier's staging when disk tiering is on)
kv_cap = max_num_seqs * max_model_len # DEMAND, in tokens
kv = min(kv_cap, elastic - expert_floor) # what it actually gets
tokens = min over paged groups of n_blocks*block_size # the printed number
```
Read consequence: **`--max-num-seqs` raises demand, `--host-pool-mib` raises supply, and both are the same lever** — every MiB added to the host pool lowers the expert floor one-for-one and the cache takes it (up to `kv_cap`); the leftover returns to the experts. `--max-num-seqs` alone does nothing to capacity: it raises demand while the floor is unchanged, so the printed tokens stay put. The page's "funds ~91 % of demand" table rows are that cap, not a policy percentage.
- Derive the plane from a live ledger instead of guessing: `plane + 2*unit ≈ printed_experts + host_pool_bytes`.
- `--gpu-headroom-mib` lowers go to the cache but the engine's settle floor is built from it — 48 MiB measured decode-neutral, 24 untested. 48 is the first rung.
`ceiling = (settled_free_MiB - engine_floor_MiB) + current_pair_sum`.
Worked (dankserver, 2026-10-13): 29,120 + (2,058 − 209) = **30,969 MiB/card**.
A pair at the ceiling settles ~330 MiB free at worst case. If a bound cannot be
traced to a ledger line, it is a personal reserve — label it and put it in the
bats gate as such.
## Procedure
1. Read `scripts/<svc>.env` for the current pool: `GPU_HEADROOM_MIB` (96), `HOST_POOL_MIB` (44,928 per rank), `MAX_SEQS` x `MAX_MODEL_LEN` (demand), `HOST_POOL_MIB`, `PLACEMENT`.
2. Compute the target as tokens: tokens x bytes/token-per-card, where bytes/token = (cache_given_bytes / tokens) from the latest ledger. If the target exceeds `elastic - expert_floor`, ALSO lower `expert_floor` via `--host-pool-mib` (up = more room = fewer resident experts) — a bigger host pool buys cache only while experts remain on the card; once the host absorbs the whole plane, more host RAM buys nothing (measured: +640 MiB moved `vram_kv` byte-identical).
3. Edit ONE knob (`GPU_HEADROOM_MIB`), deploy service-scoped: `./scripts/deploy.sh <host> --service <svc>`. The engine reloads everything (~2-4 min); the deployer's `LLM_WAIT` covers it.
4. Re-read the ledger: `vram_kv` must be the new number, byte-exact or the knob lost to another claim; `mover: N slots can be lent` tells what the expert slab re-borrowed from the enlarged pool — that steal is the decode cost of the resize.
5. Re-run the probe (`scripts/probe-frank-archon-kv.py`): depth 1.75x tokens, then the 2x refusal. Update `<svc>.md` Laws with the measured pair (tokens, given, backing %).
6. Under-funded? Raise `HOST_POOL_MIB` in 1,024-MiB steps and redeploy; never touch demand to fake the number.
## 2. Pool contract (the engine enforces it at REQUEST time, not boot)
## Pinned-RAM ledger to recompute BEFORE booting (this is where a resize kills the host)
`total_pin = tp * HOST_POOL_MIB + PLE/n-gram table + PREFIX_CACHE_MIB + (statics + arena ~2,048) + checkpoint_store`, with `checkpoint_store = slots * 111.98 MiB` and `--checkpoint-slots auto` = `max_num_seqs * (1 + what the retention policy keeps)` — **it grows with the slot count**, so a slot bump raises the pin ledger twice (state + store). The repo's bats guard recomputes this and asserts `TTM_MIN_MIB >= ledger`; when you change pool or slots, update `TTM_MIN_MIB` (and that guard) in the same commit, or the seam fails. The serve script's own gate refuses to start below `TTM_MIN_MIB`; `ttm-preflight.sh` raises the knob to `TTM_TARGET_MIB`, so the target must clear the new floor with slack.
`pool_mib >= seqs*max_model_len*rate + 110*seqs + 448`
- rate/token/rank: **7,488 B fp8**, **14,144 B bf16** (1.89×, not 2× — the QSA
block key is 3.25 KiB at BOTH dtypes; it is an index, not attention state).
- +110 MiB a sequence of recurrent state (`kv_gdn_state` 54 MiB × 2 copies +
conv/tail/ple) and 448 MiB of prefix checkpoints, dtype-independent.
A pool that fails this **boots, serves short chats, then 400s every long
request** as `does not fit the 200000 token context, which leaves nothing to
generate into` — radiance refuses, never truncates. Encode it as a bats gate.
`--kv-cache-dtype` takes ONLY `bf16|fp8`, one dtype for the whole cache (no
llama.cpp-style exact-K/quantized-V pair).
## 3. Slab and KV are one budget; the slab is a SLOPE
- Idle KV VRAM is the expert cache's spillover: ledger
`mover: N slots can be lent out of the KV cache (X GiB)`. A bigger pool steals
residency twice over.
- Slab MiB ↔ resident experts is linear (~1,300 slots per 1,792 MiB measured);
the pinned host pool hands back the same slot count as the slab grows
(16,235→17,548 slab with 8,893→7,580 pool). The pinned RAM path is the slow
path, so slab MiB = throughput.
- Measured law (fp8, 43.5k single-stream, Qwen3.8-Flash-Next w4):
22,208 → 3,397, 24,000 → 3,290, 25,600 → **3,584**, 26,071 → **4,379 t/s**.
Two points inside the ±3 % noise band are NOT a plateau — never infer a
threshold from two arms.
- **Decode is a separate axis.** It follows KV dtype/bandwidth and draft
acceptance, not the slab: fp8/slab 26,071 decoded 124.1 t/s cold where
bf16/slab 22,208 decoded 94.1 (512 tokens, 51.5k). Slot count moves the
batch-shared per-stream floor (3 concurrent ≈ 6 t/s; solo ≈ 90–120).
## 4. Verify: ledger first, then live probes (no scratch server)
1. Deploy the service; read `podman logs <svc>`: `backs N sequences all at
M tokens at once` is the capacity referee; check slab slots, `unclaimed`,
`sampler: ... R rows` (= seqs × (1+spec), must stay inside the 25-row wht6
exactness bound), `settled at … free against … floor`.
2. Boundary probe: N concurrent prompts sized to `max_model_len - gen`, each
with a unique marker at the head; expect N/N marker retrieval.
3. Prefill/decode probe: repo `scripts/probe-frank-archon-kv.py` (reads the
shape from the deployed env, so it self-resizes).
### Free token-count calibration (no prefill burned)
`/v1/completions` refuses when **prompt tokens alone** exceed the window —
`prompt: 202,777 prompt tokens does not fit the 200000 token context` — and the
refusal names the count BEFORE any prefill. It does NOT fire on
prompt + max_tokens. Corpus ratio measured: **2.7617 chars/token** (2.5714 for
the fox filler), so a 200,000-token window ≈ 514,286–552,286 chars. Calibrate
with a probe ABOVE the window; below it the request is accepted and pays real
prefill (~22 s).
### Probe traps that produced false results
- Generate **≥128 tokens** (512 preferred) for any decode number; at 8–9 tokens
`inter_token` is first-token noise — rates like 92.7 vs 85.8 are noise.
- Radiance `timings` can carry `null` rates — `(t.get(k) or 0)`, and
`j.get("timings") or {}`.
- The disk prefix cache (64 GiB) survives restarts; put a random nonce at the
HEAD of a "cold" prompt. A warm 197k prefill returns in ~10 s and looks like
a 60,000 t/s miracle.
- With thinking enabled, a small `max_tokens` yields empty `content` +
`finish_reason: stop` on a healthy server; set
`chat_template_kwargs.enable_thinking=false` in probes.
- `echo ===` in an ssh'd zsh dies on `=`-expansion; it silently truncates the
command chain.
## 5. Ship
`scripts/<svc>.env` pair + header, serve-script header, bats gates (pair ≤
ceiling; pool ≥ contract), docs (runbook, `AGENTS.md` fleet row, LLM index,
log.md), `./tests/run.sh`, `deploy.sh <host> --service <svc>`, then the probes.
Record every prediction as falsifiable in the env comment and write the verdict
there when the probe lands — including when it kills the prediction.
## Traps
- The cache is ELASTIC: the ledger's `kv cache` line is the ceiling, and `mover: ... slots can be lent out of the activation arena` (987 MiB) means the slab borrows arena first, then cache blocks. A bigger ceiling only helps if the heat engine stops re-borrowing it — check `flex_bytes`/`flex_capacity` movement after load, not just the budget line.
- A resize that shrinks the pool mid-session preempts live sequences (`vllm:num_preemptions_total` jumps); a resize that grows it cannot retroactively un-preempt. Deploy at low traffic.
- Do not resize the KV pool AND the expert placement in one deploy: the `experts` and `kv cache` lines move together and the attribution dies.
- The `claimable` arithmetic (`capacity`) is computed ONCE before allocation; `card total` differs per card (26.0 vs 26.68 GiB on dankserver), so the SLOWER card's number is the budget — read the `[0]` and `[1]` `pools:` lines separately, not just the shared budget table.
@@ -0,0 +1,60 @@
---
name: react-prod-state-mismatch-bisection
description: Diagnose React SPA prod-build state mismatches (state-derived props update but sibling output stays stale) by driving a real browser and bisecting per-render state via throwaway source instrumentation.
---
# Prod React state-mismatch bisection
Use when a built SPA shows an internally inconsistent DOM — e.g. `aria-current` flips
while a sibling conditional slot stays stale, a click handler runs with zero network
activity, jsdom tests pass but the prod journey fails. Reasoning from source fails
here; only per-render state truth resolves it.
## Steps
1. **Drive the real browser, not tests.** Throwaway `@playwright/test` chromium script
beside the package that installs it (e.g. `tests/`), seeded via direct API calls +
cookie injection:
```js
import { chromium } from "@playwright/test"; // resolve from tests/ node_modules
const ctx = await browser.newContext();
await ctx.addCookies([{ name, value, domain: "127.0.0.1", path: "/" }]);
page.on("console", ...); page.on("pageerror", ...); page.on("request", ...);
```
Run it from that directory; a script at repo root or /tmp hits
`ERR_MODULE_NOT_FOUND` for workspace deps.
2. **Fiber prop probe** — call the handler outside React events to split
"delegation dead" from "handler wrong", and read the props the fiber holds:
```js
await page.evaluate(() => {
const el = document.querySelector('[data-testid^="thing-"]');
const key = Object.keys(el).find((k) => k.startsWith("__reactProps$"));
el[key].onClick(); // or .onChange({target:{value:"x"}})
});
```
3. **If props updated but output is inconsistent, suspect falsy-equality, not React.**
`undefined === undefined` yields `"true"` for `el.id === selectedId` highlights —
a fake "state updated" signal. Dump the slot's raw `innerHTML`, don't trust the
derived attribute.
4. **Instruments beats deduction:** insert temporary `console.log`s in the component
source (one in each state-mutating callback, one at render logging the state),
rebuild, restart the server, re-run the script. Compare *per-render* values — the
render where the value is `undefined` instead of the id names the root cause
(usually a server payload keyed differently than the client type, e.g. DB row key
`orgId` crossing HTTP where the contract says `id`).
5. **Also grep every suite teardown for broad `DELETE ... LIKE`/`TRUNCATE` on the
shared DB when a test flakes only under uncached concurrent runs
(`turbo run test --force`) and passes isolated:** parallel vitest files on one DB —
the suite that finishes first FK-cascades away the other's live sessions; the
symptom is a spurious 401 (or missing row) in the still-running file. Fix by
namespacing emails/ids per suite and scoping teardowns to rows the suite created.
## Cleanup
Remove instrumentation, move the throwaway script to `.AGENTTRASH/`, never commit the
debug script. Verified on franksign: `GET /api/orgs` leaked `orgId` where the client
contract was `id` (fake aria-current true, silent auto-select failure).
@@ -0,0 +1,35 @@
---
name: real-pdf-acroform-census
description: "Census a real-world PDF before running it through a flat→AcroForm pipeline: check for hidden widget layers, prove fill/flatten, avoid the pdfinfo ghost."
---
Census recipe for a real-world PDF arriving at a flat-form pipeline (e.g. franksign's sidecar that injects AcroForm widgets). Run BEFORE assuming the document is flat.
## Sequence
1. **Metadata**: `pdfinfo file.pdf` — note `Form:` line, producer (`macOS ... Quartz PDFContext` = Preview/Preview-era export), page size.
2. **Text layer**: `pdftotext -layout file.pdf -` — underscore rules (`____`) look "flat".
3. **Widget census (the decisive probe)** — never trust the looks:
```bash
podman run --rm --entrypoint python -v ~/Downloads:/in:z <sidecar-image>:local -c "
import pymupdf
d = pymupdf.open('/in/file.pdf')
for p in d.pages():
for w in p.widgets():
print(w.field_name, w.field_type_string, [round(v) for v in w.rect])
print('annots:', sum(1 for p in d.pages() for _ in p.annots()))
```
Traps: a custom ENTRYPOINT script swallows `python` as argv — override with `--entrypoint python`. Mount a directory, not a single file (MuPDF atomic-save EBUSY).
4. **Fill live-fire**: pass the file through the fill step as-is with placeholder values; verify with `pdftotext -layout` that values land on the rules.
5. **Flatten audit**: re-run the widget census on the output (expect 0) — do NOT read `pdfinfo`.
## Laws learned
- macOS Preview/Quartz exports (and most print-to-PDF paths) **already embed AcroForm widgets** under transparent rects over their drawn underscore rules. Field names are human-meaningful (`date_today`, ...). Injecting widgets over them duplicates/shadows fields — the design-time injection stage must be skipped when a widget layer exists (widget-first detection).
- pdf-lib `flatten()` removes every **widget** but leaves the AcroForm **field dictionary**: `pdfinfo` keeps printing `Form: AcroForm` on a fully flattened file. Widget enumeration (MuPDF) is the only truth; pdfinfo is the ghost.
- Underscore rules in the text layer are drawings, not fields; the text layer alone cannot tell flat from widget-bearing.
## Verdicts
- Widgets present → fill as-is; detection spec = widget layer (names, types, rects; flip top-left→bottom-left origin if the pipeline spec uses bottom-left).
- Zero widgets → true flat form; needs position detection (underscore runs + nearest preceding label) before injection.
@@ -0,0 +1,49 @@
---
name: recover-externally-reverted-file
description: "Rebuild a worktree file that an external process (stale editor buffer, watcher) keeps reverting mid-session: idempotent script rebuild, verify, immediate commit, cat-file restore instead of checkout."
---
# Recovering a File an External Process Keeps Reverting
Symptom: edits to one file flip-flop under you — grep/wc/read return two different
versions minutes apart; tool edit warnings say "file modified externally" repeatedly;
committed content disagrees with reads of the same path. Typical cause on Frank's
boxes: an IDE window with a stale buffer auto-saving the old version, or another
agent's git operation in a sibling worktree.
## Procedure
1. **Stop editing surgically.** Line-anchored `edit` calls are unsafe when the disk
identity of the file is unstable. Take one snapshot:
`cp <file> /tmp/obs1.bats` (twice, a few seconds apart; differing hashes confirm the flapping).
2. **Write an idempotent rebuild script** (`/tmp/rebuild-*.py`) that transforms
whichever known version is on disk into the target:
- every patch is `sub_if_present(pattern, exact_replacement)` /
`insert_before(anchor, block)` / `insert_after(anchor, block)`;
- each helper first checks whether the block is already present and skips;
- anchors must be taken from the committed/original file text, re-grepped,
never from memory of an edit that may have been reverted;
- print `ok/skip/FAIL` per patch so a mismatch is visible.
3. **Apply + verify + commit in ONE bash invocation**:
`python3 /tmp/rebuild.py && <syntax check e.g. bats -c / tsc> && git add <file> && git commit ...`
The commit is the stable artifact; the disk is not.
4. **Restore from git without checkout** (workspace policy blocks `git checkout/reset`
in shared checkouts): `git cat-file blob HEAD:<path> > <path>`.
Never `git checkout -- <path>` blindly — it silently nukes uncommitted work.
5. **Run consumers immediately after the restore/commit** (tests etc. in the same
invocation where practical); re-check content with one hash compare
(`md5sum <path>; git cat-file blob HEAD:<path> | md5sum`) when results look impossible.
6. If duplicates crept in from double-application (flapping + reruns), dedupe by
occurrence span (`src.find(marker)` twice, cut the second span) — full-block dedupe
fails when the two copies differ in a byte of comment text.
## Distinguishing real failure from flapping evidence
If a test fails, re-run the exact command once before believing it: the failing read
may have run against the reverted file, not the committed one. Verify what the runner
saw (`git hash-object <path>` vs `git rev-parse HEAD:<path>`).
## Afterward
Flag the reverter to the user (open editor buffer / watcher / sibling agent); do not
fight it — commits and cat-file restores make it harmless.
@@ -1,28 +1,27 @@
---
name: recover-subagent-yield-payload
description: "Recover a subagent's full structured report when it exited without yielding (SYSTEM WARNING) — extract aborted yield payloads from the session JSONL, including harness double-encode and split-JSON quirks."
description: "Recover or salvage subagent deliverables: missing yields, payloads misrouted to xd://report_issue, and degraded TAKEN-list echoes."
---
Use when a subagent ends with "exited without yielding" yet the transcript shows it repeatedly calling `yield` (each reply "…[+N]" — payload too big for display). The research is usually COMPLETE; only the yield serialization failed. Known harness quirk: the yield `arguments` get wrapped as `{"data": "<json string>"}`, so an object payload is validated as a string → "expected object, received string" retry loop until the agent dies.
# Recovering subagent deliverables
## 1. Locate the session JSONL
Transcript header prints the path: `~/.omp/agent/sessions/<cwd-slug>/<session-id>/<AgentName>.jsonl` (worktrees: cwd slug like `-.omp-wt-wt-...`).
Three failure modes seen with scout bulk-content tasks, in order of frequency.
## 2. Extract every aborted yield payload (Python via eval — shell quoting will mangle these)
- Parse each line with `json.loads` guarded by try/except (lines may be non-JSON).
- Keep `message.role == "assistant"` messages; in `content`, keep entries with `type == "toolCall"` and `name == "yield"`; collect their `arguments`.
- Unwrap: `arguments` may be a dict containing `data` (a JSON string) or a plain string; `json.loads` it.
## 1. Misrouted payload: scout wrote the deliverable to `xd://report_issue`
Symptom: yield summary says "delivered to `xd://report_issue`" instead of containing the content. That device is write-only (a bug-report channel); the response "Noted, thanks!" fooled the scout into thinking delivery worked.
## 3. Tolerate broken JSON
Naive `json.loads` on `data` can fail "Extra data: line 1 column ~4600" — the payload is a complete object followed by `, "architecture": ..., "report": ...}` (agent emitted a structure whose early brace-closure ended the first object). Recover with `json.JSONDecoder().raw_decode`:
1. `part1, end = dec.raw_decode(data.strip())`
2. `rest = data[end:].strip()` — if it starts with `,`, parse `part2 = dec.raw_decode('{' + rest.lstrip(', '))[0]`
3. `merged = {**part1, **part2}`
Do this for ALL yields and keep the largest merged dict (attempts evolve; keys seen: `summary`, `files`, `report`, `architecture`).
**Salvage path (often impossible — decide fast, ≤2 probes):**
- Transcript jsonl: `~/.omp/agent/sessions/*/*/Tan-*/Tan-*.jsonl` (glob by agent id).
- Streamed `toolCall` blocks store only **partial arg fragments** of big writes (`arguments.content` is chunked/empty; `partialArgs` holds deltas). Reassembly of a 140-line write across ~100 fragment rows has never worked — if `content` on the toolCall rows is empty or truncated, **abandon salvage**.
- Only reliable in-file content: final assistant text blocks — walk rows for `message.role=="assistant"`, `content[].type=="text"`, filter lines against the delivery contract regex.
## 4. Persist and deliver
Write the recovered `report`/`summary` to a durable file (memory dir or `local://`), then continue synthesis — never re-run the research the dead scout already did.
**Prevention (put in scout prompts for bulk content):** "Return the deliverable as plain lines in your FINAL OUTPUT TEXT. NEVER write it to `xd://report_issue`, files, or eval state."
## Prevention (do this first next time)
- While the agent is still alive and looping: write to `agent://<Name>` steering it to "yield immediately, wrap in minimal object or raw markdown, schema-invalid yield acceptable — I read the transcript anyway." It often still can't (the failure is in the harness wrapper, not the agent) → let it be killed and use the extraction above.
- Scout default schema wants objects; terse yields from `scout` agents hit this most. Keep expected reports slice-sized.
## 2. Degenerate echo: scout returns the TAKEN/dedupe examples verbatim
Symptom: candidate lines repeat the "already used" examples given in the prompt, in looping blocks. Write off that batch entirely — do not re-prompt (it repeats). Cover the cluster inline yourself.
## 3. No yield at all (hung/force-stopped)
`history://<agent-id>` renders the transcript but compresses tool payloads to "1 line" — useless for payloads, fine for status. Try `write agent://<id>` with a one-line re-emit instruction ("end your turn by yielding the lines verbatim; do not re-research") exactly once; if the reply is garbage, abandon.
## Rule of thumb for bulk creative content
Scouts add value for *terminology research* (search the universe's rank/offices vocabulary, 2-5 searches), not for drafting hundreds of lines — models echo, spin variants ("dune-mentat of the X" ×14), and misroute. Pattern: scout researches → parent drafts.
@@ -0,0 +1,29 @@
---
name: red-verify-without-stash
description: "Prove a regression test is red-capable against pre-fix code in a shared worktree where git stash/checkout are policy-blocked, via git-show blob swap with md5-verified restore."
---
# Red-verifying tests without `git stash`
## When
You fixed a file and added tests that should have failed before the fix, but workspace policy blocks `git stash` (and `git checkout -- file`) in shared worktrees (e.g. `~/.omp` shared-workspace policy). Needed to prove red-capture instead of asserting it.
## Recipe
One `bash` call — swap the fixed file for the HEAD (pre-fix) blob, run only the fix-relevant tests, restore, verify byte-identity:
```bash
cd "$WT"
md5sum path/to/fixed.file > /tmp/file-hash
cp path/to/fixed.file /tmp/fixed.file.bak
git show HEAD:path/to/fixed.file > path/to/fixed.file # pre-fix content; new tests now exercise old code
bats -f "test name A|test name B" tests/suite.bats # expect: not ok for every fix-relevant test
cp /tmp/fixed.file.bak path/to/fixed.file
md5sum -c /tmp/file-hash && echo RESTORED
```
## Notes
- `git show HEAD:path > path` truncates-and-refills the file in place; safe because you hold the md5 and a backup copy, and the restore check proves the fixed bytes are back.
- Run only the fix-relevant tests via the runner's name filter (`bats -f` regex); a full suite wastes time and old tests pass on both versions.
- Expect exactly the tests that pin the fix's behavior to fail pre-fix; tests unrelated to the change (pure coverage of old behavior) pass on both versions — don't count them as red evidence.
- `bats` pipelines mask exit codes without `pipefail`; judge by the `ok`/`not ok` lines, not the shell status.
- For non-test proof of endpoint behavior (live A/B of an HTTP request body), replay the exact body with `curl` against the live endpoint instead of speculating; keep both timings.
@@ -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.
@@ -0,0 +1,44 @@
---
name: rescue-loose-home-scripts
description: "Promote a durable loose $HOME script into dank/command-center: archive-first, black-box bats red-first with env seams, docs page + index + log entry, one atomic commit per instrument, PR to main."
---
Rescue a throwaway-looking `$HOME` script into the command-center repo as a maintained instrument. Used for `remote-job.sh` + `scripts/probes/*.hip` (PR #54, Refs #53); the archive still holds ~120 candidates.
## 1. Archive first, never delete
Loose scripts at `$HOME` root move to `~/.AGENTTRASH/home-scripts-<date>/` (`rm` is forbidden; deletion is a pruning session with Frank). Consolidate bulk media separately (`~/audiosamples`). Then check the archive for **instruments**, not convenience: something that answers a question you will ask again, or that carries a trap you paid for.
## 2. Tests first, and black-box
Write `tests/<name>.bats` before the script; confirm red (`bats tests/<name>.bats` → all `not ok` because the script is absent).
- Assert **output, exit codes, and on-disk ordering** only — never internals, never "variable X equals Y".
- A test that asserts a race outcome (file exists immediately after a detached `start`) is wrong: assert the *contract* instead (`status` reports RUN/PENDING and exits 1). Rewrite the test, not the code, when the failure is your own assumption.
- Design env seams **so the untestable path becomes testable off the target hardware**: `*_SYSFS`, `*_KFD`, `*_HIPCC`, `*_SRC`, `*_BUILD`. A refusal that can only be exercised on a card box is an untested refusal. Stub the toolchain in `$PATH`/via env to drive build-failure and busy-device paths.
## 3. Safety law in the tool, not the docs
Anything that measures a GPU must **refuse before it builds or runs** when the device is busy (`gpu_busy_percent` over threshold, or any process holding `/dev/kfd`) and must **stop nothing**: unloading a production endpoint is a human decision (`--force`). Pin the refusal-before-build ordering in a test — otherwise a reordered guard silently reintroduces contention beside a live service.
Exit-code taxonomy worth reusing: `0` did the work · `2` usage/missing inputs · `3` refused (would have contended) · `4` toolchain/build failure. A client box without the SDK must exit 4 loudly, never report an empty success.
## 4. An instrument that crashes is not an instrument
`hipGetDeviceProperties` **segfaults** on the fleet's ROCm build while `hipGetDeviceCount` works fine. The shipped probes called it only to print device name + PCI bus id — replaced by the sysfs topology block the driver prints (`current_link_width`/`current_link_speed` vs `max_*`). Record the trap as a comment in the sources so it is not reintroduced.
## 5. Docs, three places, in the same commit as the code
- `omp-docs/tools/<name>.md` — frontmatter per OKF (`type: reference`, title, description, tags, `verified`, `timestamp`); sections: contract (command table + exit status), the law + why, how to read the output, failure-modes table, test seams. Point-of-use only: measured numbers do **not** live here (they belong with the endpoint's runbook vault).
- `omp-docs/tools/index.md` — one alphabetical bullet per page.
- `omp-docs/log.md` — dated entry above the newest one: what changed + the durable laws, a few lines.
Gates before commit: `bash scripts/kb-lint.sh` (0 violations) and full `bats tests/` green.
## 6. Land it
One commit per instrument (stage that page's index line only — re-add the next page's line for its own commit), message = imperative what + why + `Refs #N`, hooks armed (never `--no-verify`). Push to the local Gitea, open the PR with `gitea-pr-api-cycle` (token from `~/.config/secrets/gitea` via curl `-K`; body ends with `---` then `cwd: <session cwd> · session: <live transcript UUID>`), then `xdg-open <PR-URL>`. **Never merge without Frank's explicit OK.**
## 7. Report the gap you could not close
Verification on a client box proves the guard/refusal/usage paths only. If compile-or-run needs the hardware box, say so in the PR under "Not done here" and offer the exact low-risk command rather than implying the artifact is proven.
@@ -0,0 +1,39 @@
---
name: rng-pool-sweep-blackbox-test
description: "Prove membership/absence in a randomly sampled pool (epithets-style) from black-box tests: prime-cycled Math.random sweeps every index; pool-first probe for setting precedence."
---
# Deterministic pool sweeps for random-sampling black-box tests
When a built bundle samples uniformly at random from a curated pool (epithet-style) and tests must prove a setting admits/excludes whole classes of pool entries, do NOT write probabilistic sampling tests. Two proven designs:
## 1. Full-pool sweep (class membership, distinct counts)
In the test driver (the process that imports the built bundle), before firing utterances:
```ts
let seed = 0;
Math.random = () => (seed++ % PRIME) / PRIME; // PRIME > pool size, e.g. 1009
```
The extension calls `Math.random()` at speak time, so the patch applies regardless of import order. Picks are `floor(random * poolSize)`; with N=PRIME samples the step is `< 1`, so **every index of every pool ≤ PRIME is visited exactly once**. Observable per-utterance text (e.g. the fixture server logs the synthesized `input=`) then reveals the entire sampling pool:
- distinct picks == pool size exactly (pin the total only if it is itself law);
- a sentinel name from class X appears / must never appear per mode — deterministic, zero flake;
- absence proofs are exhaustive, not lucky.
Drive one narration per iteration and gate the loop on the fixture's call counter advancing (`while (speechCount() < before + expected) await nap(2)`), otherwise typed/coalescing paths collapse iterations and counts drift.
**Wall-clock budget:** one sweep ≈ 20–40 s per pool; bats serializes and jobs cap at 600 s — run at most two sweeps per backgrounded bats `-f` selection, or launch full suites with `timeout: 0`.
## 2. Pool-first probe (setting precedence, milliseconds)
With the same patched RNG, the FIRST pick is `pool[0]`. Run each config mode with exactly one utterance and compare the first spoken name:
- mode-A setting vs env-mode-A → equal (settings key honored);
- env-X overriding file-Y → equals env-X's pick (precedence law);
- pick strings are pool literals that live in exactly one class (e.g. `comrade operator`).
Avoid: regex scans over spoken text (possessives/compounds false-fire), probabilistic "absent in 300 samples" tests (flake floor unless the block is contiguous), and reading source internals (white-box).
Red-first check: the sweep test for a not-yet-filtered pool must fail by SPEAKING the forbidden sentinel — if a "new mode" test passes pre-implementation, the probe accidentally tautologized (e.g. old pool[0] == new pool[0]); pick probe literals whose values differ between old and new behavior. Beware bats `setup()` rebuilding the bundle per test file: a long first sweep lets mid-session src edits leak into later tests' "red" proof — prove red from a pristine tree before implementing.
@@ -0,0 +1,65 @@
---
name: rootless-to-root-quadlet-journal-read
description: "Convert a rootless Podman Quadlet (e.g. Alloy) to a ROOT system service so it can read the host journal — the machine-id mount, Network=host for loopback-published Loki, root image mirror, and the user-unit cutover. Use when an sd_journal/loki.source.journal reader ships zero lines with a green health check."
---
# Rootless→root Quadlet so a container can read the host journal
Symptom: a rootless Quadlet that scrapes the systemd journal (`loki.source.journal`,
any `sd_journal` reader) ships **zero** lines — silently, `health` green,
`loki_source_journal_*_lines_total 0`, no error in the log.
## Why (proven)
- Journal files are `root:systemd-journal 0640` (`group:adm`, `other::---`). A rootless
container spawns with no supplementary groups → `sd_journal` opens an empty view.
Proof a rootless read is denied: `podman run --rm -v /var/log/journal:...:ro busybox
cat /var/log/journal/*/*.journal` → `DENIED`.
- Rootless Quadlet **container** logs also live in `user-<uid>.journal` (journald log
driver), not `ctr.log`. A system-journal-only read misses them.
## The shape that works (root, not rootless)
Run it as a **root system Quadlet** (`/etc/containers/systemd/<svc>.container`,
`WantedBy=multi-user.target`). Root's DAC override opens every journal file, so one
`loki.source.journal` reads `system.journal` **and** `user-*.journal` together.
Four load-bearing bits (all validated on Dankclaw Alloy 2026-10-20):
1. **`Volume=/etc/machine-id:/etc/machine-id:ro`** — the single biggest gotcha.
`sd_journal` locates the journal dir by the *local* machine id; without it the
container opens **nothing** and reads exactly 0 lines, health green. A root probe
without it = 0; with it = 31,825 lines in 20s.
2. **`Network=host`** — if the sink (Loki) publishes `127.0.0.1:3100` (loopback), no
bridge container (root or rootless) can reach the host loopback; `host.containers.internal`
and the LAN IP are both refused. Host network + push to `http://127.0.0.1:3100`.
Side effect: bind the app UI to `127.0.0.1` (no `PublishPort`) so it stays loopback-only.
3. **`/var/log/journal` + `/run/log/journal`** mounted `:ro`.
4. **Image must be in the ROOT store.** A root `podman build` runs in the build
container's own resolver and fails DNS here. Build rootless, then mirror:
`podman save -o t.tar <ref> && sudo podman load -i t.tar`. Compare image ids so a
rebuild actually re-lands (stale root tag is a silent footgun).
## Deployer cutover (retire the rootless unit FIRST)
Both the user and system managers generate `<svc>.service`; two containers named
`<svc>` both binding the same loopback port cannot coexist. Before starting the system
unit: `systemctl --user stop <svc>.service`; `mv` the user quadlet out of
`~/.config/containers/systemd/` (it regenerates from there) to `~/.AGENTTRASH/<date>-<svc>-root-cutover/`;
`systemctl --user daemon-reload`. Then install to `/etc/containers/systemd/`,
`sudo systemctl daemon-reload`, `sudo systemctl restart <svc>.service`. Generated
(system) units cannot be `enable`d here — boot persistence is the `[Install]` section.
## Probe the exact command BEFORE writing the unit
`sudo podman run --rm --net=host -v /etc/machine-id:...:ro -v journal mounts
<image> run --server.http.listen-addr=127.0.0.1:<free-port> <config>` — pick a FREE UI
port (the deployed instance already owns the real one; a bind conflict reads as
"reads 0 lines", not "port in use"). A squashed one-line River/HCL block is invalid —
attributes need newline terminators, or the probe dies before reading anything.
## Verify (query gotchas that caused false "empty" reads)
- `loki_source_journal_target_lines_total` must climb.
- Loki `/loki/api/v1/query_range` returns `values` as `["<ts_ns>","<line>"]` **arrays** —
grep `"ts"` finds nothing; the data is there. `/series` defaults to a short index window
and can read empty while recent data is queryable. Use a wide `start`/`end`, no `direction`.
- A fresh journal reader replays the whole persistent journal from the oldest; Loki
`reject_old_samples` + the per-tenant rate limit discard old/throttled entries
(`loki_discarded_samples_total{reason="rate_limited"}`) without counting as writer
drops. It self-clears once it reaches the tail; raise the ingestion rate to speed it or
start at the head to skip.
@@ -0,0 +1,41 @@
---
name: rotate-fleet-service-host-secret
description: "Rotate a Dank-fleet rootless-quadlet service's host-side secret (e.g. open-notebook OPEN_NOTEBOOK_PASSWORD) safely: confirm the deployer's secret seeder is idempotent first so a future deploy won't clobber the new value, change ONLY the target line while holding encryption keys byte-identical, then restart the user unit and prove the rotation (new token 200, old token 401, no token 401). Not for git/Gitea tokens or LLM endpoint secrets."
---
# Rotate a fleet service's host-side secret safely
Host secrets live OUTSIDE the repo, in `/home/dank/Containers/<svc>/env` (mode 600). The repo only holds the knobs (`scripts/<svc>.env`, secrets declared EMPTY). Changing a secret = edit the host file, never the repo.
## 1. First: prove the deployer won't clobber it (MANDATORY)
`deploy-remote.sh` seeds some secrets from `dankclaw/.env`. Confirm the seed fn is **idempotent** before editing, or a later `deploy.sh` overwrites your value. In `scripts/deploy-remote.sh`, `seed_<svc>_env` must look like open-notebook's:
```bash
for var in A B C D; do grep -q "^${var}=." "$dest" 2>/dev/null || missing=1; done
[[ "$missing" == 0 ]] && return 0 # all present → leaves the file untouched
if [[ ! -e "$dest" ]]; then ...seed from dankclaw/.env...; fi
```
If it returns-early on all-present, editing `$dest` directly is deploy-safe. If it seeds unconditionally, edit BOTH the host file and `dankclaw/.env` instead.
## 2. Read, then rewrite only the target line
`read ssh://<host>/home/dank/Containers/<svc>/env` (never print secrets to chat — they leak into the transcript). Then `write ssh://...` overwriting with **all** lines, changing only the one secret. **Hold encryption keys byte-identical** (e.g. `OPEN_NOTEBOOK_ENCRYPTION_KEY`): a DB-encrypted credential store is unreadable if that key changes. Then `chmod 600`.
- ssh:// `read` emits NO edit tag, so the `edit` tool can't target it — use `write` (full overwrite) with the exact values from the read.
## 3. Restart the right unit
Rootless quadlets are USER units: `ssh <host> systemctl --user restart <svc>.service`. (systemctl --user works over plain ssh here — linger + bus are set up.) Restart only the app unit; DB password unchanged means the DB unit needs no restart.
## 4. Prove the rotation behaviorally (this is the smoke test)
Hit a protected API route three ways, expecting **new=200 / old=401 / none=401**. Pass the bearer via a mode-600 curl config + `-K` (never `-H "Bearer …"` — argv leak):
```bash
ssh <host> 'cfg=$(mktemp); chmod 600 "$cfg"
printf "header = \"Authorization: Bearer %s\"\n" "<new-or-old>" > "$cfg"
curl -s -o /dev/null -w "%{http_code}\n" -K "$cfg" http://127.0.0.1:5056/api/<protected>'
```
## Gotchas that bit
- **Health is on the API loopback port, not the web port.** open-notebook: `/health` answers on **127.0.0.1:5056** (the FastAPI), NOT `:8502` (Next.js, which 404s `/health`). Probing the wrong port reads as "service failed" when it's up and `healthy`. Excluded/auth-free paths (`/`, `/health`, `/docs`, `/openapi.json`, `/redoc`) — everything else needs the bearer.
- **`OPEN_NOTEBOOK_PASSWORD` unset = auth OFF** for anything that can reach the port (other containers dial it by name on `dankclaw_default`), so it's mandatory, not cosmetic; deployer refuses to start on blank.
- Browser/clients keep the old secret in localStorage → they 401 on next call and re-prompt. Expected.
- **Harness command-parser gotcha:** adjacent/interleaved single+double quotes in one bash line (`'...'"$x"'...'`) crash `pi-natives:command: unterminated quote`. Build a curl-config `header=` line with the proven single-quoted printf (`printf 'header = "Authorization: token %s"\n' "$tok"`) and put long bodies in a file via the `write` tool + `jq --rawfile`, not a giant inline `$(printf …)`.
@@ -0,0 +1,61 @@
---
name: run-e2e-specs-from-isolated-worktree
description: "Run serverAppConfigs Playwright e2e specs against the live fleet from a feature branch when git checkout is workspace-policy-blocked — isolated worktree + symlinked deps, with the variadic --project and gitignored-auth traps."
---
## When
You are authoring/verifying a `tests/e2e/*.spec.ts` change for `dank/serverAppConfigs` and the shared workspace blocks `git checkout` / branch switch (concurrent-agent safety). You must run the new specs against the LIVE fleet (they are guard-chain tests: Anubis PoW + authentik outpost + app) but a fresh worktree has no `node_modules`, no `.auth`, and no `.env.local`.
This is NOT `deploy.sh` (which runs the full e2e gate itself from the main-tracking checkout — see `serverappconfigs-deploy-execution-context`). Use this to iterate/verify individual specs on a branch before opening a PR.
## Setup the worktree
The main deploy checkout (has the deps + auth state) is `/home/blank/Projects/serverAppConfig`. Branch off `origin/main` into a throwaway worktree:
```bash
git -C <repo> worktree add -b feat/<topic> /tmp/wt-<topic> origin/main
ME=/home/blank/Projects/serverAppConfig/tests/e2e
ln -sfn "$ME/node_modules" /tmp/wt-<topic>/tests/e2e/node_modules
cp -r "$ME/.auth" /tmp/wt-<topic>/tests/e2e/.auth
cp "$ME/.env.local" /tmp/wt-<topic>/tests/e2e/.env.local # gitignored
```
- **`.env.local` is mandatory** — without it the config throws `missing E2E_STANDARD_USER — set it in tests/e2e/.env.local or the environment` at load, even for `--list`. It is gitignored (a bats seam forbids tracking it), so a fresh worktree never has it.
- `.auth` holds `qa-e2e.json` / `qa-e2e-admin.json` (the `standard`/`admin`/`guest` storage states); copying it avoids re-login, though the `setup` project refreshes it anyway.
- The node_modules symlink resolves fine for playwright.
## Validate wiring before running
```bash
cd /tmp/wt-<topic>/tests/e2e && npx playwright test --list
```
Confirms your new spec's NUMBER PREFIX is matched into a project. **A spec whose prefix is not in any project's `testMatch` silently never runs** — a green suite with a test that does not exist. The gate is the bats seam `tests/e2e_scaffold.bats` → "every e2e spec is matched by a playwright project". So a new spec `21-foo.spec.ts` MUST also edit `playwright.config.ts`'s `standard` (or other) `testMatch: /(10|12|...|21)-.*\.spec\.ts/`.
## Running — the `--project` trap
`--project` is **variadic**: the space form swallows the positional filters as extra project names.
```bash
# WRONG: "21-notebook" parsed as a second project → Project(s) not found
npx playwright test --project standard 21-notebook 22-foo
# RIGHT: equals form, then positional title/file filters
npx playwright test --project=standard 21-notebook 22-foo
npx playwright test --project=guest 14-guard -g "notebook.franksdank.cloud is gated"
```
`standard`/`admin`/`guest`/`frankbook` all `dependencies: ["setup"]`, so `00-auth.setup.ts` runs first and logs in the qa-e2e accounts (~24 s each). Expect ~50 s wall for a couple of specs.
## Verify the spec is meaningful, not vacuous
These specs prove the guard chain handed off to the **app**, so assert a positive app marker (title / app content string / the app's own API returning `status:"success"`), never just `status < 500` or "not the IdP shell" — the latter passes on a 404 or a parked login wall (the 13-sso / 17-hermes class of silent-green bug).
## Cleanup
```bash
git -C <repo> worktree remove --force /tmp/wt-<topic> # symlink + copied auth are throwaway; branch already pushed
```
Full `./tests/run.sh` (bats, incl. `e2e_scaffold`) must pass before commit; the deploy's own Playwright gate is the canonical live run after merge.
@@ -0,0 +1,41 @@
---
name: ryzenai-generic-failure-loader-order
description: "Debug AMD Ryzen AI OGA \"Exception during initialization: Generic Failure\" on Linux XDNA2 — the LD_LIBRARY_PATH-vs-activate ordering trap, its proof instruments, and the OGA 0.14 run contract."
---
Symptom (any `onnxruntime_genai` run on a Ryzen AI NPU, container or bare):
```
[E:onnxruntime:, inference_session.cc:2811 operator()] Exception during initialization: Generic Failure
```
Fails ~2 ms after `og.Model()`. No `openat` of `/dev/accel/accel0`, **nothing in the kernel log**, no XRT log (the INI logger is not built into AMD's shipped XRT), no core dump. It is NOT a platform, model-export, or driver-ABI failure.
## Root cause
`source <venv>/bin/activate` from `install_ryzen_ai.sh` **prepends** to `LD_LIBRARY_PATH`:
```
site-packages/flexml/flexml_extras/lib : site-packages/onnxruntime/capi : site-packages/voe/lib
```
`voe/lib` is the **compiler-side** bundle: it ships its own `libxrt_coreutil.so.2` and `libryzen_mm.so.1`. Set the path *before* activating and those copies win, XRT's XDNA shim binds against the wrong coreutil, `xrt_core::shim_loader::shim_loader()` → `get_userpf_device()` → `xrt::device(0)` throws, `ryzenai::Allocator::Alloc` throws next, and ORT flattens both into the bare string above.
**Rule: activate first, THEN set `LD_LIBRARY_PATH`, with `/opt/xilinx/xrt/lib` first.** Passing the var via `podman -e` and sourcing activate afterwards in the same `bash -lc` is the broken shape — that is exactly how a runner script differs from a hand-typed probe, and it is why diagnostics pass while the runner fails. Mounting XRT is not enough either: the tree must be **on** the path.
## Proof instruments, cheapest first
1. `ldd <deployment>/lib/libonnxruntime_providers_ryzenai.so | grep -E "libxrt_coreutil|libryzen_mm"` — the resolved path settles it alone: `/opt/xilinx/xrt/lib` = good, `.../voe/lib` = this bug.
2. `XILINX_XRT=/opt/xilinx/xrt /opt/xilinx/xrt/bin/xrt-smi examine` → `RyzenAI-npu5 | aie2p | 6x8` + `NPU Firmware Version`. Needs `libboost_filesystem1.83.0`, `libboost_program_options1.83.0`, `libuuid1` (26.04 names; the 24.04 docs' 1.74.0 does not exist). A "No such file or directory" for a file that exists = missing interpreter/dep, not a missing binary.
3. `strace -f -e trace=openat` — `/dev/accel/accel0` never opened ⇒ failure is pre-device ⇒ loader-level.
4. `gdb -q -batch` with `catch throw` + `bt 10` loop → names `shim_loader` / `Allocator::Alloc`.
Known dead ends (one run each, then stop): `--ulimit memlock`; XRT INI verbosity; `DD_LOG_LEVEL` / `RYZENAI_TRACE` / `RYZENAI_EP_TRACING` (print nothing before the throw); byte-scanning XRT `.so` for PCI IDs (ambiguous); libboost presence for the OGA path (only `xrt-smi` needs it); flag-by-flag container matrices (`--memory`, `--security-opt`, `--name`, extra mounts — all innocent).
## Verified facts on this stack
- Vendor XRT 2.25.37 userspace (`dpkg-deb -x`, never dpkg-installed) works against Ubuntu's in-tree `amdxdna` 0.7.0; no DKMS module needed. PCI `1022:17f0` rev 11 = XDNA2, reported as `aie2p 6x8`.
- AMD's `quicktest` passing does **not** prove NPU use (it can silently run on CPU); trust `xrt-smi` + logits, not "it didn't crash".
- `/dev/accel/accel0` is `root:render 0660`; rootless podman cannot read it (userns drops the gid; `keep-groups`, `keep-id:gid=990`, `--group-add render` all fail) → rootful container, or a udev rule like the GPU nodes'.
- OGA 0.14 (RAI 1.8) contract: `GeneratorParams` has only `set_search_options` (no `input_ids`, no `model.generate`); feed with `Generator.append_tokens(ids)`, loop `generate_next_token()`, read `get_sequence(0)`; `max_length` counts **prompt + generated**. Full Fusion (`*_npu_4K`) caps input+output at 4096.
- Validate content, not just exit code: `np.argsort(logits[0])[-8:]`. A real logits tensor after `append_tokens` proves the graph executed (prefill is folded into step 1, so a sub-millisecond first step + a 1-token sequence means it did not); if argmax is the sequence terminator (`
@@ -0,0 +1,49 @@
---
name: ryzenai-npu-oga-linux-run
description: "Run an AMD vendor-compiled Ryzen AI (XDNA2) model — OGA/onnxruntime-genai — on a Linux Ryzen AI host with the in-tree amdxdna driver, without installing the vendor driver or touching the host driver; includes the triage for ORT \"Exception during initialization: Generic Failure\"."
---
Run a vendor-compiled Ryzen AI (XDNA2) model on Linux with the **in-tree** amdxdna driver: no vendor kernel module, no `dpkg -i`, host driver untouched.
## Preconditions
- Host: Strix Point / Strix Halo / Krackan (PCI `1022:17f0`), in-tree `amdxdna` bound to the NPU function (`readlink /sys/bus/pci/devices/<bdf>/driver` → `amdxdna`), `/dev/accel/accel0` present.
- `ryzen_ai-<ver>.tgz` installed into a venv (`install_ryzen_ai.sh -a yes -p <venv>`; it hard-codes `python3.12` — put a uv-managed 3.12 **bin dir** on PATH, never a symlink: a symlinked shim breaks venv creation with `No module named 'encodings'`).
- Model artifact MUST match the RAI version: `<name>_rai_<ver>_npu_<ctx>`. Other versions load and emit garbage.
- Vendor XRT userspace: `dpkg-deb -x` the debs from `RAI_<ver>_Linux_NPU_XRT.zip` (base + npu + `xrt_plugin…amdxdna.deb` — the plugin is what ships `libxrt_driver_xdna.so.*`) into a host dir, bind-mount it at `/opt/xilinx/xrt`.
## Container run line (the part that is easy to get wrong)
```
--device /dev/accel/accel0 # root:render 0660 → rootful, or a world-writable udev rule
-v <xrt-root>/opt/xilinx/xrt:/opt/xilinx/xrt:ro
-e RYZEN_AI_INSTALLATION_PATH=<venv> -e XILINX_XRT=/opt/xilinx/xrt
-e LD_LIBRARY_PATH=/opt/xilinx/xrt/lib:<venv>/onnxruntime/lib:<venv>/deployment/lib
# cwd MUST be <venv>/deployment/lib — the RyzenAI EP is dlopen'd by bare name and the
# only place ORT looks is the working directory.
# Activate the venv explicitly; a login shell (`bash -lc`) re-sources the profile and
# can put a numpy-less base python first on PATH.
```
## LAW: mounting XRT is not enough — `/opt/xilinx/xrt/lib` must be ON `LD_LIBRARY_PATH`
The RyzenAI EP reaches the XDNA shim through XRT's own loader. With the tree bind-mounted
but off the loader path, `xrt::device(0)` throws in `xrt_core::shim_loader`, the RMM
allocator fails next, and ORT flattens all of it into
```
[E:onnxruntime:, inference_session.cc:2811] Exception during initialization: Generic Failure
```
Signature that distinguishes this from a real device/driver fault: fails in ~2 ms, **never
opens `/dev/accel/accel0`**, nothing in `dmesg`/`journalctl -k`, no XRT log (the INI logger
is not built into the vendor build — `XRT_INI_PATH` + `[Common] verbosity` writes nothing).
## Triage recipe for "Generic Failure" (in this order — each step is one call)
1. `strace -f -e trace=openat` → does it open `/dev/accel/accel0`? If **no**, the failure is pre-device: loader/shim, not the driver. Also note the last file it opens (`genai_config.json` = config parsed, then provider setup fails).
2. `gdb -q -batch -x cmds --args python driver.py` with `catch throw` + `bt 10` + `continue`. Frames `xrt_core::shim_loader::shim_loader` / `get_userpf_device` / `xrt::device::device` then `ryzenai::Allocator::Alloc` ⇒ the shim never loaded ⇒ fix `LD_LIBRARY_PATH`.
3. Prove it with a two-arm run in ONE container (toggle only the suspect) before writing the law down. Both arms passing ⇒ that variable was never the cause.
4. Ground truth on device visibility: `xrt-smi examine` → `RyzenAI-npu5 | aie2p | 6x8` + `NPU Firmware Version`. If xrt-smi dies on `libboost_filesystem.so.1.83.0`, install `libboost_filesystem1.83.0`+`libboost_program_options1.83.0` — **boost is only needed by xrt-smi, not by the OGA/EP path** (verified by purging it and still loading the model).
5. `voe` in site-packages is a *compiler* toolkit (passes/patterns), not a runtime — swapping `LD_LIBRARY_PATH` to `voe/lib` fixes nothing.
6. Byte-scanning XRT `.so` files for supported PCI IDs is inconclusive (2-byte ID patterns collide constantly) — don't bother.
## Distrust "it ran" on this stack
- Vendor `quicktest` can print success while computing on CPU (missing peano lib ⇒ silent CPU fallback). Prove NPU residency per run: sample `/sys/class/accel/accel0/device/mem_usage` (needs root) during generation, or read the EP's `Actually running on NPU` line.
- Never report request #1 after a boot as a rate — first pass includes graph/init cost.
@@ -0,0 +1,61 @@
---
name: ryzenai-npu-oga-linux-spike
description: "Run an AMD-precompiled AMD Ryzen AI (XDNA2) ONNX-GenAI model on a Linux Strix Halo host from public artifacts: staging, rootless NPU device-access wall, container recipe, and the NuTTS-air speech-token contract (65,536-code vocab, 50 codes/s real-time line)."
---
Run an AMD-precompiled XDNA2 model (OGA / onnxruntime-genai) on a Linux Strix Halo host (Dankclaw-class) without installing the vendor stack on the host.
## Where the bits actually live (both public, no AMD account)
- Runtime tarball: `https://download.amd.com/opendownload/RyzenAI/1.8.0/ryzen_ai-1.8.0.tgz` (~7.28 GiB). Verify with a ranged GET — first bytes `1f 8b 08`.
- NPU driver/XRT zip: `https://download.amd.com/opendownload/RyzenAI/Driver/RAI_1.8_Linux_NPU_XRT.zip` (90 MB).
- Dead ends (404, do not re-probe): `download.amd.com/driver/linux/RAI_1.8_Linux_NPU_XRT.zip`, `.../opendownload/RyzenAI/ryzen_ai-1.8.0.tgz`, `www.xilinx.com/bin/public/openDownload?filename=...` (301 → 404). The login-walled EULA *form* is not the gate; the `opendownload` tree is the real file host.
Tarball has 30 top-level entries: cp312/linux_x86_64 wheels (`onnxruntime_genai_ryzenai-0.14.0`, `onnxruntime_vitisai-1.27.0`, `vitis_mllib`, `flexml*`, `device_essentials_strx`, `model_generate`, `voe-1.8.0`), plus `deployment.tar.gz` (contains `lib/libonnxruntime-genai.so`, `lib/libonnxruntime_providers_ryzenai.so`, AIE compiler libs), `onnxruntime.tar.gz`, `quicktest.tar.gz` (VitisAI-EP install validator), `LLM.tar.gz` (text-only OGA examples: `run_model.py`, `tokenizer_factory.py`), `install_ryzen_ai.sh`. `voe-4.0-linux_x86_64.tar.gz` is only `vaip_config.json` — no TTS xclbins. **AMD ships no TTS runner and no TTS example.**
## Host rules
- **Never `dpkg -i` the vendor XRT.** `dpkg-deb -x <deb> <dir>` and bind-mount `<dir>/opt/xilinx/xrt` at the same path in the container. The plugin deb would install a DKMS `amdxdna` that replaces the distro in-tree driver; the in-tree driver on Ubuntu 26.04 is `amdxdna 0.7.0` and already loads `amdnpu/17f0_11/npu_7.sbin` at boot (`dmesg | grep amdxdna`).
- **Rootless containers cannot read `/dev/accel/accel0`** (`root:render 0660`): the host gid never enters the userns gid map, so `--device` yields `nobody:nogroup`. Verified failing 2026-10-19: `--group-add keep-groups`, `--userns=keep-id`, `--userns=keep-id:gid=990` (process gets gid 990, node still unreadable), `--group-add render`, `--group-add gid:990` (both error). Options: run rootful (`sudo podman`, separate storage → rebuild the image rootful, ~1 min), or add a world-writable udev rule for `accel0` (the fleet already does exactly this for `kfd`/`renderD*` in `99-comfyui-gpu-access.rules`, whose header explains why: systemd user-unit spawns carry no supplementary groups).
- **Rootless image build trap:** the vendor installer's `tar -xf` preserves recorded uids (95777 etc.) and aborts under `set -e` with `Cannot change ownership ... Invalid argument`, silently skipping the `deployment`/`onnxruntime` extraction. Patch it in the Containerfile: `sed -i 's/tar -xf/tar --no-same-owner -xf/g' install_ryzen_ai.sh`.
- Installer usage: `./install_ryzen_ai.sh -a yes -p /opt/rai -c /opt/rai` (python **3.12** required; base `python:3.12-slim`; needs `linux-libc-dev zip pciutils patch libgomp1`). It builds a venv from the local wheels, reinstalls `onnxruntime-vitisai` then `--no-deps onnxruntime-genai-ryzenai`, installs `requirements_extras.txt` (transformers 4.57.6), patches `bin/activate` (sets `RYZEN_AI_INSTALLATION_PATH=$VIRTUAL_ENV` + `LD_LIBRARY_PATH` into site-packages), and untars `LLM/deployment/onnxruntime/voe/quicktest` into the cpp path.
- `xrt-smi` in a container needs `libboost-filesystem1.83.0` + `libboost-program-options1.83.0` (**hyphenated** Ubuntu names) and `apt update` first; boost is **only** needed by `xrt-smi`, never by the OGA/EP path.
## HF artifact staging
- Gated (401 on API, 403 on git) for a plain account: `neuphonic/neucodec` (the **only** NeuCodec encoder), `neuphonic/neucodec-onnx-decoder`, `neuphonic/neucodec-onnx-decoder-int8`, `neuphonic/neutts-air`. `amd/neutts-air_rai_1.8.0_npu_4K` is public. Public mirrors of the codec exist (`eugenehp/neucodec` has the full 1.16 GB `pytorch_model.bin`) — treat a mirror of a gated release as a licensing decision for the operator, not a workaround to take silently.
- Gated-repo loader trap: `HF_HUB_OFFLINE=1` + `HF_HOME` pointed at a cache laid out as `hub/models--<org>--<repo>/snapshots/<rev>/…` works, but `NeuCodec._from_pretrained` **asserts the repo id** is in `["neuphonic/neucodec", "neuphonic/distill-neucodec"]` — a local directory path is refused outright, so pass the repo id and satisfy it from the cache.
- `github.com/neuphonic/neutts` is public and ships `samples/{dave,emily,greta,jo}.{pt,txt,wav}` = **pre-encoded reference codes + transcripts** → a spike needs zero gated bytes.
- Auth without leaking the token into `ps`: build a 600-perm credential file with a shell builtin and point git at it —
`umask 077; printf 'https://oauth2:%s@huggingface.co\n' "$(cat ~/.cache/huggingface/token)" > ~/.git-credentials-hf`
then `git -c credential.helper="store --file ~/.git-credentials-hf" clone ...`. Never `git clone https://oauth2:$TOKEN@...`.
## NuTTS-air on XDNA2 (the model-side contract)
- The package is an OGA model dir: `genai_config.json` (`type: qwen2`, 24 layers, hidden 896, 14 heads / 2 KV, ctx 32768, `vocab_size` **217,652**, `provider_options: [{"RyzenAI": {"hybrid_opt_token_backend": "npu"}}]`), plus `dd_metastate_*` AIE artifacts and `model.onnx` + `model.onnx.data`.
- **Speech tokens live in the text vocab, and the codebook is 65,536 — not 10,001.** `tokenizer.json` `added_tokens` holds exactly 65,536 `<|speech_N|>` entries, ids **151,671–217,206** contiguous, over a base BPE vocab of 151,643; specials are 151,644–151,670 (`<|SPEECH_GENERATION_END|>` = 151,670). The vendor template maps a codec index straight to its id: `"".join(f"<|speech_{i}|>" for i in ref_codes)`. An encoded reference really does contain codes in the 60k range, so a 10k assumption silently truncates the space.
- The graph's own `eos_token_id` is `[151645, 151643]` (the **chat turn** stop) and `search.eos_token_ids` is unset — pass 151,670 as an extra eos or speech ends after one token.
- **Real-time line = 50 speech codes/s** — NeuCodec `sample_rate 24000 / hop_length 480`, measured exactly (49.96–50.00 across three 20–25 s references). Judge any NPU/iGPU TTS number against 50. (Qwen3-TTS, the fleet's deployed arm, is 12.5 codes/s — do not conflate the two bars.)
- Reference encode is cheap and cacheable: `librosa.load(sr=16000, mono=True)` → `[1,1,T]` float tensor → `codec.encode_code(audio_or_path=…)` (mirrors `NeuTTS.encode_reference`); CPU encoder RTF 0.28–0.38 on 8 threads. `neucodec` needs `torchtune` (`RotaryPositionalEmbeddings`) — install both `--no-deps --target <dir>` so a second torch never shadows the venv's.
- Build the prompt by *importing* the vendor function, never by copying the template:
`sys.path.insert(0, <neutts repo>); from neutts.neutts import NeuTTS`, then
`NeuTTS._apply_chat_template(SimpleNamespace(tokenizer=hf_tok, input_format="text"), ref_codes, ref_text, text, None)` — works on a duck-typed `self`, so no backbone/codec load. Template: `user\n<|TEXT_REPLACE|>\nassistant\n<|SPEECH_REPLACE|>`; splice `TEXT_PROMPT_START + encode(f"{ref_text} {text}") + TEXT_PROMPT_END` at TEXT_REPLACE, then truncate at SPEECH_REPLACE and append `[SPEECH_GENERATION_START] + encode("".join(f"<|speech_{i}|>"))`.
- Voice identity = reference codes + transcript (request-time conditioning). No trained artifact exists to preserve; packs holding only `reference.wav` require the gated encoder once, offline — **and** a transcript, which a voice pack does not carry (`manifest.json` holds provenance/crop geometry only) and a host without ASR cannot produce.
- Full-Fusion input ceiling: 4096 tokens **total**, input + output.
## Status caveat (2026-10-19)
Staged artifacts, image build, device-access matrix, prompt contract, reference pre-encode:
**done and reproducible.** Token generation on the NPU: **NO-GO, and the loader is the
wall.** The device is healthy (`xrt-smi validate` PASSED incl. `throughput`,
`RyzenAI-npu5 / aie2p / 6×8`, `pyxrt.device(0)` constructs), and `strace` shows the
failing load reading `genai_config.json`, opening `model.onnx`, loading
`libxrt_driver_xdna.so.2` and opening `/dev/accel/accel0` with successful GEM ioctls —
then the RyzenAI EP **declines the graph before touching any `dd_metastate_*`**, silently:
`GENAI_ORT_VERBOSE_LOGGING`, `GENAI_LOG_ORT_LIB`, `set_log_options`,
`set_log_callback(callable)`, `RYZENAI_EP_TRACING`, `RYZENAI_LLM_PROFILE_INIT` all emit
zero lines. Hardlinked mirror dirs of the model tree were ruled out as shadowing. One run
loaded and produced finite logits; byte-identical runs never reproduced it. **No token rate
was ever measured, so quote none**, and remember `quicktest` success ≠ NPU residency (that
build falls back to CPU silently — prove residency via
`/sys/class/accel/accel0/device/mem_usage`).
@@ -0,0 +1,116 @@
---
name: ryzenai-oga-model-dir-and-eos-override
description: "Debug OGA on XDNA2 when the RyzenAI EP never claims the If-rooted graph (shadow model dirs, bind-mounted configs, missing device), or a TTS/LLM graph stops on the wrong eos token."
---
# OGA on XDNA2: graph not claimed, or generation stops at once
Two independent failure families in the AMD `onnxruntime-genai` (OGA) + RyzenAI EP stack on
Linux XDNA2 (Strix Halo / Ryzen AI Max). Diagnose them in this order — they are separate
gates and each has its own instrument.
## Gate 0 — is the NPU path alive at all?
Run the vendor's own instrument before touching anything else:
```bash
cd /opt/rai/quicktest && RYZEN_AI_INSTALLATION_PATH=/opt/rai python quicktest.py
# want: "Setting environment for STX/KRK" ... "Session successfully initialized" ... "Test Finished"
```
It finds the device by shelling out to `lspci -nn` and grepping `1022:17f0`, so it also
proves `lspci` is present in the container. Pass ⇒ the in-tree `amdxdna` driver and the
extracted userspace pair correctly; stop blaming the host.
Separate **XRT enumeration** from **OGA's device probe** — they are not the same check:
```python
import sys; sys.path.insert(0, "/opt/xilinx/xrt/python")
import pyxrt; dev = pyxrt.device(0) # constructs => XRT sees the device
```
`pyxrt.device(0)` succeeding while OGA says `No RyzenAI devices detected` means the
refusal is OGA's own predicate, inside `libonnxruntime-genai.so` (the string lives there,
next to `RyzenAI`, `max_length_for_kv_cache`, `library_path`). EP-side env levers worth
pulling: `RYZENAI_EP_TRACING`, `RYZENAI_EP_PERFORMANCE_COUNTERS`, `RYZENAI_LLM_ASYNC_INIT`,
`RYZENAI_LLM_PROFILE_INIT`.
## Gate 1 — `invalid model … Node (If) cond_full`
This is **ONNX Runtime verifying the raw graph**, which happens when no provider claimed
it: the EP registered nothing and the CPU provider was left holding an `If` whose branches
mismatch. It is never a corrupted model.
```bash
LD_DEBUG=files LD_DEBUG_OUTPUT=/tmp/ld python loadprobe.py
grep -a "calling init:.*ryzenai\|undefined symbol" /tmp/ld.*
# observed: libonnxruntime_providers_ryzenai.so loaded, then
# symbol lookup error: undefined symbol: GetProvider (fatal)
```
`GetProvider` is not a dynamic symbol in the ORT cores, the shared-provider stub, or the
EP (`nm -D` shows neither defines nor wants it), so the failure is registration, not an
ELF mismatch. Confirm the cores are identical before chasing versions: both shipped
`libonnxruntime.so.1.27.0` copies hash equal.
Separate the two gates with an explicit global-scope load before importing OGA:
```python
import ctypes
ctypes.CDLL("/opt/rai/deployment/lib/libonnxruntime_providers_ryzenai.so",
mode=ctypes.RTLD_GLOBAL)
import onnxruntime_genai as og
og.Model(MODEL) # registration fixed => error MOVES to "No RyzenAI devices detected"
```
An error that **moves** proves you cleared one gate and exposes the next. Silence in the
first form means the provider list was empty.
### Dead ends (measured, do not retry)
- **Shadow model dirs never work.** Hardlink mirror, symlink mirror, and even a
read-only single-file bind mount of a patched `genai_config.json` over the export's own
`genai_config.json` all produce the same `If` rejection. The graph is only claimable
when loaded from the export's own directory, and there is no config-path override in
`0.14.0-dev`: the only env names in the shipped binaries are `GENAI_LOG_ORT_LIB`,
`GENAI_ORT_VERBOSE_LOGGING`, `OG_ORT_LIB`. Carrying a config change therefore means
either editing the download in place or a **single-file bind mount over the real dir**
(`-v file.json:/spike/<model>/genai_config.json:ro`) — and if the EP still refuses, the
gate is not the config.
- **Bisect the image before believing "environment drift".** Run the same load probe
against every local tag; three tags failing identically rules the image out.
- **Rule out host state with instruments, not memory:** `fuser -v /dev/accel/accel0`,
`dmesg | grep -i xdna`, `stat -c "%i %y"` on the model files, `df -h`, `free -g`.
- `sysfs` class is `accel`, not `misc`: glob `/sys/class/accel*/*`. An empty
`/sys/class/misc/accel*` is your glob, not a missing device.
- `GENAI_ORT_VERBOSE_LOGGING=1` and `og.set_log_options(...)` produce nothing useful in
this build; `set_log_options` is a pybind builtin (no `inspect.signature`) and rejects
`severity` as an int. Don't burn turns trying to make it talk — use `LD_DEBUG`.
- `strace` is absent from the RAI image and `apt` may have no mirror: `LD_DEBUG=files` is
the loader tracer that needs nothing installed.
## Gate 2 — generation stops after one token
OGA reads its stop list from the **model** section: `model.eos_token_id`. Chat exports
carry the chat-turn-close id (e.g. 151645) there, so the model emits it first and the
speech/tool budget is never spent — while `search` has **no** eos key: writing
`search.eos_token_id` is a parse refusal (`Unknown value "eos_token_id"`), not a no-op.
- Fix: rewrite `model.eos_token_id` to only the task's own end token (TTS: the
speech-generation-end id), or pass it through `set_search_options` if the build accepts
that key name.
- A sub-millisecond `generate_next_token()` **with finite, high-confidence logits** means
the graph ran and chose a stop token — it is not evidence of a CPU fallback.
- Verify a token-range census against the export's `added_tokens.json`, not against a
guessed vocabulary size: NeuCodec codes reach 65,536 entries (ids 151671…217206), and a
census bounded at 10k silently miscounts every code as a text token.
- A hardlinked run dir is a loaded gun: `open(dst, "w")` on a hardlink truncates the
**original** through the shared inode. `os.unlink` the link before writing a patched
file. Also skip `.git/` and mirror directories when walking an HF checkout.
## Rate measurement
Once a load succeeds, measure at ≥300 steps with a host-side `%CPU` sampler beside the
container (`ps -C python -o pid=,pcpu=,rss=` every 2 s): a device-bound decode waits on
`accel0` and stays under one core, a silent CPU fallback pegs at least one. Never report
request #1 after a boot — graph capture makes it a warmup artifact.
@@ -0,0 +1,77 @@
---
name: ryzenai-venv-dependency-triangle
description: "Debug the torchao/torchtune/transformers import triangle inside an AMD Ryzen AI (ryzen_ai-*.tgz) venv under torch 2.6, including the masked \"cannot import name\" failures and the port-the-one-symbol escape."
---
# Ryzen AI venv dependency triangle (torch 2.6 era)
Symptom that starts it all — and it never names the culprit:
```
ImportError: cannot import name 'HubertModel' from 'transformers'
```
The named symbol is healthy. `import torchao` alone reproduces the failure; `import transformers` alone does not. transformers imports its **torchao integration** whenever the real modelling path loads, and the failure is re-raised two frames deep by the lazy loader as a missing name.
## Instruments
Never conclude from `grep -c Symbol transformers/__init__.py` — 4.5x generates its lazy `__init__`, so an absent literal proves nothing. Get the real exception:
```python
import traceback, torch, transformers
try:
__import__("transformers.models.hubert.modeling_hubert")
except Exception:
traceback.print_exc()
```
That prints the truth, e.g. `AttributeError: module 'torchao.quantization' has no attribute 'Float8WeightOnlyConfig'` from `transformers/modeling_layers.py`.
Also probe the order matrix (`import X; from transformers import Y` for each suspect): only the poisoned order fails, which isolates the culprit without reading vendor code.
## The triangle (measured 2026-10-19, ryzen_ai 1.8.0, torch 2.6.0+cpu, transformers 4.57.6)
| Option | Result |
|---|---|
| torchao absent | `from transformers import HubertModel` -> OK |
| torchao 0.7.0+cpu | `ImportError: Float8WeightOnlyConfig` (masked as missing symbol) |
| torchao 0.8.0+cpu | imports (warns `No module named 'triton'`), still lacks `Float8WeightOnlyConfig` |
| torchao >= 0.9 | has the symbol, requires torch >= 2.7 -> impossible in this venv |
| torchtune, ANY version (0.6.1 … 0.1.1) | guard `torchao not installed`, or `pyarrow requires NumPy 2.0 or newer, found 1.26.4` |
Two independent dead ends: torchtune's package init pulls `datasets` -> **the pyarrow AMD ships in its own venv refuses that venv's own numpy 1.26.4**. So `import torchtune` is structurally dead, and torchao (the only thing that satisfies its guard) breaks transformers. **Never "just upgrade" torch/numpy: the vendor's ONNX runtime and torch build are what the NPU EP is loaded against.**
## Escape: port the one symbol, pin both absent
Ask what the third-party package is actually used for before fighting the resolver. In `neucodec` it was one symbol (`RotaryPositionalEmbeddings`, ~40 lines, copied verbatim from `torchtune/modules/position_embeddings.py`, BSD-3) used by two files. Apply it in the image with a transform that **asserts** each import line exists, so a vendor update fails the build instead of shipping a half-patched package:
```dockerfile
COPY patches/rope_local.py /opt/patch/rope_local.py
RUN python - <<'PY'
import pathlib
from importlib.util import find_spec
pkg = pathlib.Path(find_spec("neucodec").origin).parent # find_spec: importing it is what is impossible yet
(pkg / "rope_local.py").write_text(pathlib.Path("/opt/patch/rope_local.py").read_text())
old, new = "from torchtune.modules import RotaryPositionalEmbeddings", "from .rope_local import RotaryPositionalEmbeddings"
for name in ("bs_roformer5.py", "codec_decoder_vocos.py"):
p = pkg / name; s = p.read_text()
assert old in s, name + ": import line moved - re-read the vendor source"
p.write_text(s.replace(old, new))
PY
```
Gate both absences at build time so nobody re-adds them and inherits a three-week-later mystery:
```dockerfile
RUN ... import-check \
&& python -c "import importlib.util as u; assert u.find_spec('torchao') is None; assert u.find_spec('torchtune') is None"
```
Prove a ported numeric kernel with the vendor's own fixtures, not with faith: `neucodec` ships `samples/jo.wav` *and* its own encoding `samples/jo.pt`, so re-encoding the WAV and comparing integer codes is a pass/fail correctness test of the RoPE port.
## Rules of thumb
- `--no-deps` for anything adjacent to torch/onnxruntime/numpy; check the vendor pins by reading them out of the venv (`python -c "import torch; print(torch.__version__)"`), never from docs.
- A `--dry-run` install that resolves without touching torch/numpy/onnxruntime is the licence to install *with* deps.
- `uv pip install --target DIR` / a nested venv does **not** layer over a parent venv: a venv created from `/opt/rai/bin/python` resolves "system site-packages" to the base interpreter, and uv's resolver ignores `.pth` paths and will pull a second numpy that breaks the vendor C extensions.
- Ephemeral `podman run --rm` probes discard everything you installed in them — build a throwaway tag (truncate the Containerfile before the failing step) instead of re-hypothesising.
@@ -0,0 +1,30 @@
---
name: serverappconfigs-deploy-execution-context
description: "Run scripts/deploy.sh for serverAppConfigs from the correct checkout: must be a clean dev-side main branch, never a feature worktree, and how to get there when the session worktree sits on another branch."
---
# Run scripts/deploy.sh from the correct checkout
`deploy.sh` hard-refuses outside a clean `main` checkout (`FAIL: deploy.sh must run from the main branch.`, exit non-zero) — and this box has several serverAppConfigs checkouts.
## The map (dev box, verified 2026-10-21)
- **Canonical dev main checkout: `/home/blank/Projects/serverAppConfig`** — the ONLY place `deploy.sh` may run. It holds the `main` branch worktree (git refuses the same branch checked out twice, so session worktrees can never satisfy the gate).
- `~/.omp/wt/wt-<...>` session worktrees: often parked on unrelated feature branches with uncommitted work — never deploy from here; do not `git checkout` here (workspace policy blocks it in shared trees).
- `/tmp/wt-*` build worktrees: correct place to edit/test/PR, wrong place to deploy (branch != main by design).
## Procedure
```bash
cd /home/blank/Projects/serverAppConfig
git pull --ff-only origin main # deployer must carry the merged commit
./scripts/deploy.sh <target> --service <svc>
```
Merge lands on origin/main first — deploy runs AFTER the PR is merged (ff-pull picks it up). Each invocation costs ~2 min (e2e gate ≈ 1 min + health gates); chaining several `--service` runs in one bash call is fine, but background it (`| tail` per service) so the harness doesn't stall the turn.
## Gotchas
- `worktree add -b main /tmp/...` fails (`a branch named 'main' already exists` / `already used by worktree at /home/blank/Projects/serverAppConfig`) — that IS the answer: don't mint a main clone, use the canonical path. Prune leftovers: `git -C /home/blank/Projects/serverAppConfig worktree prune`.
- The remote runner updates its OWN checkout (embedded ff-only pull before dispatch), so shipping a brand-new service handler works: the remote gate is evaluated against freshly pulled code. Only the CLIENT gate (deploy.sh `valid_service`) can veto a first-ever deploy — see skill `new-fleet-service-checklist`.
- Bare `deploy.sh <target>` = fleet-wide drain (restarts every lagging service marker). Always `--service`.
@@ -0,0 +1,73 @@
---
name: shell-suite-green-locale-proof
description: "Proving a bats/shell suite is actually green (grep, never tail) and debugging generated output that silently loses content under LC_ALL=C (multibyte quantifiers in awk/grep). Use when reporting \"suite green\", or when a generator emits partial output with exit 0."
---
# Proving a suite is green, and locale-blind generators
Two failure modes earned on `dank/command-center` (2026-10-20): a false "all tests pass"
report, and a generator that silently dropped a third of its output.
## 1. Never report green from a `tail` window
`bats tests/ | tail -25` shows the last tests, not the failures — `not ok` lines print
inline at their position and vanish off the top. A "109 ok, 0 failures" claim built this
way was wrong; the claim had to be corrected in the merged PR's body.
```bash
out=$(bats tests/ 2>&1)
printf '%s\n' "$out" | grep -cE '^ok' # passing count
printf '%s\n' "$out" | grep -E '^not ok' # THIS is the claim; empty == green
```
Also: a suite can be green under the operator's terminal and red under a C locale, so
run both before writing "green" in a PR body:
```bash
env -u LC_ALL bats tests/ # operator's locale (LANG from the desktop session)
LC_ALL=C bats tests/ # cron, systemd user units, agent shells
```
State which locale each number came from. Agent shells in this fleet run `LC_ALL=C`
even when `LANG=en_US.UTF-8` — `LC_ALL` overrides everything.
## 2. Multibyte quantifiers silently truncate generated output
A quantifier on a multibyte character (`─+`, `━+`, `→*`) breaks under `LC_ALL=C`: gawk
matches byte-wise, so `─+` means "the last byte of U+2500, repeated", which never occurs
in real text. Symptom: output loses *every* match — sections, headers, rows — with
**exit status 0**. No error, no partial-write warning.
```bash
# broken under C
awk '/^[[:space:]]*\/\/[[:space:]]*─+ (.+) ─+[[:space:]]*$/'
# byte-safe in every locale: match the multibyte char as a LITERAL, no quantifier
/^[[:space:]]*\/\/[[:space:]]*─/ {
s = $0; sub(/^[[:space:]]*\/\/[[:space:]]*/, "", s)
if (s ~ /─ .*─/) { gsub(/─/, "", s); gsub(/[[:space:]]+/, " ", s) }
}
```
Do NOT "fix" it by pinning a locale (`LC_ALL=en_US.UTF-8`) — that locale may not be
installed; a CachyOS box here shipped only `C.utf8`. Make the matcher locale-independent.
## 3. Prove the loss, don't infer it
Diff old vs new against real input in both locales, with counts, before claiming a fix:
```bash
git show origin/main:scripts/gen-keybinds.sh > /tmp/old.sh
for L in C C.utf8; do
printf '%s: old=' "$L"; LC_ALL=$L bash /tmp/old.sh "$REAL" | grep -c '^## '
printf ' new='; LC_ALL=$L scripts/gen-keybinds.sh "$REAL" | grep -c '^## '
done # old printed 0 sections vs 12 under C, 120 rows intact, exit 0
```
Check whether a *committed generated artifact* was produced by a bad run — if it has the
full content, it came from a UTF-8 shell and needs no regeneration.
## 4. Gates must not demand deletion
Repo linters walking `.AGENTTRASH/` (quarantined, gitignored) turn green only once the
quarantined page is deleted — an incentive to destroy history. Skip any path containing
`.AGENTTRASH` in both the linter and the coverage checker, and pin it with a test.
@@ -0,0 +1,44 @@
---
name: sigterm-kill-test-stub
description: "Pin \"code kills its child process\" behavior with a bash stub player: make the stub SIGTERM-reactive via sleep&wait, log KILLED via trap; diagnose deferred-trap failures."
---
Use when a test must prove production code sends SIGTERM to a spawned child (player, worker, server stub) and the test asserts the kill happened — or when such a test mysteriously fails with `KILLED` missing and wall time equal to the stub's sleep duration.
## The trap-during-sleep gotcha
A bash stub that sleeps in the **foreground** does NOT run its TERM trap when signalled: bash defers traps until the current foreground command completes. The stub dies (or logs) only after the sleep finishes — long after the test read the evidence file.
Symptoms:
- Assertion on a `KILLED` marker line fails although production code demonstrably calls `child.kill("SIGTERM")`.
- Test wall time equals the stub's full sleep (the kill "had no effect").
## Fix: reactive stub skeleton
```bash
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$STUB_LOG" # call record
trap 'printf "KILLED\n" >> "$STUB_LOG"; exit 143' TERM # BEFORE the sleep
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$COPY" ;; esac; done
sleep "${STUB_HOLD:-0.1}" & # background sleep…
wait $! # …wait is interruptible
printf 'DONE\n' >> "$STUB_LOG"
```
- `sleep N & wait $!` is the idiom: a signal makes builtin `wait` return immediately, the trap runs at once, `exit` inside the trap prevents fallthrough to `DONE`.
- Tune duration per test via the env knob (`STUB_HOLD=5`) so the child is provably still alive when the kill is triggered.
- Put the trap BEFORE any long command; a trap registered after the sleep never guards it.
## Harness wiring (black-box driver pattern)
- Scenario: start the long work → settle (>spawn latency, <hold) → trigger the killer (shutdown event, second invocation, `/stop`) → settle → read log.
- Expose the stub log lines on the driver's stdout (`LOG <line>` per line) so tests grep output, not files: black-box seam stays output-only.
- Assert `KILLED` present; for interrupt-semantics also count (`kills -eq 1`) and total call lines (`PLAYS 3` = call, KILLED, call).
## Orphan hygiene
Second child still sleeping when the driver exits: spawn with `stdio: "ignore"` so the orphan holds no capture pipe — it SIGPIPEs/quiets at teardown; do not lengthen tests waiting for it.
## Verify before trusting
Run the pair separately: kill-tests green while a byte-equality test (`cp` of the payload before sleep) stays green — the reactive rewrite must not drop the copy step (it sits between trap and sleep).
@@ -0,0 +1,48 @@
---
name: single-work-log-bundle-rule
description: "A bundle keeps ONE work log — the entry law, the one-file rule, and the kb-lint gate that fails a second log.md. Use before writing any log entry or creating log.md/NOTES.md/PROGRESS.md/worklog anywhere."
---
A bundle keeps ONE work log. Before writing any log entry, or creating any file named `log.md` / `NOTES.md` / `PROGRESS.md` / `STATUS.md` / `worklog/`, use this: the entry law, the one-file rule, and the `kb-lint.sh` gate that fails a second log. Law earned 2026-10-20 on `dank/command-center` PR #57, after an agent had once kept logs in fifty-odd files and none of them was the place to look.
# One work log per bundle
## The rule
- **One file, at the bundle root.** In `dank/command-center` that is `omp-docs/log.md`. In another repo, the change-log page its docs index names. A subdirectory NEVER keeps its own log, and no run/branch/feature gets a private one.
- **Write it in the same commit as the change it describes; read it before starting** to recover where things stand.
- **Entry = what changed + the durable lesson, a few lines.** No per-file inventories, no run-by-run numbers, no transcripts, no re-expanding an old entry in place — `git log --follow <log>` is the transcript.
- **No log page in the KB?** Add one at its root and list it in the index. That is the fix — never a log somewhere else.
- Measured numbers in a log still have to pass the purpose test (`agent/APPEND_SYSTEM.md` § 1): a figure for a model/runtime/hardware config that is neither deployed nor being compared is dead weight — delete it, do not archive it.
## The gate
`scripts/kb-lint.sh` reports any `log.md` that is not the bundle root's:
```
a bundle has one work log, log.md, at the bundle root; found: core/log.md, log.md
```
`scripts/kb-coverage.py` enforces the other half of "one place to look": every directory has an `index.md` and that index links its siblings. Both gates skip any path containing `.AGENTTRASH` — quarantined pages are not bundle content, and a gate that only goes green once the quarantined copy is deleted is a gate that deletes history.
Run both before committing a docs change:
```bash
bash scripts/kb-lint.sh omp-docs && python3 scripts/kb-coverage.py omp-docs
```
## Folding a second log in (the procedure)
1. **Prove it is redundant, don't assert it.** Read the candidate: its entries are usually migration bookkeeping (file moved, directory created, log created) — every one recoverable from `git log`, so it carries no fact git lacks.
2. **Check whether the copies disagree.** The two logs in command-center gave the same OKF conversion two different dates (2026-07-15 vs 2026-01-15) while git dated the rebuild 2026-08-28: duplicated logs do not preserve facts, they multiply errors.
3. **Fix what pointed at it.** The stale page stays in circulation because an index links it — repoint `core/index.md` (or whichever index) at the root log in the same change.
4. **Quarantine, never `rm`:** `mkdir -p <dir>/.AGENTTRASH && mv <dir>/log.md <dir>/.AGENTTRASH/log.md`, then `git add -A <dir>` so git records the deletion while the copy waits for a pruning session with Frank.
5. **Write the entry in the surviving log**, law-first: what changed, then the durable lesson, then links to the spec pages now carrying the law.
## Traps
- The spec called `log.md` a *reserved* name and maintenance said every bundle "has one" — which reads as "any directory may have one". Reserved ≠ replicable; say **exactly one, at the root** wherever the spec is stated, or the sprawl regrows from the spec itself.
- An OKF maintenance page teaching "one line per change with file path" (exampled as `/docs/ghostty.md — Updated keybind table`) produces manifests that duplicate git and absolute links the linter cannot resolve. Replace the instruction; do not add a second, conflicting one beside it.
- Trashing a page turns gates red if they walk `.AGENTTRASH/` (frontmatter demanded of a quarantined page, an index demanded *for the trash dir*). Fix the gates; never delete the quarantined copy to go green.
- Deleting the log file in a worktree does not preserve it: a copy moved into a gitignored `.AGENTTRASH/` inside a worktree is destroyed when the worktree is removed. Copy it to the deployment (or `~/.AGENTTRASH/`) before pruning.
- Verify the rule is actually live in a fresh session — a subagent reading `~/.omp/agent/APPEND_SYSTEM.md` and quoting the law back proves the deployment, which the current session cannot do for itself (its prompt was built before the change).

Some files were not shown because too many files have changed in this diff Show More