live: heartbeat sync 2026-10-21T17:49:41-07:00 (Refs #11)

This commit is contained in:
Frank committed 2026-10-21 17:49:41 -07:00
1 parent c0d840a8bb
commit 017404aa51
30 files changed
+568 -109

No files matched your search

@@ -0,0 +1,39 @@
---
name: bats-line-scoped-assert-traps
description: "When writing or reviewing bats tests over multi-line command output, catch glued-glob adjacency that hops lines, env-exec of shell functions (127), and file-wise runs that hide full-suite reds."
---
Three traps that produced false-green bats runs; check each when writing or reviewing tests that assert over multi-line `$output`.
## 1. `[[ "$output" == *"a b"* ]]` can hop lines
`$output` is multiline; a glob `*url auth=Bearer key*` matches across newline boundaries, and inserting a new column into a per-line log format silently breaks *old* adjacency pins elsewhere in the file.
Pin per-line facts with a line-scoped grep instead:
```bash
grep -Eq '/audio/speech .*auth=Bearer dk-speech' <<<"$output"
```
`grep` is line-scoped by construction: the assertion can only match within one CALL/log line. When a fixture adds a column to its log lines, grep the test suite for glued globs over those lines first (`grep -n '\[\[ "\$output" == \*' tests/`).
## 2. `run env PATH=... my_shell_function args` is always 127
`env` execs an external binary; bats test-file functions are invisible to it, and `BW01` (127) does not by itself fail the test — a following `[ "$status" -eq 0 ]` does, or worse, nothing checks it.
Inherit and extend PATH around the function call instead:
```bash
export PATH="$WORK/bothbin:$PATH"
DV_VAR=x my_function scenario
```
If a restricted PATH is truly required, write an executable wrapper script into that bin (`exec bun "$DRIVER" "$@"` with an absolute interpreter path).
## 3. File-wise green ≠ suite green
A single file (`bats tests/behavior.bats`) can pass while `bats tests/` is red (order-dependent state, shared fixtures, or simply a stale dist pin file in another file). Always run the whole gate — `bats tests/` plus unit runner (`bun test`) — on the exact final tree before opening a PR, and grep the log, never tail it:
```bash
bats tests/ > /tmp/gate.log 2>&1; echo "EXIT $?"; grep "^not ok" /tmp/gate.log; grep -c "^ok" /tmp/gate.log
```
## Verify a red is red for the stated reason
When a freshly-added test fails immediately and inexplicably fast (seconds), suspect harness-level breakage (127, missing exec) rather than product behavior — inspect with a temporary `printf '%s\n' "$output" > /tmp/dbg.txt` dump inside the test, delete it after, and reproduce the actual line format before fixing assertions.
@@ -1,47 +1,48 @@
--- ---
name: browser-screenshot-disk-and-cookies name: browser-screenshot-disk-and-cookies
description: Save omp managed-browser screenshots to real files (tab.screenshot path is silently ignored) and reset the shared Chromium cookie jar between persona captures. description: "Save omp managed-browser screenshots to real files (tab.screenshot path is silently ignored), prove/clear cookie state (clearCookies is domain-scoped), and get embedded-image commits past the secret gate."
--- ---
# Screenshots to disk with the omp `browser` eval helper # omp managed browser from Eval: screenshots, cookies, page reads
## Traps (both cost a full re-capture to discover) Friction-earned 2026-10-21 (cookie-state proof on a local stack), same day (static HTML guide verification — four wasted API calls from signature guesses), re-earned same day on the franksign screenshot-walkthrough campaign (19 embedded PNGs, two cookie traps, one secret-gate false positive).
1. `tab.screenshot({ path })` **ignores `path` silently** — the option is not in
its schema (`{selector, fullPage, silent, annotate, format, quality, ifChanged, threshold}`).
The image attaches inline to the tool result and **nothing is written to disk**;
`ls` shows an empty dir. Inline images may also be dropped from transcript
archives later, so "attached to chat" is not "saved".
2. The shared managed Chromium keeps **cookies across tabs and turns** (profile
persists after `tab.close()`). A second capture script that expects the
anonymous view lands signed-in and `waitForSelector("testid/…")` fails fast.
## Procedure ## Screenshots to real files
Write files via `tab.run`, which exposes the raw Puppeteer `page` — its - `tab.screenshot({ path })` returns the image to the model but the `path` option is **silently ignored** on the managed-browser path — no file appears on disk.
`screenshot({path})` does write: - Working recipe: re-capture raw and decode base64 to disk:
```js
await tab.run(async ({ page }, p) => {
const r = await page.screenshot({ encoding: "base64", type: "png" });
await Bun.write(p, Buffer.from(typeof r === "string" ? r : r.data, "base64"));
}, { args: ["/tmp/x.png"] });
```
This puppeteer variant returns the base64 **string directly**, not `{data}` — handle both or you write `undefined`.
- `computer.window("Firefox").screenshot()` fails (`captureWindow: unavailable` under niri) — don't go that way.
- Downscale before embedding: `magick "$f" -resize 1000x "$f"` keeps dark-theme UI PNGs at 20–80 kB each.
```js ## Cookie state (corrected 2026-10-21 — the old claim was wrong)
const dir = "/tmp/<session-dir>"; - Cookies DO cross `browser.open` calls: the headless browser is one shared process with one jar (a stale persona session from an earlier campaign hijacked an "anonymous" walkthrough tab).
const tab = await browser.open({ name: "caps", url }); - `tab.clearCookies()` is **domain-scoped to the current page origin** — running it on `about:blank` clears nothing and silently leaves the previous session live (a "signer" tab showed the admin's dashboard). Correct sequence: `goto(app-origin)` → `clearCookies()` → `reload()`, then verify with `page.cookies()`, not with page text.
const shot = (file) => - Cookie proof (ground truth):
tab.run(async ({ page }, f) => { await page.screenshot({ path: f }); return f; }, ```js
{ args: [`${dir}/${file}.png`] }); const cs = await page.cookies()
return cs.map(c => `${c.name}=${c.value.slice(0,12)}… dom=${c.domain} exp=${c.expires}`).join("\n")
```
await tab.clearCookies(); // ALWAYS before an anonymous capture ## Committing screenshots: the secret-gate false positive
await tab.goto(url); - Data-URI PNGs in a diff WILL trip shape-based secret gates (`ASIA[A-Z0-9]{16}` etc. appear as random substrings in base64 streams, worse with case-insensitive grep).
await tab.waitForSelector("testid/first-state"); - Prove noise, don't assume: run the gate's own pattern with `grep -oEi` over `git diff --cached` and eyeball the matches (image-byte substrings, not credentials; confirm screenshots show passwords masked).
await shot("1-anonymous"); - Then use the gate's sanctioned override with the evidence in the commit body: `ALLOW_SECRETS=1 git commit -m "... ALLOW_SECRETS: the N hits are ASIA-shape coincidences inside PNG base64 ..."`. Never `--no-verify`.
// ...drive states with direct helpers: click/fill("testid/<id>", ...) ...
await shot("2-next-state");
await tab.close();
```
Verify with `ls -la <dir> && file <dir>/*.png` — do not trust silent success. ## API signatures (guesses cost calls)
- `browser.open({ url, name })` — options object; `browser.open(url)` → TypeError.
- `tab.wait(ms)` does not exist → `await Bun.sleep(ms)`.
- `tab.run(fnOrCode)` executes in an isolated **Node/Puppeteer realm** (`{ tab, page, browser }`, plus `args` as trailing params), NOT the page — `document is not defined` means you wanted `tab.evaluate(exprString)` (page-global expression; no top-level `return`; wrap an IIFE when you need one).
- `tab.text("svg")` etc. are direct page helpers — prefer them over evaluate for plain reads.
- For app UIs with labelled forms, `role/textbox[name="Exact label *"]` beats `label=...`: visible labels often include a required-marker the source selector lacks.
## Notes ## Eval cell output
- Selectors accept `testid/<id>` handler; prefer it over CSS on `[data-testid]`. - `log(...)` inside a JS eval cell can be swallowed (printed nothing across several runs); **`return` from the cell always shows**. Put every value you need to see into the return string.
- `tab.run(fn, { args })`: functions receive `{tab, page, browser, wait, assert}`
first, then each arg; a bare code string runs in an **isolated runtime, not the ## Static page verification loop
page** — `document` is undefined there. Use direct helpers or `tab.evaluate` Serve with `python3 -m http.server <port> -d <dir>` spawned via `Bun.spawn`, open, `tab.evaluate` anchor cross-check (`hrefs` vs `ids`), `img` census (`complete && naturalWidth > 0`), screenshots, `tab.close()`, `proc.kill(9)`. If a rendered label looks wrong in a low-res screenshot, read the real DOM text before "fixing" markup — `&lt;id&gt;` renders as `<id>` and misreads as `</id>` at thumbnail size.
(page-global expression; top-level `return` invalid) for page access.
- Byte-identical PNG sizes between two frames = same view; fine as proof of a
round-trip (state A → B → A).
@@ -1,11 +1,11 @@
--- ---
name: gitea-pr-api-cycle name: gitea-pr-api-cycle
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.\"" description: "\"Open/update/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, merged-branch deletion via the branches endpoint, and post-merge live-checkout fast-forward plus the plugin-package-name upgrade gotcha.\""
--- ---
# Gitea PR/issue cycle — Dankclaw API # Gitea PR/issue cycle — Dankclaw API
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. 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. Re-earned 2026-10-21 on `dank/franksign` #30 (branch-delete endpoint guess cost one 404; docs bullet went stale under the merge).
## Where ## Where
- 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. - 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.
@@ -31,6 +31,7 @@ Friction-earned 2026-10-18 (PRs #15–#20, issue #18), re-earned 2026-10-19 on `
- **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]}`.) - **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**. - 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). - **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).
- A PR whose branch carries already-merged commits plus one new one is fine: open against `main`, the diff is just the new commit — the cheap way to land a follow-up fix from a worktree whose branch history is the merged stack (no checkout/branch gymnastics needed).
## Merge (the 422 trap) — corrected ## Merge (the 422 trap) — corrected
`POST /pulls/N/merge` needs **form-encoded** fields; the merge form field name is lowercase **`do`**: `POST /pulls/N/merge` needs **form-encoded** fields; the merge form field name is lowercase **`do`**:
@@ -69,13 +70,15 @@ All three landed within ~5 s of retrying. Treat `mergeable` as a readiness gate,
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. 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 ## Post-merge landing
- Delete the merged branch via the **branches endpoint**: `DELETE /repos/dank/<repo>/branches/<urlencoded-branch>` (`chore%2Fname`) → **204**. The `refs/heads/…` path answers 404 — that is the wrong endpoint, not missing auth (auth failures also 404 here, so check which you changed last). If you then run `git push origin --delete <branch>` it errors `failed to push some refs` — harmless: the API already deleted it; don't "fix" the error.
- 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. - 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. - 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. - **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. - 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`. - **`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. - **`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. - Prune: `git worktree remove --force <path>` (force: untracked deps link), `git branch -D`, branch delete on the remote (branches endpoint above). 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.
- **Status bullets written pre-merge go stale at merge time.** A HANDOFF/readme line saying "unmerged (open PR)" is wrong the moment you merge — audit those lines as part of the merge turn and land a one-bullet docs PR (see the Create section's merged-stack branch trick for doing that without a fresh worktree).
## Attribution footer (AGENTS.md) ## 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. Every issue/PR body ends with `---`, then `cwd: <session cwd> · session: <session id>`. Read the real values, never invent an id.
@@ -1,47 +1,49 @@
--- ---
name: gitea-token-from-http-credential-store 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." description: Extract the Dankclaw Gitea token from ~/.config/secrets/gitea (a URL-line credential store using http:// and %3a-encoded port) without the empty-token 401; covers the https-only extraction trap and the $$..$$ shell-expansion trap.
--- ---
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: # Gitea token out of the HTTP credential store
`~/.config/secrets/gitea` is a git **credential-store file**, not a bare token file. On the Dank
boxes it reads:
``` ```
http://dank:<40-char-token>@192.168.0.143%3a3000 http://dank:b7caa2f689cdc00841854b85cd0173386f707d86@192.168.0.143%3a3000
``` ```
## Extraction that actually works Three traps, all of which produce a silent **401 Unauthorized** rather than a parse error:
```bash 1. **Scheme is `http://`, not `https://`.** An extraction pattern written `s#^https://…#` matches
tok=$(sed -nE 's#^[a-z]+://[^:]+:([^@]+)@.*#\1#p' ~/.config/secrets/gitea | head -1) nothing and yields an empty token. GNU BRE has no `?` quantifier — use the
echo "tok len ${#tok}" # must be 40 — assert before any call `http[s]*://` form or Python `https?://`:
cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"
curl -s -K "$cfg" http://192.168.0.143:3000/api/v1/<path>
rm -f "$cfg" # in the SAME session: the file holds a live token
```
## `git credential-store --file … fill` answers EMPTY — do not debug it ```bash
TOK=$(sed -n 's#^http[s]*://[^:]*:\([^@]*\)@.*#\1#p' ~/.config/secrets/gitea)
test -n "$TOK" || echo "extraction failed" # never ship an unasserted capture
```
Tried 2026-10-21 while opening `dank/command-center` PR #61: the obvious extraction ```py
tok = re.search(r"https?://[^:]*:([^@]+)@", pathlib.Path.home().joinpath(".config/secrets/gitea").read_text()).group(1)
```
```bash Always assert non-empty (`test -n`) or print `${#TOK}` once: a correct token here is 40 chars,
printf 'protocol=http\nhost=192.168.0.143\nport=3000\n\n' | git credential-store --file ~/.config/secrets/gitea fill and the length check catches a wrong pattern without echoing the secret.
```
prints **nothing**, with no error. Not permissions, 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 because the `&&` hides which link broke. 2. **The token body contains `$$…$$`.** An omp output token inside a *file* is inert, but pasted
literally into a shell script line it expands (`$$` = shell PID). Keep it in a variable or read
it from the file — never inline it in command text.
## The silent-empty-token trap (bit 2026-10-21, cost a debug detour) 3. **Port is percent-encoded (`%3a3000`).** Don't rebuild the API URL from that line; the working
endpoints are `http://192.168.0.143:3000/api/v1/...` and `https://gitea.franksdank.cloud/api/v1/...`
— both serve the same API, so a host/port mismatch looks like an auth failure.
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). Auth header for every call: `Authorization: token $TOK` (the `token` keyword is required; a bare
value 401s too).
- `echo "tok len ${#tok}"` (must be 40) before believing ANY API result — reads included. **Never print the token.** Extract into a variable, then use `$TOK`; read the file with `read
- Prove auth once per session: `GET /api/v1/user` → `"login":"dank"`. path=...:raw` only when you must inspect the format.
- `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).
- `jq -n --arg t … --arg h … --arg b "$(/bin/cat /tmp/pr.md)" '{…}'` — `$(cat file)` gets optimised away in this harness, so use `--rawfile b /tmp/pr.md` instead.
## Other notes Related: `gitea-pr-api-cycle` (POST/PATCH body shapes), `gitea-merge-form-encoded` (merge needs
`do=merge&merge_style=no-ff`, answers 200 with an empty body — verify by fetching the PR, not by
- `git credential fill` fails headless here (`could not read Username … terminal prompts disabled`); read the store file directly instead. reading the response), `gitea-credential-store-url-format`.
- Never print the token: mask when inspecting the file —
`sed -E 's#(://[^:]+:)[^@]+@#\1REDACTED@#' ~/.config/secrets/gitea`
- Merge writes need form-encoded fields, not JSON — see `gitea-pr-api-cycle`.
- 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,26 @@
---
name: handdrawn-svg-diagram-verification
description: "Author legible hand-drawn SVG diagrams for zero-JS static pages (theme vars, shared markers, text-width budgets) and verify each by managed-browser screenshot loop that catches label collisions; includes the browser/eval API traps hit doing it."
---
Use when adding diagrams to a static, zero-JS HTML artifact (guide/report) where mermaid/JS is not allowed, or when verifying hand-drawn SVGs render legibly.
## Authoring rules (what makes SVGs legible)
- Theme colors ONLY via the page's CSS vars: define classes once — `svg rect.box {fill:var(--surface);stroke:var(--border)}`, `svg .mut {fill:var(--muted)}`, arrow paths stroke var(--muted)/var(--accent); status text via inline `style="fill: var(--ok|var(--err)|var(--warn))"` equivalents. Never hardcoded hex.
- Markers once, shared: hidden `<svg width="0" height="0" style="position:absolute">` right after `<body>` holding `<marker id="arrM">` (fill var(--muted)) + one per stroke color used; reference via CSS `marker-end: url(#arrM)`. `orient="auto-start-reverse"`.
- Text budget: chars × 0.535 ≈ px width at font-size 10.5 (0.56 at 11, ~0.72 bold 13-14). Keep every label ≤ box width − 16px; split long labels into 2–3 lines rather than shrink font.
- Structure > decoration: dashed `rect.frame` lanes with a 11px bold lane label ("REQUEST TIME / AUTHORING TIME"); one crossing arrow between lanes max, labeled; lifelines = `class="dash arrow"` + `style="marker-end:none"`.
- Collision suspects, pre-check: labels near box corners/edges, crossing curves over lane labels (flatten curve control points to arc OVER label text, or move label beside the curve's x-range, not under its start), two labels in the same y-band.
## Verification loop (catches what eyes miss in source)
1. Serve: `Bun.spawn(["python3","-m","http.server","8899","-d",dir])`, `await Bun.sleep(500)`.
2. `const tab = await browser.open({ url: "http://127.0.0.1:8899/#anchor" })` — options object, NOT url string.
3. Position per diagram: `goto(url#anchor)` then `tab.evaluate("window.scrollBy(0, N)")`; shot each with `tab.screenshot({path})`. Attached images are the ground truth — read them for label overlaps, border grazes, clipped text.
4. Fix coordinates with `edit`, re-shoot ONLY the touched regions. Expect 2 rounds: first shots expose collisions you designed in.
## Eval/browser traps (all personally hit)
- `tab.wait` does not exist → `await Bun.sleep(ms)`.
- `log()` output from JS eval cells is SWALLOWED — return the value from the cell instead (`return JSON.stringify(x)`).
- `tab.evaluate(...)` can return undefined/nothing on managed hidden tabs without erroring — for content assertions prefer `tab.text("svg")` or parse the file with node; screenshots remain trustworthy.
- `browser.open` errors list the correct signature — read it and fix in one retry.
- Screenshot misreads at small font: a label that *looks* like mangled markup (`</id>`) in a downscaled shot is usually fine in source — settle with `tab.text(selector)` returning `[...]`, don't "fix" correct source.
@@ -0,0 +1,32 @@
---
name: iptables-insert-order-drop-shadows-accepts
description: "Diagnose or prevent a service port that times out beside a LISTENing socket because a deployer's iptables -I sequence landed a blanket DROP above its ACCEPTs; includes the sandboxed fake-iptables red/green bats pattern."
---
# iptables `-I` order trap: blanket DROP shadowing ACCEPTs
## Failure signature
- Client to a port gets `curl: (28) Connection timed out` (SYN swallowed), never refused.
- `ss -ltn` shows the socket LISTENing; the process is fresh and healthy.
- `sudo nft list ruleset | grep <port>` (or `iptables -L INPUT --line-numbers -v`) shows the catch-all `drop` rule ABOVE the specific ACCEPTs, with all counters on the drop and 0 on the accepts.
## Root cause pattern
`iptables -I CHAIN ...` inserts at position 1. A deployer block like:
```
-C/-I ACCEPT -s A ; -C/-I ACCEPT -s B ; -C/-I ACCEPT -s C ; -C/-I DROP
```
ends with DROP at position 1 — above every ACCEPT. Worse: `-C` checks existence, NEVER order, so re-running the block "verifies clean" while the chain stays poisoned.
## Repair (converges on poisoned hosts)
1. Delete the blanket DROP wherever it sits: `iptables -D INPUT ... -j DROP 2>/dev/null || true`
2. Keep/insert the ACCEPTs at the head (`-C` before `-I`).
3. APPEND the DROP (`-A`), so it is last.
Delete-before-append keeps exactly one DROP per run (idempotent).
## Behavioral test pattern (bats)
Text greps cannot see order. Fake the kernel: write a small `iptables` shim to `$BATS_TEST_TMPDIR/bin` backed by an ordered file `$FW_STATE` (`-C`=grep -Fxq, `-I`=prepend, `-A`=append, `-D`=delete or exit 1), prepend it to `PATH`, `eval "$(sed -n '/^<install_fn>()/,/^}/p' script.sh)"`, stub `run_privileged(){ "$@"; }`, `systemctl(){ :; }`, `curl(){ return 0; }`, and **seed the state file with the poisoned order** before running. Assert: exactly one DROP, its line number equals `wc -l`, and every ACCEPT line number is smaller. Prove red by running the same scenario against `git show origin/main:<script>`.
## Misc traps hit while doing this
- zsh: `UID=...` is read-only/arith (renames blow up); a glob that matches nothing aborts the ENTIRE `&&`-less command line, silently skipping earlier `rm`s on that line.
- `echo ===` in a remote zsh command parses as `=cmd` expansion — quote sentinel strings.
- OpenWebUI `create_token` needs cwd `/app/backend` AND `WEBUI_SECRET_KEY` from PID 1's `/proc/1/environ` (`podman exec` sessions don't inherit the app process env).
@@ -0,0 +1,42 @@
---
name: new-service-deploy-gate-census
description: "When a newly added serverAppConfigs fleet service fails to deploy (\"is not deployed on target\" or \"Unit svc.service not found\"), run the full gate census; use pre-flight when adding any new service."
---
Adding a deployable service to serverAppConfigs touches SIX independent gates. PR #69 (blackbox-exporter, 2026-10-21) shipped with three of them missing and failed three separate deploys before all were found. Run this census pre-flight (new service) or as a debug ladder (deploy failure).
## The six gates, in failure order
| # | Gate | File | Failure signature |
|---|------|------|-------------------|
| 1 | client allow-list `valid_service()` | `scripts/deploy.sh` | `Service 'X' is not deployed on <target>.` (client-side, before anything runs) |
| 2 | remote allow-list `service_is_allowed()` | `scripts/deploy-remote.sh` | same message from the remote runner |
| 3 | dispatch case in `deploy_service()` | `scripts/deploy-remote.sh` | falls to catch-all `deploy_compose_service` which HARD-FAILS on dankclaw (no compose since 2026-09-16): `no quadlet handler` |
| 4 | planner entry + `SERVICE_ORDER` | `scripts/deploy-plan.py` | service silently absent from auto-plans (explicit `--service` still reaches gates 1-3) |
| 5 | quadlet unit validity | `<host>/.../quadlet/X.container` | `Failed to restart X.service: Unit X.service not found` — quadlet refuses to GENERATE a unit without `Image=`; also check every key the unit's comments claim (e.g. `Network=host` declared, not just argued) |
| 6 | health gate in the dispatch tail | `deploy-remote.sh` `wait_for_health` | deploy runs but hangs/fails at health |
## Pre-flight commands
```bash
# gates 1-3 in one shot (replace TARGET/SVC):
grep -c "$TARGET:$SVC" scripts/deploy.sh scripts/deploy-remote.sh # both allow-lists > 0
sed -n '/^deploy_service()/,/^}/p' scripts/deploy-remote.sh | grep "$SVC)" # dispatch case
python3 scripts/deploy-plan.py --target "$TARGET" --paths <the-service-paths> # gate 4
grep '^Image=' <host>/<svc>/quadlet/$SVC.container # gate 5 minimum
```
## Test seams that pin the class (exist post-2026-10-21)
- `tests/deployment_flow.bats` "the client and remote allowlists agree per target" — drift only; blind to a service missing from BOTH.
- `tests/deployment_flow.bats` "every gated <target> service is dispatched" — gate 3 (red-proof: plant a gated, undispatched name in a sandbox copy).
- `tests/observability_quadlet.bats` "every observability unit declares an Image" — gate 5.
## Deploy-execution traps (same tranche)
- `deploy.sh` MUST run from a checkout literally on `main` (branch-name gate) with a clean tree. A detached worktree pointed at the `main` ref fails (branch gate / dirty index). The primary checkout `~/Projects/serverAppConfig` is the deploy host; switching it to `main` needs explicit user OK when workspace policy blocks checkout.
- After merging, delete nothing by `git add -A` blind: a stray nested dir (e.g. a failed `git worktree add` stub) lands as an embedded-repo gitlink in the commit; `git rm --cached <dir> && git commit --amend` to excise, move debris to `~/.AGENTTRASH/`.
## Gitea merge invocation (consumed probe turns twice)
`POST /api/v1/repos/<o>/<r>/pulls/<n>/merge` with **JSON** body `{"Do":"merge"}` (capital D) + `Content-Type: application/json`. Form-encoded `Do=merge` 422s `[Do]: Required` on this Gitea despite older lore. Credential: `~/.config/secrets/gitea` holds `http://user:PW@host%3aport` (percent-encoded colon) — use **Basic** auth with `user:PW`; `Authorization: token` 401s. Never echo the password: build the header inside python from the file.
@@ -1,21 +1,21 @@
--- ---
name: niri-monitor-flap-refresh-triage 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." description: "Diagnose niri/amdgpu monitor failures on blankpad — panel dropping, wrong refresh rate, black/flickering output, letterboxed 1080p on an ultrawide — and fix the refresh without reloading the compositor or logging Frank out."
--- ---
# niri monitor triage: flaps, phantom modes, 60 Hz fallback # niri monitor triage: flaps, phantom modes, 60 Hz fallback, stale kernel mode list
Frank's reaction to a broken display is immediate and loud; the fix must never log him out. 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. 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) ## Step 1 — separate the four diseases (do this before touching anything)
```bash ```bash
niri msg outputs # name, mode, position, full mode list per output 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 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? 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 --user -b --no-pager | grep -iE 'disconnecting connector|new connector|Page flip commit failed|DMCUB|falling back to preferred|could not be found'
journalctl -k -b --no-pager | grep -iE 'amdgpu|PM: suspend exit|\*ERROR\*' journalctl -k -b --no-pager | grep -iE 'amdgpu|PM: suspend exit|\*ERROR\*|enabling link|DP_LANE_COUNT_SET|DP_LINK_BW_SET'
``` ```
Reading it: Reading it:
@@ -25,16 +25,25 @@ Reading it:
- `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. - `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. - 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) ## Step 2 — phantom-mode trap AND stale-mode-list trap (niri fails soft, so both look 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. Two different lies, told by two different layers. `output { mode "…" }` with an unavailable refresh does **not** error: niri logs a WARN and drops to `preferred` on every connect → janky 60 Hz ultrawide, blamed on the monitor. Check the requested mode against the panel's real list:
Compare the requested mode against the panel's real list:
```bash ```bash
niri msg outputs | sed -n '/<monitor name>/,/^$/p' | grep -E '^\s+[0-9]+' niri msg outputs | sed -n '/<monitor name>/,/^$/p' | grep -E '^\s+[0-9]+'
``` ```
If the kernel list lacks modes the EDID advertises (e.g. 3440x1440 absent, largest mode 1080p), discriminate parser gap vs stale cache — **compare the kernel's EDID blob with sysfs and decode it**:
```bash
command -v edid-decode # parse the live EDID: edid-decode /sys/class/drm/card1-DP-1/edid
modetest -M amdgpu -c 2>/dev/null | awk '/connected[[:space:]]+DP-1/,0' \
| sed -n '/value:/,/DPMS/p' # the 384-byte blob DRM actually holds
```
- Blob good (all extension blocks present, native DTD visible via `edid-decode`) **and** mode list bad → **stale mode list cached from a corrupted AUX/DDC read during a flapped hotplug**. DRM rebuilds modes only on a clean re-connect, and every subsequent flap re-caches garbage. Software cannot force the rebuild — the fix is a monitor **power cycle** (unplug power ~30 s; needs Frank's hands), then re-apply the mode. Do NOT conclude "kernel can't parse this EDID" and do not edit config for it.
- Blob itself degraded → AUX hardware read failure, same physical remedy, worse prognosis (cable/port).
## Step 3 — apply the mode WITHOUT a config reload (the no-logout path) ## Step 3 — apply the mode WITHOUT a config reload (the no-logout path)
```bash ```bash
@@ -43,7 +52,11 @@ niri msg outputs | grep -A1 '<monitor>' # confirm the new mode
pgrep -c niri # compositor still alive, same PID 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. `niri msg output …` is documented as temporary ("not saved into the config file") — it changes one output and cannot log the session out. **`rc=0` proves nothing**: for a mode outside the kernel's published list niri exits 0 and silently no-ops — always re-read `niri msg outputs` to confirm. Avoid `niri action reload-config` when Frank is working: it reloads his whole config.
## Step 3.5 — flap census beyond the display: who else dies at the same second
Correlate DP flap timestamps against `journalctl -k | grep -E 'usb .*: (new |disconnect)|Synchronize Cache'`. A USB hub chain re-enumerating in lockstep with a DP flap means shared power/ground on the monitor or dock — and it identifies what else brown-outs (keyboard, webcam, USB SSD). `KT USB Audio` (idVendor 12d1 KTMicro) in the tree = the monitor's built-in DAC, i.e. the monitor's USB upstream port is in that hub chain. Kernel `enabling link 2 failed: 15` across *multiple boots* + `core_link_write_dpcd (DP_LANE_COUNT_SET/DP_LINK_BW_SET) failed` = physical layer (cable/dock/monitor PSU), not compositor, not config: prescribe cable swap / other input after a clean-boot power cycle, and say plainly which of the four diseases you did NOT fix.
## Step 4 — persist, and check you edited the file that is actually live ## Step 4 — persist, and check you edited the file that is actually live
@@ -61,4 +74,4 @@ Desk config law (its own header): panels keyed by **EDID identity**, not connect
## Escalate honestly ## 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. 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 four diseases you found and which you did **not** fix.
@@ -1,14 +1,15 @@
--- ---
name: omp-models-yml-runner-api-placement name: omp-models-yml-runner-api-placement
description: "Add or edit a non-chat (tts/image/embedding) model row in ~/.omp/agent/models.yml, wire the speech role, and diagnose a failing speech device without blaming a healthy endpoint" description: "Add or edit a row in ~/.omp/agent/models.yml — non-chat (tts/image/embedding) runners and authenticated/remote providers with command-backed apiKey values — wire the speech role, verify with a poisoned scratch HOME, and diagnose a failing speech device without blaming a healthy endpoint"
--- ---
# Scope # Scope
`~/.omp/agent/models.yml` (or `models.yaml`) on a workstation: adding a **runner** `~/.omp/agent/models.yml` (or `models.yaml`) on a workstation: adding a **runner**
model row (TTS / image / embedding / rerank / video) alongside chat providers, model row (TTS / image / embedding / rerank / video) alongside chat providers,
flipping `config.yml` `modelRoles` at it, and diagnosing a speech device that adding an **authenticated or off-network** chat provider without committing its
refuses to speak. secret, flipping `config.yml` `modelRoles` at it, and diagnosing a speech device
that refuses to speak.
# The landmine # The landmine
@@ -51,6 +52,46 @@ which the runner transport never reads).
Roles live in `config.yml` (`modelRoles`), not in `models.yml`. Kind roles Roles live in `config.yml` (`modelRoles`), not in `models.yml`. Kind roles
(`image`, `web`, `speech`, `dictation`, `judge`) take no `:thinking` suffix. (`image`, `web`, `speech`, `dictation`, `judge`) take no `:thinking` suffix.
# Authenticated / remote providers
Provider schema (`src/config/models-config-schema-bundle.ts`): `baseUrl`,
`apiKey`, `api`, `headers`, `compat`, `authHeader`, `auth`
(`"apiKey" | "none" | "oauth"`), `discovery`, `models`, `modelOverrides`,
`transport`. Defining `models` without `apiKey` and without `auth: none|oauth`
is a validation error, so an authenticated endpoint needs a key *shape* even if
the value comes from outside the file.
**Never put the literal secret in `models.yml`.** `apiKey` (and any `headers`
value) is resolved by `src/config/resolve-config-value.ts`:
- leading `!` → command-backed: `/bin/sh -c "<rest>"`, cwd = project dir,
10 s timeout, stdout **trimmed**, result cached for the process lifetime
(failure backs off 30 s);
- otherwise the string is looked up as an **exact env-var name**; the literal is
the fallback only when no such env key exists.
So the secret lives in a 600 file, and the tracked file stays clean:
```yaml
apiKey: "!cat ~/.config/secrets/openwebui_api_key" # QUOTE it: an unquoted
# leading ! is a YAML tag
```
Laws: a literal `sk-…` in this repo trips the pre-commit shape gate *and* lands
the key in every agent context on every box that pulls; rotation means rewrite
the secret file **then restart the harness** (the `!cmd` value is process-cached,
so a live session keeps serving the old key and a stale session reads exactly
like a revoked one); every workstation needs its own copy of the secret file.
**A row behind a proxy copies its direct-connected twin's `compat` block**, it
does not simplify it. Measured through an OpenAI-compatible gateway on
2026-10-21: SSE forwards `reasoning_content` and `tool_calls` deltas plus final
`usage` under `stream_options.include_usage`; `image_url` content parts reach
the vision tower; `chat_template_kwargs` is forwarded (same prompt:
`enable_thinking` false → 0 reasoning tokens, true → a real block). Dropping
kwargs/effort maps because "it is only a proxy" silently changes thinking
behaviour on that path. Check per gateway, never assume either direction.
# Verify after every edit (never assume the file loaded) # Verify after every edit (never assume the file loaded)
```bash ```bash
@@ -62,6 +103,25 @@ omp models | grep -c 'validation failed' # must be 0
A run that lists only built-ins means your row broke the file — revert before A run that lists only built-ins means your row broke the file — revert before
yielding. yielding.
**Poisoned-scratch-HOME proof** for a remote/credential row (the only test that
shows a success came through the new path and not from a LAN endpoint):
```bash
mkdir -p /tmp/ompx/.omp/agent /tmp/ompx/.config
cp agent/models.yml agent/config.yml /tmp/ompx/.omp/agent/
ln -s ~/.config/secrets /tmp/ompx/.config/secrets # REQUIRED: ~ in a !cmd value
# expands to the scratch $HOME
sed -i 's#http://192\.168\.0\.[0-9]*:[0-9]*/v1#http://127.0.0.1:9/v1#g' /tmp/ompx/.omp/agent/models.yml
HOME=/tmp/ompx omp --model '<provider>/<id>:off' --tools bash --no-session --no-skills \
--no-rules --no-extensions --no-pty --no-lsp --no-title --cwd /tmp -p '<ask for a real tool call>'
```
Then `rm -rf /tmp/ompx` — that tree holds endpoint URLs. Cross-check the server
side before believing the client's echo (gateway access log / container log line
for the request you just made), and confirm the change never hit the live
checkout until you mean it to: `git -C <live checkout> status --porcelain agent/models.yml`
plus a grep for the new provider name.
# Speech transport contract (`openai-speech`) # Speech transport contract (`openai-speech`)
Body is `{model, input, response_format, voice?, speed?}`; `voice` is forwarded Body is `{model, input, response_format, voice?, speed?}`; `voice` is forwarded
@@ -72,13 +132,17 @@ error, not a fallback — and `auth: none` makes a dead endpoint read as
*available*, so point `modelRoles.speech` at such a provider only after it has *available*, so point `modelRoles.speech` at such a provider only after it has
answered a real synthesis. answered a real synthesis.
A gateway's own `/api/v1/audio/speech` route is NOT an equivalent: OpenWebUI
substitutes its own voice enum before forwarding, so a cloned pack name comes
back 400 from the upstream. Keep `modelRoles.speech` on the direct endpoint.
# Three speech-role laws (bundle-read + measured 2026-10-18) # Three speech-role laws (bundle-read + measured 2026-10-18)
1. **No setting carries a voice for `openai-speech`.** The transport builds the 1. **No setting carries a voice for `openai-speech`.** The transport builds the
body with `voice` from the call's `voice_id` and nothing else. body with `voice` from the call's `voice_id` and nothing else.
`tts.localVoice` is the *local Kokoro backend's* key; `speech.voice` is the `tts.localVoice` is the *local Kokoro backend's* key; `speech.voice` is the TUI's
TUI's **speak-aloud** voice, an enum of Kokoro names that never reaches an **speak-aloud** voice, an enum of Kokoro names that never reaches an HTTP
HTTP request (setting it to a pack name breaks the vocalizer and does nothing request (setting it to a pack name breaks the vocalizer and does nothing
for synthesis — read `cfgSpeechVoice` in `dist/cli.js` before believing for synthesis — read `cfgSpeechVoice` in `dist/cli.js` before believing
otherwise). So a voice-less call is the harness's **normal** shape: a otherwise). So a voice-less call is the harness's **normal** shape: a
voice-required endpoint needs a server-side default (fleet answer: voice-required endpoint needs a server-side default (fleet answer:
@@ -0,0 +1,69 @@
---
name: omp-plugin-modal-bats-harness
description: "Drive a real omp-extension settings modal headlessly from bats (ui.custom stub, DV_KEYS key-replay) and ship the bundle with host pi packages external."
---
# Headless modal testing + host-external bundling for omp plugins
Friction-earned 2026-10-21 on `dank/dankvoice` PR #31 (select menu → vendored pi-common settings modal). Three failed driver iterations and one 43 MB bundle before green.
## The build contract (do this first)
A modal imports runtime values (`SelectList`, `matchesKey`, `getSelectListTheme`) from `@oh-my-pi/pi-tui` / `@oh-my-pi/pi-coding-agent`. Bundling the root barrels = 43 MB and trips `--target node` on bun builtins. The plugin host IS bun and hoists every pi package into `~/.omp/plugins/node_modules/@oh-my-pi/` — the same ground pi-common stands on. So:
```
bun build src/index.ts --outdir dist --target bun --format esm \
--external @oh-my-pi/pi-coding-agent --external @oh-my-pi/pi-tui \
--external omp-legacy-pi-modules
```
- `omp-legacy-pi-modules` is a harness-internal virtual module behind a lazy dynamic import in the pi-coding-agent barrel; externalizing is safe, omitting it fails the build.
- Declare the externals in `peerDependencies`.
- The packed-artifact load test must reproduce the hoisted host layout: extract the tarball, then `ln -s <repo>/node_modules/@oh-my-pi <work>/node_modules/@oh-my-pi` before importing the entry — a bare temp dir has nothing to resolve against and the test fails for the wrong reason.
## The driver stub for `ctx.ui.custom`
The modal mounts through `ctx.ui.custom(factory, opts)`; factory signature is `(tui, theme, keybindings, done)`. The bats driver stub:
```ts
custom: (factory: any) => {
customCall += 1;
let settleDone: () => void = () => {};
const closed = new Promise<void>((r) => { settleDone = r; });
// theme: Proxy answering EVERY style method; pass through the LAST arg —
// style calls are (colorName, text); returning args[0] prints color names,
// and a fixed {bg,fg,inverse} trio crashes the frame's theme.bold.
const style = (...a: unknown[]) => String(a[a.length - 1] ?? "");
const theme = new Proxy({} as Record<string, typeof style>, { get: () => style });
const component = factory(
{ requestRender: () => {}, terminal: { rows: 60, cols: 100 } }, // rows feeds list pagination; omit → NaN, nothing scrolls
theme, {}, () => settleDone(),
);
const census = () => { for (const l of component.render?.(100) ?? []) console.log(`ROW ${l}`); };
census();
// keys from DV_KEYS / DV_KEYS<n> per call: up|down|enter|tab|esc|ctrl+u named,
// name*N repeat, anything else typed.
(async () => { /* for each token: beats below; await ~140ms after enter (async
roster fetch), ~20ms otherwise; final ~150ms; settleDone(); */ })();
return closed;
},
```
## Key-replay discipline (each cost a red iteration)
- **Never zero-interval.** A burst of `\x1b[B` bytes in one tick coalesces in SelectList — `down*3` lands one step. Beat ~25 ms between repeats.
- **Type one character at a time.** A whole sentence written to the inline editor in one `handleInput` is swallowed (paste-shaped); iterate `[...text]` with ~10 ms beats.
- **Sleep after every `enter`**, not just fixed small delays: Enter opens submenus whose list contents arrive from an async fetch.
- Row math must be pinned: row order in `settingsFields` is the UI contract; tests reach row N with `down*N`.
## Vendored-modal semantics that bite
- Enum fields with `options.length <= cycleThreshold` (default) CYCLE inline on Enter instead of opening the picker — a curated list must set `cycleThreshold: 0` or an "open picker, down, enter" choreography silently cycles and the stray Enter activates the next row.
- Submenus render inside the body component's `render()` output — one census sees both.
- SelectList initial highlight = current value (`items.findIndex(value)`); choreography counts from there.
## Test layering that held
Component-level pins (field census, commits, audition) against fake deps with bun test; behavior bats drives the REAL bundle through the stub for wiring laws (persistence, byDir, notify strings). Don't duplicate choreographies across layers.
Related managed skills: `gitea-pr-api-cycle` (landing), `plugin-git-install-committed-artifact` (install validation).
@@ -0,0 +1,36 @@
---
name: screenshot-walkthrough-in-static-docs
description: "Embed a real-browser user-flow walkthrough (screenshots as data URIs) into a single-file zero-JS static docs page; includes the selector laws, cookie isolation, and secret-gate proof earned driving franksign's full demo."
---
name: screenshot-walkthrough-in-static-docs
description: "Embed a real-browser user-flow walkthrough (screenshots as data URIs) into a single-file zero-JS static docs page."
# Screenshot walkthroughs in a single-file static guide
Friction-earned 2026-10-21 on `dank/franksign` `guide/index.html` §13: 19 un-retouched captures of every user flow (signup → studio → template → send → sign → freeze → signed PDF → claim shelf → void → 410 door), embedded as data URIs, guide still single file, zero JS.
## Recipe (end to end)
1. **Boot the prod-shaped stack** on a non-collision port (`PORT=3210`; e2e seams squat :3100, demos :3000). Kill it before running any test gate.
2. **Walk flows as distinct identities sequentially** — the managed browser shares ONE cookie jar across tabs. Between roles: `tab.clearCookies()` then `tab.reload()` **on the app's origin, never on `about:blank`** (domain-scoped clear on about:blank clears nothing and the previous identity's session silently survives — you'll screenshot the admin where you meant the signer).
3. Fresh per-checkout demo state is fine; re-running flows with new identities leaves harmless history that actually *helps* later shots (roster with completed + pending + voided rows).
4. **Selectors**: prefer `role/textbox[name="Label *"]` / `role/button[name="Sign"]` / `testid/…`. Forms render required markers INTO the label text (`Email *`) — `label="Email"` misses; `text/…` clicks may silently no-op where `role/button[...]` works. Snapshot once per screen (`tab.ariaSnapshot(undefined,{compact:true,interactive:true})`) instead of guessing.
5. **Screenshot to disk** (path option silently ignored — see `browser-screenshot-disk-and-cookies`): `tab.run(async ({page}, p) => { const r = await page.screenshot({encoding:"base64", type:"png"}); await Bun.write(p, Buffer.from(typeof r === "string" ? r : r.data, "base64")); }, { args: [path] })` — this variant returns a bare string, not `{data}`; handle both.
6. **Shrink before embedding**: `for f in *.png; do magick "$f" -resize 1000x "$f"; done` → 17–83 kB each; 19 shots ≈ 800 kB raw, ~1.1 MB final guide.
7. **Non-browser payoff shots**: rasterize artifacts the flow produces (e.g. signed PDF) with `pdftoppm -png -r 90` + resize — the deterministic-output image is the best single screenshot of a PDF pipeline.
8. **Assembly**: write the section HTML with `__IMG:name__` placeholders via the write tool; a one-shot node script replaces tokens with `data:image/png;base64,…` and splices before `<footer>`. Never paste base64 through the edit tool.
9. Update nav + add a pointer paragraph from the prose-journeys section to the walkthrough section.
## Verify
Serve the dir (`python3 -m http.server`), open, assert via one evaluate: `img` count == captured, `failed == 0` (`complete && naturalWidth>0`), and every `href^="#"` resolves to an id. Screenshot the section top + a mid scroll. Then kill the stack BEFORE the gate.
## Secret gate vs data URIs
A base64 wall trips shape patterns (`AKIA/ASIA[A-Z0-9]{16}` etc.) via case-insensitive grep at law-of-large-numbers rates. Prove noise, don't just override: `git diff --cached | grep -oEi '<patterns>' | sort -u` — hits that are substrings of PNG streams (mixed-case `AkIa…`, random tails) are image bytes. Then use the hook's documented deliberate override (`ALLOW_SECRETS=1 git commit`) and record the proof in the commit body. Confirm no real credential is visible in any shot first (password fields render masked; note demo tokens in captions are dev-DB throwaways).
## Voice/law notes
- Captions must state what the demo fakes (no SMTP → "link travels by copy-paste") and that captures are un-retouched with real demo values.
- The walkthrough routinely surfaces UI laws the prose hid (e.g. Void `disabled={r.state !== "pending"}` while the API accepts completed voids) — write those discoveries into a closing law box, and check the prose section against them.
@@ -0,0 +1,27 @@
---
name: static-page-headless-verify-traps
description: "Verify a static HTML artifact headlessly after writing it: serve on a scratch port, assert structure, screenshot — with the omp JS-eval/browser API traps (browser.open options object, no tab.wait, tab.run is Node-isolated, log() swallowed in JS cells)."
---
# Headless verify of a static HTML file — traps that cost cells
Use after writing/rewriting a self-contained HTML artifact (guide, report, landing page) that must be proven to render before commit.
## Loop
1. Serve: `Bun.spawn(["python3","-m","http.server","<port>","-d","<dir>"])` inside the eval cell; kill at cell end (`proc.kill(9)`), or `pkill -f "http.server <port>"` first if a prior cell died mid-run.
2. Open + assert + screenshot in ONE cell; also cross-check anchors statically with node (regex `href="#x"` vs `id="x"`) — no browser needed for that half.
3. Entity/markup contamination sweep with grep on the FILE (`&quot;`, `&#`, raw `<` inside text): what renders small can hide real damage; conversely a suspicious screenshot is often just low-res misread — settle label text via `tab.text("svg")`/`tab.text(selector)` before "fixing" markup.
## Eval/browser API traps (all hit live 2026-10-21)
- `browser.open({ url })` — options OBJECT; `browser.open(url)` is a TypeError.
- `tab.wait(...)` does NOT exist — use `await Bun.sleep(ms)` or better `tab.waitForSelector/waitForText`.
- `tab.run(fnOrString)` executes in the isolated Node-side runtime: `document` is undefined there. For page-world expressions use `tab.evaluate("...string...")` (expression, no top-level `return`; wrap a function IIFE when you need one) or the direct helpers (`tab.text(sel)`, `tab.count`, `tab.attr`).
- **`log()` output from JS eval cells is silently swallowed** — even after successful browser calls. Extract data by `return`-ing the value from the cell (Python cells log fine; JS cells must return). A cell that "ran but printed nothing" usually means it printed via `log()` — re-run returning the payload.
- Screenshots come back as webp images in the tool result AND save via `screenshot({path})`; image text under ~13px is unreliable to read — verify exact strings via `tab.text()` instead of squinting.
## Static anchor/claim audit before rendering
- Counts, schema/table names, endpoint bodies: re-grep the SOURCE per claim; never trust numbers carried in from conversation memory (this caught "public vs franksign schema", "OR signer_user_id join that doesn't exist", wrong test-counts pre-commit).
- `git diff --stat` regenerated binary fixtures (PDF CreationDate churn) before committing docs — don't commit fixture noise the setup script wrote.
@@ -0,0 +1,32 @@
---
name: tts-synth-timeout-cap-run-triage
description: "Triage a dankvoice \"The operation timed out.\" toast: prove/disprove a qwentts engine cap-run (2048-frame, no EOS) via Dankclaw journals before suspecting the client."
---
# TTS synth-timeout triage
Symptom: omp shows `Error: dankvoice: The operation timed out.` — a real 60 s stall, not a client bug. `SYNTH_TIMEOUT_MS = 60_000` (`src/audio-io.ts`) + the notify at `utterance.ts:181` is the ONLY emitter of that text.
## Known root cause (chronic as of 2026-10)
The pinned `qwentts.cpp tts-server` (frank-tts-engine, Dankclaw, container port 8880) can miss EOS and run to its hard-coded 2048-frame cap: ~164 s of audio from a short sentence, ~90–100 s wall. Measured ~5/day, 68 in 14 days. The engine is a serial FIFO (`QWENTTS_MAX_BATCH=1`), so every narration queued behind a cap-run also times out — the toast comes in bursts. Shim logs `BrokenPipeError` at `wfile.write` (client gone when buffered audio finally ships). No frame-cap/deadline flag exists (`/app/tts-server --help`); the shim can't cancel (engine buffers before writing); `SHIM_TIMEOUT=600` deliberately outwaits it. **Fix belongs in `serverAppConfig` (watchdog restart or upstream feature request), never in dankvoice.**
## Procedure
1. **Endpoint is alive?** `curl -fsS -o /dev/null -w "%{http_code} %{time_total}s\n" -m 10 http://192.168.0.143:7772/v1/audio/voices` and time one synthesis with the session's actual voice (voice may be set per-directory in `~/.omp/cache/dankvoice.json` `byDir` — read that first).
2. **Read the journals — from bash ssh, NOT the `ssh://dankclaw/` device.** The ssh:// device lands as a different user (blank) than the ssh config (dank@192.168.0.143) and sees no units, no sockets — false negatives everywhere:
```bash
ssh -o BatchMode=yes dankclaw 'journalctl --user -u frank-tts --since "-6h" --no-pager | grep -B1 -A20 BrokenPipeError | tail'
ssh -o BatchMode=yes dankclaw 'journalctl --user -u frank-tts-engine --since "-6h" --no-pager | grep -E "Generation done : (2048|[89][0-9]{2}) frames"'
```
Signature = a facade `BrokenPipeError` whose timestamp is ~60 s after an engine `Admit slot=0`, and/or `Generation done : 2048 frames`. Window-correlate with the engine: `--since "HH:MM" --until "HH:MM"` and skip `[Sample]`/`Generated` noise.
3. **Chronicity count:** `journalctl --user -u frank-tts-engine --since "-14 days" --no-pager | grep -c "2048 frames"`.
4. **Verdict:** cap-run confirmed → the client behaved correctly (toast + next utterance speaks). Route the fix to `dank/serverAppConfigs` (`dankclaw/voiceservice/`): host-side watchdog on the journal signature restarting `frank-tts-engine.service`, and/or upstream per-request frame budget.
## Traps
- `systemctl --user` / `journalctl --user` on Dankclaw require the `dank` user — rootless quadlet units live there.
- zsh on Dankclaw eats `===` separators and unquoted URLs as globs: quote args or avoid `echo ===`.
- `tts-server` is not on the exec PATH: use `/app/tts-server`.
- Roster may contain only cloned voices (narrator1-4 retired) — a 400 on a stale voice name is a separate bug; timeouts are the cap-run.
- Elevated RTF right after idle (~2 min gap) is iGPU clock ramp, not contention.
@@ -0,0 +1,46 @@
---
name: worktree-identical-file-tag-collision
description: "Diagnose and repair edit-tool tag collisions when two git worktrees hold byte-identical copies of the same-named file, so edits land in the wrong checkout."
---
## Symptom
You edit `path/to/file.ext` (bare or `~`-relative name) while another worktree under `~/.omp/wt/` holds a byte-identical copy of that file:
- the edit lands in the OTHER worktree (the most-recently-read copy), or
- the tool rejects with "header needs this file's current tag #XXXX" even though you just read the target's #XXXX, or
- after the edit, `grep -c "your marker"` in the intended worktree returns 0.
Root cause: the edit tool keys `[path#tag]` headers partly by hash; identical content ⇒ identical tag ⇒ ambiguous match, resolved to the freshest-read copy.
## Detection (before editing anything)
```bash
grep -c "<distinctive marker you are about to add or just added>" <target-worktree>/<file> # 0 after a "successful" edit = collision
```
Never trust the edit tool's success echo alone when sibling worktrees exist — verify the marker in the TARGET file.
## Repair (stray edit landed in the other worktree)
Restore that copy from its own HEAD — never hand-reverse:
```bash
cd <other-worktree> && git show HEAD:<file> > <file>
```
## Fix the collision, then re-edit
Give the two copies different hashes so tags stop colliding — cheapest is a scratch marker on the NON-target copy:
```bash
cd <other-worktree> && echo "# <id> scratch copy" >> <file>
```
Then re-issue the edit against the target with its original tag, and verify with `grep -c` again. (Editing the stray copy to a different content also works; anything that diverges the hashes.)
## Prevention
- When sibling worktrees exist, `read` the TARGET file by its worktree-scoped path immediately before the edit, and use that exact path in the header.
- Prefer including a distinguishing context line in the edit anchor so a mis-landed edit is rejected instead of applied.
- Same trap bites tests that copy fixtures (`$REPO_ROOT` resolves via the helpers file's location, not `$PWD`): red-proof a seam by copying the whole tests+fixture tree to a temp dir and running bats from there, or by testing the embedded command directly against a mutated fixture copy.
@@ -1,4 +1,4 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792532762570 "lastConsolidatedAt": 1792630061849
} }
@@ -1,6 +1,6 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792439707623, "lastConsolidatedAt": 1792621367932,
"lastResult": { "lastResult": {
"at": 1791599865775, "at": 1791599865775,
"sessions": 1, "sessions": 1,
@@ -1,4 +1,4 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792469747088 "lastConsolidatedAt": 1792627210418
} }
@@ -0,0 +1,15 @@
## Probe Network
- DO NOT ALLOW PROBES TO LEAVE HOME NETWORK
## Device Access
- AUTHORIZED TO ANY HOME NETWORK DEVICES BUT NO OTHERS
- PRESERVE LOCAL API SETTINGS
## Change Policy
- DO NOT ALTER OR MAKE CHANGES TO HOME NETWORK OR DEVICES
## Safety
- DO NOT BREAK ANYTHING
## Recon Method
- PURE DEEP RECON
@@ -1,14 +1,14 @@
## Agent Output Patterns ## Agent Output Patterns
- Typed intro: "Update, [randomly generated epithet]!" - Typed intro: "Update, [randomly generated epithet]!"
- Heckle mode option - Heckle mode option
- Typed outro: "More later, over and out!" - Typed outro: "More later, over and out!"
- Generation frequency control - Generation frequency control
## Documentation Flow ## Model Connectivity
- CONNECT TO MODELS FROM OUTSIDE NETWORK
- Put any created docs in the existing knowledgebase instead of creating a new one ## Documentation Flow
- Put any created docs in the existing knowledgebase
## Log Organization ## Log Organization
- Logs kept explicitly to one file inside the knowledge base - Logs kept explicitly to one file inside the knowledge base
@@ -1,8 +1,8 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792608451914, "lastConsolidatedAt": 1792627495365,
"lastResult": { "lastResult": {
"at": 1792573818108, "at": 1792626257549,
"sessions": 1, "sessions": 1,
"deltas": 2, "deltas": 2,
"model": "frank-zealot" "model": "frank-zealot"
@@ -1,7 +1,5 @@
## Okf Format ## Okf Format
- "final HTML report"
- "okf format"
## Human Readability ## Human Readability
- "human readability" - "human readability"
@@ -1,10 +1,10 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792608445881, "lastConsolidatedAt": 1792627458459,
"lastResult": { "lastResult": {
"at": 1792574016725, "at": 1792625888033,
"sessions": 1, "sessions": 1,
"deltas": 2, "deltas": 1,
"model": "frank-zealot" "model": "frank-zealot"
} }
} }
@@ -3,4 +3,4 @@
## Agent Skills ## Agent Skills
- 16 is a project level agent skill, rather than a strict application thing - 16 is a project level agent skill, rather than a strict application thing
## Design Assumptions ## Design Assumptions
- All users are studios. - All users are studios.
@@ -1,10 +1,10 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792608476331, "lastConsolidatedAt": 1792627453281,
"lastResult": { "lastResult": {
"at": 1792607267681, "at": 1792609389951,
"sessions": 1, "sessions": 1,
"deltas": 7, "deltas": 1,
"model": "frank-zealot" "model": "frank-zealot"
} }
} }
@@ -0,0 +1,2 @@
## Documentation Guides
- Step-by-step guide with platform screenshots for all user flows
@@ -3,5 +3,9 @@
- Compaction applied - Compaction applied
- The suite shouldn't loop through every epithet - The suite shouldn't loop through every epithet
## Operation flow
- The operation timed out
## Testing behavior ## Testing behavior
- Tests play ONE random Epithet - Tests play ONE random Epithet
- Tests must not depend on the external API
@@ -1,8 +1,8 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792608310239, "lastConsolidatedAt": 1792627572197,
"lastResult": { "lastResult": {
"at": 1792607323355, "at": 1792622122061,
"sessions": 1, "sessions": 1,
"deltas": 1, "deltas": 1,
"model": "frank-zealot" "model": "frank-zealot"
@@ -0,0 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792627428845
}
@@ -0,0 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792630063044
}
@@ -1,4 +1,4 @@
config_version = 14 config_version = 15
[audio] [audio]
enable_overdrive = true enable_overdrive = true