Files
command-center/agent/managed-skills/dankvoice-menu-knob-workflow/SKILL.md
T

4.1 KiB

name, description
name description
dankvoice-menu-knob-workflow Add a persisted /say knob to dankvoice and test its menu row through the bats driver (DV_SELECT picks, INPUT stub, dist-pin rebuild gate).

Adding a setting to dankvoice (~/Projects/dankvoice, omp speech extension) means five touchpoints plus one behavioral test. Skip one and TypeScript or a silent drop catches it; skip the test and the row ships dead.

The five touchpoints (order matters)

  1. src/settings.ts — field on Settings (optional) and a type-guarded line in readSettings. A field without a guard line is written to disk and never read back; tsc stays clean, so nothing else catches it.
  2. src/config.ts — Config field (required, defaulted), resolution line, entry in the returned object, and the knob documented in the file header comment (that header is the README's pointer for "full knob list").
    • Convention: explicit env wins over settings (env.X === "1" ? true : env.X === "0" ? false : (settings.x ?? false)); booleans use 1/0, enumerations use the literal value.
  3. src/index.ts — persistSettings() key list (a knob missing there never survives the session) and the getter in the new Speaker({...}) deps object.
  4. src/utterance.ts — getter on SpeakerSettings if the Speaker reads it at speak time.
  5. src/menu.ts — the row. Rows carry their own run; never dispatch on choice === options[N].

Gate at the call site, not the prompt, when a mode must not apply to operator-typed text: Speaker.speak(ctx, text, thinking, auto) — auto=true only from message_end/agent_end; /say <text> passes nothing. Prompt-only gating leaks.

Test the row behaviorally, never as a config assertion

tests/behavior.bats drives the built dist/index.js against a mock harness. Selects and typed text both come from one pipe-separated queue: DV_SELECT="row label|sub choice|typed text", consumed in call order; a consumed-exhausted select returns options[0], so always name the row by its rendered label ("Verbosity: normal (change)", "Intro: none (change)").

  • ui.input(title, placeholder) in the driver mock logs INPUT <title> <placeholder> and takes the next queue entry. If the mock lacks input, no typed value was ever testable — add the stub before writing the test.
  • Scenario pattern that proves a pick works (case "knobs"): say.handler("menu") → say.handler("on") → two agentEnd(answer(...)) with settle() between. Then assert on observable output, not on the settings file:
    • prompt-shaping knob: [[ "$output" == *"ONE spoken sentence"* ]] (directive reached the rewrite request)
    • framing: [[ "$output" == *"input=Update, chief!"* ]]
    • throttle/framing counting: [[ "$output" == *"CALLS 1"* ]]
  • A guard test whose negative case would pass trivially must also assert its premise, e.g. truncation: assert CALLS 1 so "no outro" cannot be a green caused by nothing ever being cut.

Red first, in a batch: write all the new @test blocks, run bats tests/behavior.bats -f "<pattern>", confirm the new ones fail, then implement.

Gates and gotchas

  • Two bats runs at once corrupt each other: setup() runs bun run build, so a concurrent run rebuilds dist/ mid-suite and the byte-identical pin test fails spuriously. Serialize.
  • Edit src/ → rebuild before commit: the pin test compares committed dist/ to a fresh build. Sequence is bun run build && bunx tsc --noEmit && bats -f <pattern>, then git add -A (dist included). A failure there is normally a stale bundle, not a bad assertion.
  • VAR=value some_function args in bats does reach the child process, so knob tests need no export.
  • edit tool: inserting after a line whose content you know but that the current tag never displayed gets refused — the refusal echoes the real line; if it matches intent, re-issue the identical edit instead of re-reading.
  • Ship: branch feat/<issue#>-<slug> off fresh origin/main, one commit per knob, PR targets main, merge only on Frank's explicit OK (do=merge, merge_style=no-ff), then omp plugin upgrade dankvoice and cmp installed dist/index.js against the merged one.