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)
src/settings.ts— field onSettings(optional) and a type-guarded line inreadSettings. A field without a guard line is written to disk and never read back; tsc stays clean, so nothing else catches it.src/config.ts—Configfield (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 use1/0, enumerations use the literal value.
- Convention: explicit env wins over settings (
src/index.ts—persistSettings()key list (a knob missing there never survives the session) and the getter in thenew Speaker({...})deps object.src/utterance.ts— getter onSpeakerSettingsif the Speaker reads it at speak time.src/menu.ts— the row. Rows carry their ownrun; never dispatch onchoice === 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 logsINPUT <title> <placeholder>and takes the next queue entry. If the mock lacksinput, 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")→ twoagentEnd(answer(...))withsettle()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"* ]]
- prompt-shaping knob:
- A guard test whose negative case would pass trivially must also assert its premise, e.g. truncation: assert
CALLS 1so "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
batsruns at once corrupt each other:setup()runsbun run build, so a concurrent run rebuildsdist/mid-suite and the byte-identical pin test fails spuriously. Serialize. - Edit
src/→ rebuild before commit: the pin test compares committeddist/to a fresh build. Sequence isbun run build && bunx tsc --noEmit && bats -f <pattern>, thengit add -A(dist included). A failure there is normally a stale bundle, not a bad assertion. VAR=value some_function argsin bats does reach the child process, so knob tests need noexport.edittool: 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 freshorigin/main, one commit per knob, PR targetsmain, merge only on Frank's explicit OK (do=merge,merge_style=no-ff), thenomp plugin upgrade dankvoiceandcmpinstalleddist/index.jsagainst the merged one.