Compare commits

..
Author SHA1 Message Date
Frank 332067a516 live: heartbeat sync 2026-10-18T19:52:41-07:00 (Refs #11) 2026-10-18 19:52:41 -07:00
dank 66e3f220e7 Merge pull request 'feat(grilling): omp-aware interview redesign, absorb ask-etiquette law' (#20) from feat/grilling-omp-aware into main 2026-10-18 19:39:00 -07:00
Frank 6134e1168e fix(grilling): plain-mechanics rephrase of the dialog line (Refs #20)
'Too Claudish': drop the aside ('the gotchas no doc confesses') and the
simile ('watches it fall'); lead with the constraint itself. Facts
unchanged: exclusive dialog, scouts before ask, Esc ends the run, the
ledger survives.
2026-10-18 19:31:32 -07:00
Frank c379d16858 fix(grilling): correct ask-tool acceptance semantics (Refs #20)
The ask tool's recommended flag only labels option 0; the reply always
names the selected options, so 'the user's single Enter accepts it'
overclaimed a default-accept mechanic that does not exist. Stated
truthfully: the label invites one-glance acceptance, the reply is
self-documenting.
2026-10-18 19:25:44 -07:00
Frank ca8b46241e fix(grilling): round-two review notes (Refs #20)
- opening restores 'shared understanding' alongside 'nothing is
  silently assumed', and 'Map this as a design tree'
- cluster rationale restated: tight clusters keep each scout aware of
  possible adjacencies
- web_search: explicit 'do not delegate to a subagent'
- 'Done' heading -> 'Definition of Done'
2026-10-18 19:24:51 -07:00
Frank 3fa0242f56 style(grilling): rewrite in the mattpocock corpus voice (Refs #20)
Read all 36 upstream skills, then rewrote to their house style per
writing-for-agents' own levers: leading words as tokens (frontier,
brief, ledger, artefact, scout), vocabulary-first and used exactly,
positive phrasing with prohibitions kept only as hard guardrails,
completion criteria sharpened to checkable+exhaustive ('counted before
sending', 'frontier empty and nothing sits blocked'), zero history,
gotchas the environment cannot confess kept inline. All settled
behaviour from the grilling-on-grilling session survives intact.
2026-10-18 19:15:14 -07:00
Frank fe039a0b73 fix(grilling): strip history and box-specific prose per review (Refs #20)
- drop provenance blockquote and the absorbed-law attribution: the skill
  is instructions, not a history
- 'concurrency is capped' replaces the 3-scout figure of one machine
- completion distillate: project's knowledge base or a log markdown file,
  not a command-center-specific path (skill travels outside this repo)
2026-10-18 19:09:04 -07:00
dank d4092f2f65 Merge pull request 'docs(agent): rule 6 - live todo tracking' (#19) from feat/todo-live-tracking into main 2026-10-18 19:07:56 -07:00
Frank cedef97c99 feat(grilling): omp-aware interview redesign, absorb ask-etiquette law
Rewritten per grilling-on-grilling (2026-10-18, all branches settled via
ask rounds): ask tool is the default round vehicle (one artefact per
question, recommended:0 = bare-yes semantics, hybrid boundary ask-default
/ prose-exception); todo as visible decision tree (phase=branch, blocked
reasons) + durable local://grilling-state.md ledger surviving the
Esc-aborts-the-run path; scout=environment facts (spawn-before-ask,
exclusive concurrency), web_search=world facts, inline=cheap lookups;
completion gate = summary + one closing ask, then distilled decisions
land in omp-docs/log.md via branch -> PR.

Absorbs managed skill one-decision-per-ask-question (Frank's choice;
reworded: ideas heavily encouraged via own atomic question, ban is on
smuggling into option text only). Managed skill deleted here; heartbeat
drops it from the live tree.
2026-10-18 18:58:23 -07:00
Frank 0ee08bc368 live: heartbeat sync 2026-10-18T18:51:41-07:00 (Refs #11) 2026-10-18 18:51:41 -07:00
19 changed files with 434 additions and 268 deletions

No files matched your search

+5 -1
View File
@@ -13,7 +13,7 @@ modelRoles:
default: llamacpp (dankserver)/frank-archon:medium
smol: llamacpp (dankclaw-3090)/frank-zealot:medium
web: web/searxng
speech: local/kokoro
speech: frank-tts (dankclaw)/frank-tts
judge: openrouter/~typesafe/jev-latest:auto
dictation: local/parakeet-tdt-0.6b-v3
memory: llamacpp (dankclaw-3090)/frank-zealot:medium
@@ -206,6 +206,10 @@ speech:
enabled: true
mode: yield
enhanced: true
# Kokoro voice for SPEAK-ALOUD only — the enum is Kokoro's, and this block never
# reaches an HTTP request. The tts tool forwards `voice_id` and nothing else, so
# a frank-tts pack name here would break the vocalizer and still leave a
# synthesis call voice-less. Say `voice_id: "narrator1"` in the call.
voice: am_fenrir
secrets:
enabled: true
@@ -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 <base> 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 <image> --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 <tag>`,
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.
@@ -0,0 +1,30 @@
---
name: corpus-voice-skill-rewrite
description: "Rewrite a skill to emulate an author corpus's voice: full read, style-DNA distillation from writing-for-agents levers, positive-phrasing pass, sharpened completion criteria"
---
# Corpus-voice skill rewrite
Rewrite a skill so it reads as if a chosen author wrote it. Done for `grilling` against the `mattpocock/skills` corpus (2026-10-18, command-center PR #20); behaviour preserved, voice swapped.
## Where the corpus lives
Installed third-party skills: `~/.agents/skills/<name>/SKILL.md` (the skills-CLI global store; `~/.agents/.skill-lock.json` names sources). Whole mattpocock corpus ≈ 2,800 lines — small enough to read inline in ~4 `cat` batches via bash; do NOT delegate the reading, style lives in the exact prose.
Read the author's own meta-skill FIRST if one exists (`writing-for-agents` for mattpocock — it states his levers explicitly), then the corpus practicing them.
## Style DNA checklist (mattpocock; generalise per author)
- **Leading words**: compact pretrained concepts repeated as tokens, never sentences (_red_, _seam_, _fog of war_, _brief_, _frontier_). Define each once, reuse everywhere. Prefer words already in the author's other skills so the corpus shares one language.
- **Vocabulary-first**: bolded glossary terms, then used exactly; explicit "_Avoid_: component, service".
- **Positive phrasing**: prohibitions rewritten as target behaviours; keep a prohibition only when no positive form exists (hard guardrails).
- **Sharpened completion criteria**: fuzzy bounds ("understanding reached") → checkable + exhaustive ("frontier empty and nothing sits blocked", "one artefact per question, counted before sending").
- **Zero history**: no provenance, attribution, or absorbed-from lineage in the body.
- **No-op hunting**: delete harness defaults and anything the environment already confesses (formats, rendering, caps). Gotchas the environment CANNOT confess (exclusive dialogs, Esc-abort semantics) stay inline — they're the cache worth keeping.
## Procedure
1. Read entire corpus; tabulate levers with per-skill evidence before writing.
2. Rewrite preserving settled behaviour 1:1 — voice only; diff the two versions for semantic drift.
3. Audit pass: grep the new text for history words (extracted/absorbed/from/upstream), machine-specific constants, repo-specific paths.
4. Run repo checks, commit as `style(<scope>)`, note the lever→instance mapping in the PR comment so the reviewer can grade the emulation.
@@ -1,125 +1,81 @@
---
name: frank-tts-add-cloned-voice
description: "Add or repair a cloned voice on the dank fleet: /home/dank/voices/<name> 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/<name>/` 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/<name>/` 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/<voice>-<UTC>/`.
## 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).
@@ -1,105 +1,64 @@
---
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, repo dank/command-center) with the token stored in ~/.config/secrets/gitea — incl. the required merge payload and where review comments actually live on 1.27.3."
---
# Gitea API PR operations (Dankclaw)
# Gitea API PR operations (Dankclaw `dank/command-center`)
`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.
## Where
- Token: `~/.config/secrets/gitea` (`http://user:TOKEN@192.168.0.143` single line).
Extract WITHOUT echoing:
`tok=$(sed -nE 's#^http://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)`
- Build a 600-perm temp curl config and use `curl -K`:
`cfg=$(mktemp) && chmod 600 "$cfg" && printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"`
Delete `$cfg` after use. NEVER pass `-H "Authorization: token $tok"` (the literal
header lands in transcript + logs). NEVER echo the token.
- Base URL: `http://192.168.0.143:3000/api/v1/repos/dank/command-center`.
## Endpoint + auth
- API base: `http://192.168.0.143:3000/api/v1/repos/dank/<repo>`
- 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.
## Recipes (all verified live 2026-10-18)
- **Open PR** (token only needed for private repos; this one reads publicly but
POST needs auth): `POST /pulls` JSON `{title, head, base:"main", body}`.
- **PATCH PR title/body**: `PATCH /pulls/<n>` with JSON body `{title}` and/or
`{body}`. Returns 201 with the full PR. Use `jq -n --arg` to build JSON; for
multi-line bodies write the text to a temp file and `--arg b "$(cat b.txt)"`.
(jq CLI: a bare `---` argument after the program is parsed as a flag → error;
fold footers into the --arg string instead.)
- **List open PRs**: `GET /pulls?state=open` → `jq -r '.[] | "#\(.number) \(.head.ref) \(.title)"'`.
- **Read PR**: `GET /pulls/<n>` (no token; public repo) → `jq -c '{state,merged,mergeable,head:.head.ref}'`.
- **MERGE**: `POST /pulls/<n>/merge` with `-H 'Content-Type: application/json'`
and body **`{"Do":"merge"}`** — this build REQUIRES the `Do` field:
`{}` or `{"DoNotMerge":false,...}` → 422; `{"Do":"merge"}` → 200 +
`{"state":"closed","merged":true,"merge_commit_sha":"..."}`. Verify with a
plain GET /pulls/<n> afterwards (merge endpoint's own stdout may not parse).
Merge is the `--no-ff` merge commit on main; then `git -C ~/.omp fetch && git
merge --ff-only origin/main` propagates the live deployment.
- **Issues**: same shape at `/issues` (`POST {title, body}`), `/issues/<n>/comments`
(`POST {body}`, `PATCH .../comments/<id>`) — both work with the temp-cfg auth.
Extract the token (the `http://` scheme contains a colon — scheme-stripping
awk regexes silently grab the port instead; use this anchored sed):
## Review comments: the API lies on 1.27.3 (learned 2026-10-18)
- `GET /pulls/<n>/comments` (inline review comments) → **404 `not found` on every
route** (token or not, `?pull_request_id=` or not) even when a review with
comments exists.
- `GET /pulls/<n>/reviews` works (shows `state`, `comments_count`) but bodies are empty.
- **The comments are readable from the PR HTML**:
`curl -sS http://192.168.0.143:3000/dank/command-center/pulls/<n> -o /tmp/pr.html`
then extract `<div class="content comment-content">` blocks
(python: `re.finditer(r'comment-content', src)` → widen ±800 chars, strip tags).
Duplicates appear (suggested-change UI shows text twice); dedupe mentally.
- Per-conversation resolve API also 404s — flag resolutions in an issue comment instead.
```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
```
## Body-footer law (AGENTS.md)
Every issue/PR body ends with `---` line then `cwd: <wd> · session: <id>`. Keep
it inside the --arg string (not a `---` CLI arg).
## Keep the token out of argv (repo secret law)
`curl -H "Authorization: token $tok"` is world-readable in `/proc/<pid>/cmdline`.
Use a mode-600 config file:
```bash
cfg=$(mktemp); chmod 600 "$cfg"
printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"
```
- 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/<n>/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:"<branch>", 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/<n>` 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.
## Gotchas
- Content-Type: POSTs with `-d @file.json` MUST carry
`-H 'Content-Type: application/json'` on the command line. `-H` entries put in
the curl config file are ignored for this (header ordering quirk).
- `xdg-open <pr-url>` lands the PR in Frank's Firefox the moment it is opened —
law, not courtesy.
- `jq: cannot index "not found" with "user"` = endpoint 404'd into an error
object; check `-w '%{http_code}'` before blaming jq.
- The token in `.git/config`'s remote URL is the same secret; the credential
file (`~/.config/secrets/gitea`) is the one git actually consults (global
credential.helper) and the one to reuse for API calls.
@@ -0,0 +1,29 @@
---
name: gitea-pr-api-cycle
description: "Open/update/merge PRs and issues on the Dankclaw Gitea API (192.168.0.143:3000, dank/command-center) with the token-safe curl+jq sequence, including the {\"Do\":\"merge\"} merge payload and post-merge live-checkout fast-forward."
---
# Gitea PR/issue cycle — dank/command-center
Friction-earned 2026-10-18 across PRs #15–#20 and issue #18: three separate fumbles (jq trailer flag, 422 merge payload, silent -o/-w swallowing) codified once.
## Where
- API base: `http://192.168.0.143:3000/api/v1/repos/dank/command-center/{pulls,issues}`
- Token: never inline, never echoed. Extract from the git credential file into a 600-perm temp curl config:
`tok=$(sed -nE 's#^http://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)`
`cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"` → `curl -K "$cfg"`; `rm -f "$cfg"` when done.
- Unauthenticated GET works for state reads (`GET /pulls/N` → `jq -c '{state,merged,merge_commit_sha}'`).
## Body construction (the two traps)
- Build JSON with `jq -n --arg t <title> --arg b "$(cat b.txt)" '{title:$t, head:<branch>, base:"main", body:$b}'`; write the body to a temp file first (`printf '%s' … > b.txt`). NEVER pass `---` as a bare jq argument — jq parses it as a flag (`unknown flag: ---`). The AGENTS.md attribution trailer (`---` + `cwd: … · session: …`) goes INSIDE the body string.
- Always capture status explicitly: `-o r.json -w '%{http_code}'` and print it; a bare `-o` hides failures (a 422 looked like success-with-no-output once).
## Merge (the 422 trap)
`POST /pulls/N/merge` requires body `{"Do":"merge"}` — `Do` is the Required field on this Gitea. `{}`, `{"DoNotMerge":false}`, `{"merge_style":…}` all return 422 (`"[Do]: Required"`). Success may return an EMPTY body — verify by GET afterwards (`state=closed, merged=true`), not by the POST response.
- Merge only with Frank's explicit per-PR OK (repo law).
- Post-merge the live deployment must follow: `git -C ~/.omp fetch -q origin && git -C ~/.omp merge --ff-only origin/main` (the checkout IS the deployment; branch is `main`).
## PR etiquette per AGENTS.md
- Immediately after opening: `xdg-open <PR-URL>` lands it in Frank's Firefox.
- Redesign mid-review: amend + `git push -f` the branch, then PATCH the PR body (`/pulls/N`, `{"body":…}`) so title/body match the net diff — verify `{number,state,mergeable}` in the response.
- Issue creation identical shape: `POST /issues` with `{title, body}`, expect 201; PATCH same endpoint `/issues/N` to fix bodies retroactively, then GET-readback verify.
@@ -0,0 +1,42 @@
---
name: gitea-review-comment-html-scrape
description: "Read inline review comments on the Dankclaw Gitea when pulls/N/comments 404s (Gitea 1.27.3): scrape comment-content blocks and diff anchors from the PR HTML page"
---
# Gitea review-comment extraction when the API 404s
On the Dankclaw Gitea (192.168.0.143:3000, Gitea 1.27.3) the review-comment API is dead for inline comments:
`/api/v1/repos/{owner}/{repo}/pulls/{N}/comments` returns 404 `not found` **even with a valid token**, and `pulls/comments?pull_id=` / `?pull_request_id=` variants 404 too. `/pulls/{N}/reviews` works but its bodies are empty for inline reviews (read `comments_count` for how many exist); `/issues/{N}/comments` shows only conversation comments, never inline ones.
## Procedure
1. Confirm the review exists and how many inline comments it holds:
`curl -sS http://192.168.0.143:3000/api/v1/repos/dank/command-center/pulls/{N}/reviews | jq -c '.[] | {id, state, comments_count}'`
2. Scrape the PR page instead (no token needed for a public repo):
`curl -sS http://192.168.0.143:3000/dank/command-center/pulls/{N} -o /tmp/pr.html`
3. Extract each comment's text — every inline comment renders a `comment-content` block; dedupe (each comment appears twice in the HTML):
```python
import re, html
src = open('/tmp/pr.html').read()
seen = set()
for m in re.finditer(r'comment-content', src):
t = html.unescape(re.sub(r'<[^>]+>', ' ', src[m.start()-100:m.start()+800]))
t = re.sub(r'\s+', ' ', t).strip()[:400]
if t not in seen:
seen.add(t); print('>>', t)
```
The comment text appears twice back-to-back inside each block ("text text") — read it once. Blocks start after `Copy Link|Copy Source`; ignore those chrome words.
4. **Locate the file/line anchor** (comment-content blocks carry no `data-path`/`data-line` attrs on this build): search the raw HTML for the comment string, walk *backward* ~4–7 KB, strip tags; the nearby `agent/...` path, `@@ -a,b +c,d @@` hunk header, and surrounding diff context lines identify the anchored file and the quoted source line.
## Replying / housekeeping
- Per-conversation resolve API does not exist on this build (404s) — reply to the round as a normal issue comment (`POST /repos/{o}/{r}/issues/{N}/comments`, token via `curl -K` temp cfg, `header = "Authorization: token …"`, 600 perms, `jq -n --arg b "$(cat b.txt)" '{body:$b}'`) listing which note got which fix commit.
- Edit a posted comment: `PATCH /repos/{o}/{r}/issues/{N}/comments/{id}` with `{"body": ...}` — use to fix your own typos rather than double-posting.
- Token source on this box: `~/.config/secrets/gitea` (grep the remote-URL pattern); never echo it.
## Why this works
The PR page is server-rendered with full inline comment bodies; Gitea's "Copy Source" UI element guarantees the raw text is present pre-markdown-render.
@@ -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).
@@ -1,12 +1,14 @@
---
name: omp-models-yml-runner-api-placement
description: Add or edit a non-chat (tts/image/embedding) model row in ~/.omp/agent/models.yml without disabling every custom provider
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"
---
# Scope
`~/.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
refuses to speak.
# The landmine
@@ -63,9 +65,52 @@ yielding.
# Speech transport contract (`openai-speech`)
Body is `{model, input, response_format, voice?, speed?}`; `voice` is forwarded
only when explicitly set; `sample_rate`/`bit_rate` **throw**
**only** when the caller passed `voice_id`; `sample_rate`/`bit_rate` **throw**
(`openai-speech does not support sampleRate`). Codec comes from the
`output_path` suffix; 15 000-character cap. An unknown `voice` name is a server
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
answered a real synthesis.
# Three speech-role laws (bundle-read + measured 2026-10-18)
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.
`tts.localVoice` is the *local Kokoro backend's* key; `speech.voice` is the
TUI's **speak-aloud** voice, an enum of Kokoro names that never reaches an
HTTP request (setting it to a pack name breaks the vocalizer and does nothing
for synthesis — read `cfgSpeechVoice` in `dist/cli.js` before believing
otherwise). So a voice-less call is the harness's **normal** shape: a
voice-required endpoint needs a server-side default (fleet answer:
`DEFAULT_VOICE` in `scripts/frank-tts.env`, journal marks the line
`(default)`, unknown names still 400 with the list).
2. **An explicit role has no fallback candidate.** `modelRoles.speech` set to one
model yields exactly one candidate, so a dead endpoint or an unresolvable
selector fails with *"No available speech model matches the speech role"*
instead of degrading to Kokoro. Loud-by-design; do not "fix" it by adding a
fallback in the client.
3. **`models.yml` loads at process start; `config.yml` is read live.** A session
older than the new row fails exactly like a dead endpoint while a fresh
process succeeds in the same minute.
# Diagnosing "speech device won't speak" — in this order
1. `curl` the endpoint directly with a real voice name. 200 + audio ⇒ the service
is fine and the fault is harness-side; stop touching the server.
2. Control test in the *same* session: point `modelRoles.speech` at
`local/kokoro` and call the device. It works ⇒ `config.yml` is live-read and
the runner selector or its voice argument is the problem, not the endpoint.
Revert the role immediately after.
3. Distinguish stale registry from dead endpoint with a fresh process:
`omp -p --no-session --no-extensions --no-skills --no-rules --tools tts --cwd /tmp
'Call the tts tool exactly once with text="…" output_path=/tmp/x.wav
voice_id=<name> — then reply with the tool result line.'`
Succeeds ⇒ the long-lived session predates the `models.yml` row; restart it.
4. Cross-check server-side, never trust the client's echo: the façade/access log
line and the engine's own ledger must carry the same byte count the tool
printed. (`model=…` in a tool result is what the client *requested*; a server
line is the fact.)
5. For harness-behaviour claims, read the bundle rather than reasoning from the
tool description: `/home/blank/.bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js`,
e.g. `grep -o -E '.{300}response_format:[a-zA-Z$]+.{250}'` for the speech body
builder, `grep -o -E 'cfgSpeechVoice:.{0,400}'` for what a settings key feeds.
@@ -1,27 +0,0 @@
---
name: one-decision-per-ask-question
description: "Use when putting decisions to Frank via the ask tool or a grilling round — exactly one work item per question, rounds may group many single-item questions."
---
# One Decision, One Work Item, One Question
Friction-earned rule (corrected twice on 2026-10-18, during the voice/TTS planning).
## Rules
- Each question addresses **exactly one work item**. A round may contain many questions covering different work items; a question may not.
- Never merge two artefacts into one option because they share an implementation dependency. Sharing an endpoint, a service, or a build step is NOT grounds for one question. (`frank-tts` serving both a podcast and an audiobook generator produced exactly this bug.)
- Never offer something Frank did not ask for inside another question's option text. If an idea is mine, it gets its own question or stays out of the plan; it never rides along as "…and also X" in an option label or preview.
- Do not derive option wording from my own scaffolding (todo text, my table row labels, my runbook headings). Derive it from his stated goal.
- Mark the recommended option; a bare "yes" must accept it.
- Facts are mine to fetch (subagent/scout), never a question for him. Only decisions go to him.
- A question whose answer depends on an unsettled fact or another open question belongs to a **later round**, not this one.
## Anti-patterns that triggered this
- One option: "Audiobook/podcast factory — open-notebook + EPUB→narration builder." Two products, one shared dependency, zero requests for the second one. He answered "I never asked for audiobook generation."
- One question: "Is the deliverable the podcast, or the platform (harness voice + OpenWebUI + alerts)?" Four work items in one question. Rejected: "You're lumping multiple asks into one question. Ask me per item."
## Check before sending
For each question: can I name the single artefact it decides? If two names come to mind, split it.
@@ -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 | `<repo>/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/<name>` with a startup warning — so when authoring an override, REMOVE the store twin (`npx -y skills remove <name> -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 <live>/<name> ~/.agents/skills/<name>` 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 <owner>/<repo> --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/<name>/`, add an "Authored override" note after frontmatter, then `npx -y skills remove <name> -g` and confirm `jq '.skills|has("<name>")' == 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.
@@ -1,4 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792359739617
"lastConsolidatedAt": 1792378334862
}
@@ -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/<name>.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/<name>.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 (`<host>/<svc>/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.
@@ -115,4 +115,4 @@ This rule exists because subagent hangs were observed in early deployments (2026
Before declaring a subagent deployed: verify the heartbeat endpoint returns within the timeout window, and confirm the recovery path is wired (retry/fallback/escalation logic exists in the codepath).
A subagent without a heartbeat or recovery path is a gap: wire the heartbeat, add the recovery path, then deploy.
A subagent without a heartbeat or recovery path is a gap: wire the heartbeat, add the recovery path, then deploy.
@@ -1,8 +1,8 @@
{
"v": 1,
"lastConsolidatedAt": 1792370846394,
"lastConsolidatedAt": 1792378149820,
"lastResult": {
"at": 1792370515419,
"at": 1792374778678,
"sessions": 1,
"deltas": 1,
"model": "frank-zealot"
@@ -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
@@ -1,8 +1,8 @@
{
"v": 1,
"lastConsolidatedAt": 1792370776395,
"lastConsolidatedAt": 1792378276529,
"lastResult": {
"at": 1792370420509,
"at": 1792374324899,
"sessions": 1,
"deltas": 1,
"model": "frank-zealot"
+13 -8
View File
@@ -269,14 +269,19 @@ providers:
# when explicitly set, and sampleRate/bitRate are REJECTED outright (the
# transport throws "openai-speech does not support sampleRate"), so the
# codec comes from the output_path suffix and the only tunable that matters
# is the voice name — which must be a real pack name (narrator1/2/3); an
# unknown name is an engine error, not a fallback.
# DELIBERATELY NOT WIRED YET: config.yml `modelRoles.speech` still points at
# local/kokoro. Flip it to "frank-tts (dankclaw)/frank-tts" only after the
# service is deployed and has answered a real synthesis, because `auth: none`
# makes the provider read as AVAILABLE even while the container is down — the
# role would then fail instead of falling back to Kokoro. The same flip must
# re-key `speech.voice` (now am_fenrir, a Kokoro voice) to a pack name.
# is the voice name — and it comes from ONE place: the tool's `voice_id`
# argument. No setting feeds it (`speech.voice` is the speak-aloud Kokoro enum,
# `tts.localVoice` is the local backend's), so a call that omits voice_id arrives
# at 7772 with no `voice` key and the endpoint answers 400 "unknown voice None;
# available voices: narrator1, narrator2, narrator3". Say the voice in the call.
# WIRED 2026-10-18: `config.yml` `modelRoles.speech` is this row, flipped only
# after the service was deployed and answered real synthesis (two packs,
# different bytes, 24 kHz PCM16, RTF 0.57). Why the gate existed: `auth: none`
# makes this provider read as AVAILABLE even while the container is down, and an
# explicit speech role has NO fallback candidate, so a dead 7772 fails the device
# loudly instead of quietly narrating in Kokoro. Reverting is ONE key:
# `modelRoles.speech: local/kokoro`. `speech.voice` stays a Kokoro name either
# way — it was never this endpoint's voice.
# SCHEMA LAW, learned the hard way (2026-10-18): provider-level `api` must be
# a CHAT transport — the models.yml schema accepts only the chat/judge APIs
# there, and naming a runner API (`openai-speech`) at provider level fails the
+29 -15
View File
@@ -3,30 +3,44 @@ name: grilling
description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
---
> **Authored override.** Extracted 2026-10-18 from `mattpocock/skills` (identical to upstream at extraction) into the command-center repo so Frank can customize it. Removed from the `skills` CLI global store (`~/.agents/.skill-lock.json` pruned) so `skills update -g` never resurrects a duplicate. Edits to this file are repo prose: branch → PR → Frank OK.
# Grilling
Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it.
Interview the user relentlessly until nothing is silently assumed and you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it.
Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled: the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round.
A decision is **frontier** when it can be asked now, without an answer you haven't heard yet. The interview is done when the frontier is empty: every branch of the tree visited.
Format a round like so:
## Rounds
```
❓ **Q1** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
Work the tree in **rounds**: ask the whole frontier at once, then wait. A decision whose answer hangs on an open question or an unfetched fact joins a later round.
➡️ <your recommended answer>
Ask each closed decision - anything expressible as 2–5 options - through the `ask` tool:
---
- **One artefact per question**, counted before sending. Name the artefact each question decides; two names means two questions. Shared plumbing (an endpoint, a service, a build step) still leaves two artefacts.
- Recommended answer first, marked `recommended: 0`, which labels it "(Recommended)" for one-glance acceptance. Every reply names what was picked, so the record is unambiguous.
- Options quote the user's goal. Their words, their stated goal, ground the wording; your todo text and table rows stay out of it.
- Your own idea is welcome: it gets its own recommended question. Attach it to its own question, so it rides there on its merits.
- `multi: true` for pick-many, `header` for the branch chip, `preview` when the decision needs code or config in front of the user.
❓ **Q2** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
Open narrative - "walk me through the deploy flow" - has no options to offer; ask it as prose in chat. Without an `ask` tool the whole round degrades to text: `❓ **Q1 — title**: body`, then `➡️ recommendation`, phrased so one bare "yes" accepts every recommendation.
➡️ <your recommended answer>
```
The dialog is exclusive: dispatch scouts before the `ask` call in the same message, or they wait behind it; their results land between rounds. Esc ends the run outright, with no turn to wrap up, so the ledger is what survives it.
Word each question so "yes" accepts your recommended answer.
## The ledger
Each round the user answers reshapes the tree: settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one.
The tree lives in the `todo` list while the interview runs, so the user watches it fall:
Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it; don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report; ask the rest of the frontier now. The _decisions_ are the user's: put each to them and wait.
- One task per decision, filed under the phase for its branch; `via: <parent decision>` in the task text carries the edge.
- `pending` is the frontier; `block` with a one-line reason waits on a fact or an open decision; `done` is settled. Unblock the moment the dependency settles.
The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding.
Write `local://grilling-state.md` at the end of every round: the tree, each decision's verdict and one-line rationale. It is the durable copy of the interview; keep it current so an Esc costs one round.
## Facts are yours, decisions are theirs
- Environment facts (filesystem, repo, live config): dispatch a **scout** per fact-cluster, batched into one `task` call, before the round's `ask`. Cluster tightly; doing so keeps each scout more aware of possible adjacencies.
- Outside-world facts: call `web_search` directly, do not delegate to a subagent.
- Cheap lookups: `read`/`grep` inline.
- The main session is where questions happen: every spawn carries the exact facts to return, and comes back into the next round.
## Definition of Done
The frontier is empty and nothing sits blocked. Distil the ledger into a **brief**: one line per decision, verdict plus rationale, grouped by branch. Put it in chat, then one closing `ask`: "Proceed on this brief?" Nothing executes before that yes. After it, fold the brief into the project's knowledge base, or a log markdown file, wherever the project keeps its decisions.
@@ -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_odl3r9.jpg"
[wallpaper.last]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_73qev3.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_odl3r9.jpg"
[wallpaper.monitors.DP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_73qev3.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_odl3r9.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_odl3r9.jpg"
[weather]
unit = "imperial"