diff --git a/agent/managed-skills/audit-never-run-container-deploy/SKILL.md b/agent/managed-skills/audit-never-run-container-deploy/SKILL.md
new file mode 100644
index 0000000..2334b55
--- /dev/null
+++ b/agent/managed-skills/audit-never-run-container-deploy/SKILL.md
@@ -0,0 +1,77 @@
+---
+name: audit-never-run-container-deploy
+description: "Audit a locally built podman/quadlet deploy path that has never actually run: prove base-image assumptions, env-file reader divergence, and image-hash coverage before the first deploy."
+---
+
+# Audit a never-run container deploy path
+
+A deployable that has never been built or started has never been *executed*: every
+fault in it is invisible to tests that stub the runtime. Audit it as static code
+against the tools it calls, then prove each suspicion cheaply against the real
+tool. The faults worth hunting are the ones that **report success while doing
+nothing** — those are the ones that survive a green `/health` and a green e2e run.
+
+## The three fault classes (all measured 2026-10-18, frank-tts-engine)
+
+1. **A command the base image does not ship.** e.g. `adduser` in a `RUN` line
+ against a trimmed `ubuntu:24.04` — absent, and not pulled in by
+ `ca-certificates curl libgomp1`. Dies at build time (loud), but only the first
+ time anyone builds it.
+ Prove it in the base, never from memory:
+ `podman run --rm sh -c "command -v adduser || echo absent; useradd --system --uid 10000 --home-dir /app --shell /usr/sbin/nologin tts && id tts"`
+ Note the accepted cosmetic warning (`uid 10000 is greater than SYS_UID_MAX 999`)
+ in a comment, or a future agent "fixes" it by dropping `--system` and the uid
+ stops matching the sibling container.
+2. **Two readers of one env file disagree.** systemd units that pass
+ `EnvironmentFile=` hand it to podman `--env-file`, which does **no shell
+ parsing** — it takes the literal remainder of the line — while bash `source`
+ strips one quoting layer. So `WARM_TEXT="Warm up, one two three."` reaches the
+ container *with its quotes*, and a script that interpolates it into a JSON body
+ produces invalid JSON: the container boots, `active`, every voice registered,
+ every voice cold. Fix in the consumer: strip exactly one matched pair **only**
+ when both ends are the same quote AND the inner content carries another quote or
+ backslash (never mangle a value that legitimately contains quotes), then escape.
+3. **A build-skip hash that under-covers the image.** `build_compose_image`-style
+ helpers skip `podman build` when a label hash matches; hashing the `Containerfile`
+ alone means editing a `COPY`'d app/script rebuilds **nothing** — deploy reports
+ success, `/health` answers, container serves old code. Each case arm must name
+ the paths its `Containerfile` copies (`extra=(…)`) and hash Containerfile+extras.
+ Arms that have no extras keep their old hash, so they do not needlessly rebuild.
+
+## Test it as behaviour, never as a grep of the handler
+
+- Drive the **real** function: `awk '/^build_compose_image\(\) \{/,/^\}/' deploy-remote.sh > bci.sh`,
+ `source` it, put a stub `podman` first on `PATH`, assert the observable question —
+ *did the deploy ask podman to build, or did it skip?*
+- Stub traps that cost real time: podman is invoked `podman image inspect --format …`
+ so the tag is **`$3`**, not `$2`; tags contain `/` so a state file named after one
+ needs `${tag//\//_}` or the write silently lands in a nonexistent directory and
+ every image looks permanently unbuilt; `--label cfhash=…` arrives **before** `-t `,
+ so collect the label and write it after the arg loop.
+- For an env-file bug, write one test that reads the file **both** ways — `source`
+ and the `--env-file` way (parse `KEY=literal remainder`) — and assert the program
+ receives the same value in both shapes, including a value that carries its own
+ quotes and a backslash. A suite that only sources the file is how the bug shipped.
+- Prove red: `git show HEAD:scripts/deploy-remote.sh > scripts/deploy-remote.sh`,
+ run the new test (must fail at the exact intended step), restore. Keep each fix in
+ its own commit and run the seam at each one so the branch bisects.
+
+## Base-image and host facts to check while auditing
+
+- `useradd` lives in `login(1)` (base carries it); `adduser` does not.
+- Rootless podman **cannot raise rlimits** — `--ulimit nofile=…` above the host hard
+ limit dies at container create, not at runtime.
+- A referenced `Network=` `.network` file the generator cannot find means the
+ container unit is **not produced at all**.
+- Two containers talk by DNS name only if they attach to a **common** network;
+ check both unit files rather than trusting a comment that says they share one.
+- Container DNS name + the network that owns it, never `host.containers.internal`
+ or a published loopback port, for service-to-service traffic.
+
+## Do not
+
+- Do not deploy the audited thing because the audit is finished: first build of a
+ heavy image (recursive source clone ~10 min) plus a new unit on a host serving
+ people is the operator's call.
+- Do not add a Prometheus target for a container with no `/metrics` — say the probe
+ is a decision (blackbox HTTP), do not fake a scrape.
diff --git a/agent/managed-skills/frank-tts-add-cloned-voice/SKILL.md b/agent/managed-skills/frank-tts-add-cloned-voice/SKILL.md
index 104bea3..d8d5a37 100644
--- a/agent/managed-skills/frank-tts-add-cloned-voice/SKILL.md
+++ b/agent/managed-skills/frank-tts-add-cloned-voice/SKILL.md
@@ -1,125 +1,81 @@
---
name: frank-tts-add-cloned-voice
-description: "Add or repair a cloned voice on the dank fleet: /home/dank/voices/ pack, name rules, boot-warm registration, and the under-a-second proof"
+description: "Add or repair a cloned voice on the dank fleet: pack builder, the reference.wav contract, the boot warm hook (argv-cap and env-quote laws), and how to prove a voice really answers."
---
-Use for "give frank-tts a new voice", "voice X is missing/unknown", or "the first
-request of the day takes 5 s". Runbook of record: vault pages [[Frank TTS]] (voice
-packs, naming) and [[Frank TTS Engine]] (the warm registry). Everything below runs
-as the deploy user on **Dankclaw** (`ssh -o BatchMode=yes dankclaw '…'`); the
-containers are **rootless** (`systemctl --user …`, `podman …` without sudo).
+# Adding (or repairing) a cloned voice on the dank fleet
-## Naming (the question that gets asked first)
+Repo law lives in `serverAppConfigs-docs/Dankclaw/Voice Packs.md` and
+`Frank TTS Engine.md` — read those for the pack format. This skill is the
+**operating sequence plus the traps that cost real time on 2026-10-18**.
-A voice is addressed **by its directory name** — `voice=narrator1` on
-`POST /v1/audio/speech` (omp's TTS device may send `voice_id`, same value). Use:
+## Stack, fast
-- `^[a-z0-9][a-z0-9._-]*$`, **lowercase**, ≤ 64 chars. No slash, no `..`, no
- space, no uppercase; must not start with `.`/`_`/`-`.
-- Why lowercase rather than just "alphanumeric": the shim accepts
- `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` (`app.py`, `VOICE_NAME_RE`) but the **engine
- lowercases names**, so `Narrator1` resolves on one side and not the other. The
- boot warmer refuses a mixed-case pack loudly instead of half-working.
-- Names are user-visible: a 400 carries them in `available_voices`. Fleet
- convention is `narrator1`, `narrator2`, … for the open-notebook podcast
- speakers — open-notebook is configured with exactly `narrator1/2/3`, so renaming
- those breaks podcast rendering, not just TTS — and a lowercase personal name for
- a person's own voice.
+- `frank-tts-engine` — arm A `qwentts.cpp tts-server`, container-internal `:8880`,
+ publishes **no port**. Reach it with `podman exec frank-tts-engine curl …`.
+- `frank-tts` — OpenAI façade on host `:7772`; resolves voice **by name**.
+- Packs: `/home/dank/voices//` with `reference.wav` (+ manifest, `.spk`,
+ `.rvq`). Builder: `scripts/build-voice-pack.sh` (crop 19.84–25 s, first 10 s of
+ any source is forbidden).
+- Weights: `/home/dank/models/Qwen3-TTS-GGUF/` —
+ `scripts/download-frank-tts-engine-qwentts.sh [--verify-only]` (1.4 GiB, ~18 s
+ on the LAN; verifies byte count + Hub LFS sha256).
-## Pack layout (host state — never commit it)
+## Sequence
-`/home/dank/voices//` is a voice **iff** it holds `reference.wav`:
+1. Build the pack (dry-run first — sizes are arithmetic and asserted).
+2. Deploy the engine only if its files changed:
+ `./scripts/deploy.sh dankclaw --service frank-tts-engine`
+ (first run builds the image: ~10 min recursive source clone).
+3. Register/warm: `podman exec frank-tts-engine /app/warm-voices.sh`
+ — registers **every** pack each run (the engine replaces a name it already
+ holds; `tools/tts-server.cpp:148`), then synthesizes twice per voice.
+4. Prove from the consumer side, not the config:
+ `curl -s localhost:7772/v1/audio/voices` then a real
+ `POST /v1/audio/speech` and listen to the bytes.
-```
-reference.wav 24 kHz mono pcm_s16le, 18-25 s of clean single-speaker audio
-reference.spk engine x-vector: enc_dim 2048 f32 = 8192 B
-reference.rvq RVQ codes: 22 B per 12.5 Hz frame (5456-6864 B)
-manifest.json source path + sha256, crop start/end, engine commit, name
-```
+## Trap 1 — the kernel's argument cap (this broke the first deploy)
-Build it; do not hand-assemble and never fabricate `.spk`/`.rvq` from a WAV:
+`/usr/bin/curl: Argument list too long` in the warm log ⇒ a request body was
+passed as **one argv string**. Linux caps a single argument at
+`MAX_ARG_STRLEN = 131 072 bytes`; a ~1 MB `reference.wav` base64's to ~1.27 MB.
+curl never runs, and hooks report it as `HTTP no response` while `/health` is
+green. Fix = write the body to a file and use `--data-binary @file`; feed the
+payload through a redirection, not `$(base64 …)`.
+Regression-test shape: **real** curl + real listener + a fixture >128 KB, so the
+kernel (not a stub) produces the failure. Any pack over ~96 KB trips it.
-```bash
-scripts/build-voice-pack.sh --all /home/dank/voices # everything in VOICE_PACKS
-scripts/build-voice-pack.sh --source SRC --offset SEC --duration SEC --sha256 HEX NAME /home/dank/voices
-scripts/build-voice-pack.sh --dry-run ... # validate + crop, no extractor
-```
+## Trap 2 — two readers of the env file disagree about quotes
-Knobs live in `scripts/voice-packs.env` (one `_SOURCE`/`_OFFSET`/`_DURATION`/
-`_SHA256` set per voice). Three things it enforces that an operator gets wrong:
+`bash set -a; source` strips `"…"`. podman's `--env-file` (how the unit really
+reads it) does **not**. A value interpolated into a JSON body therefore arrives
+quoted and invalidates it. Test both readers, not just `source`.
-1. **The 10-second gate.** A crop starting inside the first 10 s of the source is
- refused before anything is written (project source-material law), and the
- manifest is re-checked against the source's sha256 afterwards.
-2. **The effective crop window is 19.84-25 s, not 18-25 s.** An 18.0 s crop is a
- legal `reference.wav` but yields only 22 × 225 = 4 950 B of `.rvq`, under the
- 5 456 B floor the codec config implies — so the builder says so up front.
-3. **It never deletes.** An existing pack is refused unless `--force`, and
- `--force` moves the old directory to `$PACKS_DIR/.AGENTTRASH/-/`.
+## Trap 3 — locally built images skip rebuilds on a stale hash
-Files must be world-readable (the container runs as uid 10000): `644` files,
-`755` dirs. `mkdir -p /home/dank/voices` as the deploy user if it is absent —
-rootless podman auto-creates a missing bind source **root-owned**, and the
-endpoint then sees no voices at all.
+`build_compose_image` compares a `cfhash` label; it must cover every file the
+`Containerfile` `COPY`s, or an edited `app.py`/hook deploys "successfully" while
+the container runs old code.
-## Register and warm (the part people forget)
+## Measured numbers (2026-10-18, deployed build — retire the trial's ladder)
-The engine keeps its voice registry **in memory**, and the **first synthesis of a
-registered voice decodes the whole reference clip** to snapshot codec state:
-6.2-8.0 s, first audio byte at ~5 530 ms. Later requests restore in 0.5 ms.
-Re-registering does **not** warm — only synthesizing does. So:
+| thing | value |
+|---|---|
+| first synthesis of a freshly registered voice | 1 883 ms |
+| later short requests | 1 226–1 283 ms |
+| ~110-word request (repeat) | 9.4 s / 9.8 s — tracks generated audio, not state |
+| whole boot warm, 3 packs | 15.2 s |
+| engine resident | ~4.0 GiB |
-```bash
-# registers every pack that has a reference.wav, then speaks the warm sentence once
-podman exec frank-tts-engine /app/warm-voices.sh
-systemctl --user --no-pager --full status frank-tts-engine.service | tail -20
-```
+The trial's `6.2–8.0 s cold → 0.5 ms warm` claim does **not** reproduce; warming
+is for registration (registry is memory-only, re-register ≠ warm) plus ~0.6 s.
-The unit's
-`ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh` does
-the same at every boot, so a fresh boot is already warm; the explicit call above
-is for **adding one voice to a running engine** and needs no restart — a restart
-drops every registration and re-pays the cold cost for the whole library. The
-`podman exec` form is not cosmetic: the engine **publishes no port**, so there is
-no host address to curl.
+## Ops notes
-## Proof (do not report "added" without it)
-
-```bash
-curl -s localhost:7772/v1/audio/voices # name is listed
-time curl -s -o /tmp/a.wav localhost:7772/v1/audio/speech \
- -H 'content-type: application/json' \
- -d '{"model":"tts-1","voice":"NAME","input":"Checking the cloned voice.","response_format":"wav"}'
-time curl -s -o /tmp/b.wav localhost:7772/v1/audio/speech \
- -H 'content-type: application/json' \
- -d '{"model":"tts-1","voice":"NAME","input":"And the second request.","response_format":"wav"}'
-file /tmp/a.wav /tmp/b.wav # really audio
-```
-
-The warmer's journal lines carry `voice NAME cold=…ms warm=…ms` — quote that pair.
-Note that streaming and buffered output differ at the same seed (92.2 % of samples,
--41.6 dB RMS): never compare two paths or two "identical" renders, only that each
-is audio and that the second request is fast.
-
-## Failure modes worth naming
-
-- `400 unknown voice …; available voices: …` → the name is not a pack: wrong
- spelling/case, missing `reference.wav`, or `/home/dank/voices` root-owned/empty.
- Trust the 400's list over any manifest.
-- 400 with `(none: no voice pack with a reference.wav in /voices)` → mount problem,
- not a voice problem.
-- First request slow **after** a boot → the warm pass failed; read the unit's
- journal (the warmer exits 0 and logs when there is no voice directory, so
- silence there is not success either).
-- Endpoint answers but synthesis fails 502 → the façade must dial
- `http://frank-tts-engine:8880` over the shared bridge
- (`frank-tts-engine.network`); a `host.containers.internal` / `127.0.0.1:8880`
- upstream can never work between containers, because netavark DNATs published
- ports only.
-
-## Never
-
-Never write under `/voices` from the endpoint (mounted `:ro` by design), never
-`git add` a voice pack, never `rm` a pack (repo policy: move to `.AGENTTRASH/`),
-and never benchmark against this endpoint while it serves — stop and mask the
-units first.
+- `systemctl --user stop` **removes** the quadlet container — `podman start` then
+ finds nothing. To run the engine without its `ExecStartPost` hook, use a
+ separate container, not stop+start.
+- `podman cp` of a fixed script into a running container is a **trial only**: the
+ layer dies with the container. Merge + redeploy to make it durable.
+- Nothing on the voice pair exposes `/metrics`; Prometheus can't scrape it (a
+ blackbox HTTP probe on 7772 is the only honest option).
diff --git a/agent/managed-skills/gitea-api-pr-operations/SKILL.md b/agent/managed-skills/gitea-api-pr-operations/SKILL.md
index 46fdfb5..754bbfa 100644
--- a/agent/managed-skills/gitea-api-pr-operations/SKILL.md
+++ b/agent/managed-skills/gitea-api-pr-operations/SKILL.md
@@ -1,105 +1,31 @@
---
name: gitea-api-pr-operations
-description: "Open, list, merge, and verify pull requests (and diagnose issue-API scope failures) on the Dankclaw Gitea via its REST API from a workstation, keeping the token out of argv (curl -K config file, mode 600) — includes the credential-store extraction one-liner, the merge endpoint's Do-field law and Content-Type-header trap, the suggested-URL-is-not-a-PR gotcha, and the insufficient-token-scope trap."
+description: "Open, list, merge, and diagnose issues/PRs on the Dankclaw Gitea API (192.168.0.143:3000, dank/command-center) with curl + token — merge payload quirk included"
---
-# Gitea API PR operations (Dankclaw)
+# Gitea API PR operations on Dankclaw
-`git push` on this fleet prints `Create a new pull request for ...` — that is a
-**suggestion, not a PR**. Verify (`GET /pulls?state=open` → expect `[]`) before
-claiming a PR "should exist"; create it via the API when asked.
-
-## Endpoint + auth
-- API base: `http://192.168.0.143:3000/api/v1/repos/dank/`
-- Token lives in the git credential store configured by
- `git config --get credential.helper` → `store --file ~/.config/secrets/gitea`,
- line format `http://user:TOKEN@192.168.0.143:3000`.
-- The repo-local `.env` `GITEA_TOKEN` (main checkout, gitignored) carries
- **`read:issue` too** (measured 2026-10-18): filing issues needs THAT token,
- the credential-store one 403s on issues.
-
-Extract the token (the `http://` scheme contains a colon — scheme-stripping
-awk regexes silently grab the port instead; use this anchored sed):
+Verified 2026-10-18 on Gitea at `http://192.168.0.143:3000`, repo `dank/command-center` (owner `dank`).
+## Token handling (never echo)
```bash
tok=$(sed -nE 's#^http://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)
-[ -n "$tok" ] && echo "token-extracted(len=${#tok})" # length check, never echo the value
+cfg=$(mktemp) && chmod 600 "$cfg" && printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"
+# use: curl -sS -K "$cfg" ... then rm -f "$cfg"
```
-## Keep the token out of argv (repo secret law)
-`curl -H "Authorization: token $tok"` is world-readable in `/proc//cmdline`.
-Use a mode-600 config file:
+## Endpoints that work
+- Open PR: `POST /api/v1/repos/dank/command-center/pulls` with JSON `{title, head, base, body}` (build with `jq -n --arg`; `head` = branch name as-is, slashes included).
+- Patch PR (title/body): `PATCH .../pulls/` — works, returns 201 with full PR JSON (`mergeable` field handy).
+- **Merge PR: `POST .../pulls//merge` with body `{"Do":"merge"}`** — capital-D `Do` is this Gitea's field (mirrors web UI form). `{}` or `{"DoNotMerge":false,...}` → 422 `[Do]: Required`. Success may return EMPTY body — verify with a separate `GET .../pulls/` for `{state:"closed", merged:true, merge_commit_sha}`.
+- Issues: `POST .../issues` `{title, body}`; patch `PATCH .../issues/`.
+- Issue/PR bodies MUST end with the AGENTS.md footer: `---` line, then `cwd: · session: `. Embed the footer inside the body string (a bare `---` as a separate shell arg breaks `jq` arg parsing).
+- `xdg-open ` lands the PR/issue in Frank's Firefox — do it the moment a PR opens.
-```bash
-cfg=$(mktemp); chmod 600 "$cfg"
-printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"
-```
+## Gotchas
+- POST/PATCH need `-H 'Content-Type: application/json'` on the curl command line; `-d @file` alone with a wrong content type yields 422 `"model.assignUsers: Bind is required"`.
+- Merge-with-empty-response is normal; never conclude failure from blank stdout — GET the PR.
+- No `tea` CLI anywhere; API + curl is the only path. GITEA_TOKEN env exists in `~/.omp/.env` (gitignored) as alternative source.
-- POST JSON needs a second header line: `header = "Content-Type: application/json"`.
- There IS NO `content-type` config option — curl rejects the file.
-- `rm -f "$cfg"` when done (a mode-600 token file left in /tmp is its own leak).
-
-## Operations
-```bash
-# list open PRs
-curl -sS -K "$cfg" ".../pulls?state=open" | jq -r '.[] | [.number,.head.name,.base.name,.title] | @tsv'
-# create (build the payload as a FILE, see below)
-curl -sS -K "$cfg" -o /tmp/pr.json -w '%{http_code}\n' -X POST -d @/tmp/pr-payload.json ".../pulls"
-# verify: 201 is not enough — check mergeable
-curl -sS -K "$cfg" ".../pulls/1" | jq '{number,state,merged,mergeable,base:.base.ref,head:.head.ref,url:.html_url}'
-```
-
-## Merging: the field is `Do`, and it is a STRING naming the strategy
-
-Measured 2026-10-18 on Gitea at 192.168.0.143:3000 (`POST .../pulls//merge`).
-`MergePullRequestOption` is `{From, To, When, Do}` — **there is no `DoMerge` and
-no `merge_method`**. Every wrong shape is a 422 with a distinct message, so read it:
-
-|body sent|http|message|
-|---|---|---|
-|`{"DoMerge":true,"merge_method":"merge"}`|422|`[Do]: Required`|
-|`{"Do":true,"merge_method":"merge"}`|422|`[]: json: cannot unmarshal JSON boolean into Go string within "/Do"`|
-|`{"Do":"true"}`|422|`[Do]: In…` (validation: not a strategy name)|
-|form body `Do=true&merge_method=merge`|422|`[Do]: Required` (the value must be a strategy name)|
-|`{"Do":"merge"}`|**200**|merged|
-
-```bash
-curl -sS -K "$cfg" -H 'Content-Type: application/json' -X POST -d '{"Do":"merge"}' ".../pulls/4/merge"
-# then prove it: .merged true, .state closed, .merge_commit_sha set, and origin/main moved
-curl -sS ".../pulls/4" | jq -c '{number,state,merged,merge_commit_sha}'
-```
-
-Strategy names: `merge`, `rebase`, `rebase-merge`, `squash`, `manually` — a
-strategy the repo does not allow is rejected too.
-
-**Merge trap (measured 2026-10-18):** on `POST /merge` the `Content-Type` header
-must go on the **command line** (`-H 'Content-Type: application/json'`). Same
-curl that succeeds on `POST /pulls` with Content-Type supplied only via the `-K`
-config file returns **405 with an EMPTY body** on `/merge`. A 405 there is this
-trap (or a base-moved mergeability recheck — GET the PR, see `mergeable:true`,
-then retry with `-H`), NOT a permission problem.
-
-## Building issue/PR bodies without quote carnage
-Hand-written `jq -n --arg t "..." '{title:$t, body:"..."}'` one-liners with long
-markdown bodies WILL break on a stray quote (and the broken quoting can swallow
-the following commands). Build bodies as files (heredoc or write tool), then:
-```bash
-b=$(jq -Rs . body.md)
-jq -n --arg t "$title" --argjson b "$b" '{title:$t, body:$b, head:"", base:"main"}' > payload.json
-curl ... -d @payload.json
-```
-
-## Gotchas measured 2026-10-14 / 2026-10-17 / 2026-10-18
-- Unauthenticated/bad token → HTTP 401 body `{"message":"not found"}`; `jq '.[]|[.number,..]'` then fails with *cannot index "not found"* — that is an auth failure, not a missing repo.
-- **Insufficient token scope ≠ missing data.** The credential-store token
- (measured 2026-10-17) carries only `write:repository,write:user`; the issues
- API answers HTTP 403 `{"message":"token does not have at least one of required
- scope(s), required=[read:issue], token scope=write:repository,write:user"}`.
- jq surfaces that JSON as the *cannot index* error — do NOT conclude the issue
- list is empty, and note POSTs under this token create NOTHING (verify, don't
- assume retry safety). Filing issues needs a fresh token minted in the Gitea
- web UI with `read:issue`+`write:issue` (Frank-class credential action; ask) —
- OR use the main-checkout `.env` `GITEA_TOKEN`, which already has issue scope.
- PR endpoints work fine with either token (`pulls` POST/GET/merge all 200/201).
-- On the created-PR response `.head.name` can be null; verify with the `GET /pulls/` and use `.head.ref`.
-- **Swagger JSON is disabled on this instance** — `/api/swagger.v1.json`, `/api/swagger.json` and `/api/v1/swagger.json` all 404. Do not go looking for the schema there; the 422 messages name the field and its Go type, which is the faster instrument.
+## Post-merge propagation
+The `~/.omp` checkout IS the deployment: `git -C ~/.omp fetch && git merge --ff-only origin/main` (dirty runtime files don't block ff). Never merge a PR at all without Frank's explicit OK.
diff --git a/agent/managed-skills/niri-config-edit-validate/SKILL.md b/agent/managed-skills/niri-config-edit-validate/SKILL.md
index 638a940..c0bfca0 100644
--- a/agent/managed-skills/niri-config-edit-validate/SKILL.md
+++ b/agent/managed-skills/niri-config-edit-validate/SKILL.md
@@ -22,7 +22,7 @@ description: "Locate, edit, validate, and commit niri compositor config on Frank
- Preset list order = cycle order for `Mod+R` (`switch-preset-column-width`); `Mod+Shift+R` cycles backwards. WITH custom preset lists the cycle contains ONLY the configured presets — no implicit full/maximize tail (verified: [0.25, 0.5] cycles period 2). Maximize = separate `Mod+F` `maximize-column`. Built-in defaults without the node: 1/3, 1/2, 2/3.
- WITHOUT `preset-window-heights`, `switch-preset-window-height` FALLS BACK to the width presets — user sees width-fractions as heights; define the node explicitly to stop it.
- Ground truth for node placement: the binary's embedded template — `grep -a -B4 -A8 "preset-column-widths {" $(which niri)`.
-- Window outlines: base geometry (`width`, `off`) lives in `cfg/layout.kdl` `focus-ring`/`border`; COLORS effectively live in `noctalia.kdl` (see include-order law). `border { off }` + focus-ring active = the visible outline.
+- Window outlines: base geometry (`width`, `off`) lives in `cfg/layout.kdl` `focus-ring`/`border`; COLORS effectively live in `noctalia.kdl` (see include-order law). `border { off }` + focus-ring active = the visible outline. Frank's look since 2026-10-18: amber-orange `#ff8c00`, width 4 (`active`+`urgent` in noctalia.kdl; tab-indicator/insert-hint/recent-windows stay red `#f43841` by choice).
## Workflow
1. Edit the live file under `~/.config/niri/cfg/` (symlinked = repo file; except `noctalia.kdl`, edit live then `cp` to mirror).
diff --git a/agent/managed-skills/skills-cli-retroactive-store-wiring/SKILL.md b/agent/managed-skills/skills-cli-retroactive-store-wiring/SKILL.md
new file mode 100644
index 0000000..08a86e9
--- /dev/null
+++ b/agent/managed-skills/skills-cli-retroactive-store-wiring/SKILL.md
@@ -0,0 +1,31 @@
+---
+name: skills-cli-retroactive-store-wiring
+description: "Retroactively register hand-copied skills with the vercel-labs skills CLI (global store + lockfile), wire omp via the agents provider (no repo symlinks), and extract authored overrides without twins"
+---
+
+# Skills CLI retroactive store wiring (omp tiers)
+
+Retrofit provenance for hand-copied skills and wire them for omp on Dank workstations. Executed 2026-10-18 on blankpad; doctrine doc: `omp-docs/tools/vercel-labs-skills/skill-store-wiring.md` (repo).
+
+## Tier law (final design — do NOT reintroduce per-skill symlinks in the repo)
+| Tier | Path | Owner |
+|---|---|---|
+| Store | `~/.agents/skills/` + lockfile `~/.agents/.skill-lock.json` (v3) | `skills` CLI |
+| Authored | `/agent/skills/` real dirs | Frank, branch → PR |
+| Managed | `~/.omp/agent/managed-skills/` | `manage_skill` only |
+
+omp has NO agent id in the skills CLI (79-agent registry in `dist/cli.mjs`; `-a pi` writes `~/.agents/skills` too — pi detector keys on `~/.pi/agent`). omp reads the store DIRECTLY via its `agents` provider (`skills.enableAgentsUser`, default true; check with `read cfg://skills`). Precedence: authored (native, 100) > skillshare > ... > agents provider > managed (5). Identical copies collapse by realpath; differing same-named copies survive namespaced `agents/` with a startup warning — so when authoring an override, REMOVE the store twin (`npx -y skills remove -g`; prunes lockfile too) or `skills update -g` resurrects a twin.
+
+## Procedure (retroactive registration)
+1. List the repo/live skill dirs to register; identify true sources (per-skill — e.g. `find-skills` is `vercel-labs/skills`, not `mattpocock/skills`).
+2. Drift check FIRST, per skill, before anything moves: `diff -rq / ~/.agents/skills/` after canonical install; abort-and-report on drift (byte-identical = clean cutover).
+3. Canonical install (scratch dir with a package.json; global scope): `npx -y skills add / --all -g` (+ per extra source `npx -y skills add vercel-labs/skills --skill find-skills --agent universal -g`). Artifacts: store dirs + `~/.agents/.skill-lock.json`; verify `jq '.skills|length'`.
+4. Authored extraction (only for skills the user will customize): copy dir into repo `agent/skills//`, add an "Authored override" note after frontmatter, then `npx -y skills remove -g` and confirm `jq '.skills|has("")' == false`.
+5. Clear repo copies of store-managed skills (repo records authored overrides ONLY). Live cutover after merge: move old dirs to /tmp snapshot, `git merge --ff-only origin/main` in the live checkout.
+6. `skills ls` from a repo containing `agent/skills` mislabels skills as Eve project skills (path collision) — attribution noise, ignore.
+7. Fresh-box recipe (three commands, no wiring): add `--all -g`, add find-skills, `remove grilling -g`.
+
+## Gotchas
+- Eve/Codex `agents/openai.yaml` files ride along from upstream; omp ignores them — prune from authored dirs (they get swept off disk elsewhere, leaving phantom ` D` in git status).
+- `skills remove` prunes only global scope if `-g` given; check lockfile after.
+- Mid-run doc readers (subagents) may see pre-cutover trees — send them a correction message, don't let stale advice ship in issue bodies.
diff --git a/agent/memories/sharpshooter/wt-20261017-181502-6d1fc1f-1oq2osmoyw03k/architecture.md b/agent/memories/sharpshooter/wt-20261017-181502-6d1fc1f-1oq2osmoyw03k/architecture.md
index a64cefa..3fbe13a 100644
--- a/agent/memories/sharpshooter/wt-20261017-181502-6d1fc1f-1oq2osmoyw03k/architecture.md
+++ b/agent/memories/sharpshooter/wt-20261017-181502-6d1fc1f-1oq2osmoyw03k/architecture.md
@@ -22,7 +22,7 @@
**Quat3rnion/halogen-swift1.5-qwen3.8-flash-next-v2-abliterated** (`.hgn` format)
-- Runtime: halogen-flash-server 0.16.2 (digest-pinned `ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84…`)
+- Runtime: halogen-flash-server 0.16.2 (digest-pinned `ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84…`)
- Format: v2 `.hgn` (flash-server exclusive)
- MTP head: INSIDE checkpoint (`HALOGEN_MTP_HEAD` unset)
- Vision: ON (v2 tower loads beside checkpoint)
@@ -62,7 +62,7 @@ Every deployed, working Podman container MUST have a self-healing, boot-persiste
Unit requirements:
-- **Boot persistence** — `[Install] WantedBy=default.target`, installed to `~/.config/containers/systemd/.container`, enabled in the user manager, with linger on for the deploy user (`loginctl show-user dank -p Linger` → `Linger=yes`).
+- **Boot persistence** — `[Install] WantedBy=default.target`, installed to `~/.config/containers/systemd/.container`, enabled in the user manager, with linger on for the deploy user (`loginctl show-user dank -p Linger` → `Linger=yes`).
- **Self-healing** — `Restart=always` (or `on-failure`) with a short `RestartSec`; quadlet health check where the service has one.
- **Repo provenance** — the `.container` file lives in this repo (`//quadlet/`), is installed by the deployer, and is covered by the quadlet test seam. The host-side unit is generated state; the repo file is the source of truth.
diff --git a/agent/memories/sharpshooter/wt-20261017-181502-6d1fc1f-1oq2osmoyw03k/queue/01a14c93-8870-75d9-9900-9a731559feef/00mveljelz-vh49.json b/agent/memories/sharpshooter/wt-20261017-181502-6d1fc1f-1oq2osmoyw03k/queue/01a14c93-8870-75d9-9900-9a731559feef/00mveljelz-vh49.json
new file mode 100644
index 0000000..d78e538
--- /dev/null
+++ b/agent/memories/sharpshooter/wt-20261017-181502-6d1fc1f-1oq2osmoyw03k/queue/01a14c93-8870-75d9-9900-9a731559feef/00mveljelz-vh49.json
@@ -0,0 +1 @@
+{"v":1,"kind":"product_decision","statement":"merge 15 and deploy","source":"contextual_resolution","evidence":"you can merge 15 and deploy","friction":{"corrective":false,"regression":false,"subtle":false},"sessionId":"01a14c93-8870-75d9-9900-9a731559feef","ts":1792374566327}
\ No newline at end of file
diff --git a/agent/memories/sharpshooter/wt-20261017-181502-6d1fc1f-1oq2osmoyw03k/state.json b/agent/memories/sharpshooter/wt-20261017-181502-6d1fc1f-1oq2osmoyw03k/state.json
index fb012fe..f2e9791 100644
--- a/agent/memories/sharpshooter/wt-20261017-181502-6d1fc1f-1oq2osmoyw03k/state.json
+++ b/agent/memories/sharpshooter/wt-20261017-181502-6d1fc1f-1oq2osmoyw03k/state.json
@@ -1,8 +1,8 @@
{
"v": 1,
- "lastConsolidatedAt": 1792370846394,
+ "lastConsolidatedAt": 1792374446411,
"lastResult": {
- "at": 1792370515419,
+ "at": 1792373460132,
"sessions": 1,
"deltas": 1,
"model": "frank-zealot"
diff --git a/agent/memories/sharpshooter/wt-20261018-170616-7c8a99d-3bf6g32a51amg/product.md b/agent/memories/sharpshooter/wt-20261018-170616-7c8a99d-3bf6g32a51amg/product.md
index 9801d4a..74dcfda 100644
--- a/agent/memories/sharpshooter/wt-20261018-170616-7c8a99d-3bf6g32a51amg/product.md
+++ b/agent/memories/sharpshooter/wt-20261018-170616-7c8a99d-3bf6g32a51amg/product.md
@@ -1,2 +1,3 @@
## top bar
- Preserve top bar customizations; customizations lost or obliterated on subsequent runs
+- Todo list updates as tasks change, not waiting for entire bill of tasks to finish
\ No newline at end of file
diff --git a/agent/memories/sharpshooter/wt-20261018-170616-7c8a99d-3bf6g32a51amg/state.json b/agent/memories/sharpshooter/wt-20261018-170616-7c8a99d-3bf6g32a51amg/state.json
index 8725d35..e3995b7 100644
--- a/agent/memories/sharpshooter/wt-20261018-170616-7c8a99d-3bf6g32a51amg/state.json
+++ b/agent/memories/sharpshooter/wt-20261018-170616-7c8a99d-3bf6g32a51amg/state.json
@@ -1,8 +1,8 @@
{
"v": 1,
- "lastConsolidatedAt": 1792370776395,
+ "lastConsolidatedAt": 1792374676416,
"lastResult": {
- "at": 1792370420509,
+ "at": 1792374324899,
"sessions": 1,
"deltas": 1,
"model": "frank-zealot"
diff --git a/dotfiles/noctalia/.local/state/noctalia/settings.toml b/dotfiles/noctalia/.local/state/noctalia/settings.toml
index 2869baa..9e9181c 100644
--- a/dotfiles/noctalia/.local/state/noctalia/settings.toml
+++ b/dotfiles/noctalia/.local/state/noctalia/settings.toml
@@ -275,13 +275,13 @@ transition = [ "fade" ]
interval_seconds = 1200
[wallpaper.default]
- path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_73qev3.jpg"
+ path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_1kdgpg.jpg"
[wallpaper.last]
- path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_73qev3.jpg"
+ path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_1kdgpg.jpg"
[wallpaper.monitors.DP-1]
- path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_73qev3.jpg"
+ path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_1kdgpg.jpg"
[wallpaper.monitors.DP-2]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg"
@@ -290,7 +290,7 @@ transition = [ "fade" ]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v9e2w3.jpg"
[wallpaper.monitors.eDP-1]
- path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_73qev3.jpg"
+ path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_1kdgpg.jpg"
[weather]
unit = "imperial"