feat(harness): give omp an adapter, so a reply stops confirming its pickers - #93
Conversation
The thing that has to exist before any omp detector can be written: twenty
byte-faithful captures of oh-my-pi `omp` v17.2.12 across three sandbox panes,
nine with a live composer and eleven with a modal owning the screen. omp inverts
the reference harness's composer layout in three ways — the statusline is
painted INTO the box's top border, the draft's LAST fragment sits ON the bottom
border with earlier fragments stacked above it, and autocomplete renders BELOW
the box — so not one of its adapter's chrome constants transfers and every
capture had to be re-derived rather than guessed from a screenshot.
Sanitized in place and length-preserving, ASCII for ASCII, so every row's column
alignment and display width survives byte-for-byte and the captures stay usable
as width fixtures. Two classes were rewritten: the environment (cwd, MCP server
names, session titles, a machine-local `skill:` palette row) and vendor account
state — omp prints the provider a session runs on, and `/model` marks with a dot
which providers this account is signed into. What the pass deliberately keeps is
the SHAPE the detectors read: seven configured providers, their model counts, the
signed-in/available split, every column boundary. The catalogue of the rest is
omp's shipped list, identical on every install, so it is not user data and stays
verbatim. The README publishes the greps that verify all of it.
No picker's confirm key was ever pressed. Every dialog was driven onto the
screen, captured, and dismissed with Escape.
Three traps are recorded so the next person doesn't re-learn them:
- Line endings vary per fixture and must NOT be normalised. Twelve are
all-CRLF, eight all-LF, none ends in a trailing newline. Edit in binary mode;
a text-mode pass silently strips the CR and changes every byte count.
- omp's pickers run on the alternate screen, so `pane.read source=recent`
returns exactly viewport_rows lines with no scrollback. "There is transcript
above the dialog" is not available as corroborating evidence the way it is
for the other harness.
- omp's `agent_status` stays `idle` while a picker is up; only the ask tool
flips it to `blocked`. Nothing may gate on `blocked`.
`describeAdapterConformance` has always asserted that an adapter stays raw-only on another harness's buffers, and that assertion has never once run. With a single adapter in the tree there was nothing foreign to feed it, so `foreignFixtures: []` made the leg vacuous — the promise was a comment, not a gate, and had the reference adapter's matchers been quietly over-eager on some other TUI's output nothing here would have said so. The omp corpus is the first foreign cohort there has ever been. The reference adapter's grammars now provably return raw-only across all twenty of omp's captures, several of which are precisely the shapes most likely to fool a matcher tuned for a different TUI: a numbered review screen, three full-screen pickers, and a two-pane provider/model selector. Globbed rather than listed, like the own and neutral cohorts beside it, so a future omp capture joins the cohort the moment it lands.
An omp pane has never had the palette button: `commandsFor` returned [] for it
and composer.tsx renders the button only for a non-empty catalog, so one of the
three things Tier 0 is supposed to give every agent for free was simply missing.
That is worth closing on its own, and it has to close before anything strips
omp's own palette off the mirror — which the adapter in the next commit does,
since the palette is composer chrome and mirroring it would draw one twice.
omp publishes no command reference to read, so this is the one catalog sourced
from somebody's terminal rather than from docs. Two rules come with that, and
both are written down beside the list:
- Only what the corpus vouches for, and the corpus speaks in three ways, each
marked per row: a row of omp's own `/` autocomplete; omp's own tip line,
which names /shake and /compact outright in prose in eight of the twenty
captures and is where both descriptions come from; and the corpus README's
capture log, which records /model, /settings and /resume as having been typed
to produce a fixture — that the command was run and its screen captured is
stronger evidence it exists than a palette row is, though weaker on what it
does, so those rows are described by the screen and nothing further.
- Nothing from the capturing machine. omp's palette mixes in rows assembled
from the user's own install — the `skill:` entries in this corpus — which are
neither omp built-ins nor anything another user would have.
Reading all three sources rather than the obvious one matters. The five palette
rows in the capture are everything omp fuzzy-matched for the string "new" before
the unfiltered capture ran off the bottom of the pane: an accident of one search,
not a curated set, and shipping only those would have left an omp user with less
in the app's palette than the raw mirror had been showing them.
The three commands that open a modal are marked uncommon rather than promoted.
The adapter up-levels none of omp's modals, so from a phone they land on the raw
mirror, to be driven with the special-keys pad and dismissed with Escape.
…ickers
`omp` (oh-my-pi) had no adapter, so it fell through to the raw terminal mirror.
That is a working Tier 0, but it costs an omp user two things.
The visible one: the mirror draws omp's own composer box underneath collie's,
statusline and all, so a phone screen carries two input boxes, and a draft typed
on the laptop sits in a box the app cannot read.
The one that matters: reply-action.ts opens with `if (!adapter) return
oneShot(args)`, so every omp pane took the legacy one-shot send — type AND submit
in a single call. A reply sent from a phone while one of omp's pickers held the
keyboard therefore fired the submit key at that picker, which confirms whatever
row it had highlighted. Registering any adapter at all swaps that for
type-then-verify, where the submit key waits until `extractInputDraft` can see
the text in the box, and this one supplies `composerReady` on top: a pre-flight
that reads the pane once BEFORE typing and refuses on a definite false. All
eleven modal captures in the corpus answer false, so the message never reaches
the modal either. Two honest edges: a pre-flight read that throws falls through
rather than blocking a send, and a deliberate `force` retry skips it — in both
cases type-then-verify is still what stands between the send and the submit key.
This is Tier 1 and stays there by construction, not by discipline.
`ompBuildBlocks` is one hard-coded raw block, so no tap anywhere in the app can
turn an omp derivation into a keystroke, and omp.test.ts asserts the raw-only
result over all twenty captures rather than counting cohorts. omp's tool-approval
dialog is a genuine Tier-2 candidate and is deliberately not in this change; it
needs the full bar, maintainer live-verification included.
None of the reference adapter's chrome code transfers and neither do its
constants, because omp inverts the layout. chrome.ts is a new bottom-up scanner
that pins the whole box before any probe reads a field off it — four steps, each
of which can only reject: the `╰─ … ─╯` bottom border, a capped continuation
walk above it, a capped suggestion run below it, and the top border checked
last. That bottom border is the single load-bearing literal here: omp writes the
draft's last fragment into it between a one-space gutter on each side, a shape
that occurs once per composer capture across the whole 58-fixture corpus and
nowhere else, since every other omp box closes corner to corner with an unbroken
rule. Every row is then measured against the box's OWN display width rather than
a constant, which is what keeps omp's welcome panel and its pickers — all 100
cells against this box's 189 — from being spliced into the composer's geometry.
When the shape doesn't fully match, `stripChrome` returns the same reference, so
a caller can read "unchanged" as "no chrome".
The suggestion cap is set to 64 deliberately, above every viewport height in the
corpus (59). Too high costs nothing on its own — the per-row width equality is
the guard, and the cap only stops the search walking a 10,000-line scrollback —
whereas too low is not the safe direction: it would decline an ordinary screen,
leaving the composer duplicated in the mirror and the reply pre-flight refusing a
send with "a menu or dialog is probably up" when none is.
Why every omp modal stays raw is now pinned by a test rather than asserted in a
comment. `parseKeyHintFooter` returns [] for nine of omp's modal footers — omp
writes `<key> <verb>`, not `<key> to <verb>` — and for /settings it yields only
{Jump sections} and {Close}, because `menuKeyFor` rejects the compound tokens
(`Enter/Space`, `←/→`, `Type`) that screen's real actions are named with. A modal
whose only button is "Jump sections" is worse than the raw mirror, and widening
the shared grammar to fit omp would move a contract the reference harness's
/model picker is pinned against. So the test is a tripwire on that shared
module: widen it for some future adapter and omp's file fails, forcing a
re-derivation here.
The registry gains its second entry, and the comments that used to single out
one agent by name now say what they actually mean — the reference adapter is the
only one that emits `wizard` / `preview-select` / `multi-select` / `menu`, which
is a fact about that adapter rather than about the agent string it is registered
under. `hasBlockGrammar` is "is there an adapter for this agent", and its test
says registered rather than verified: verified is the Tier-2 term of art, and
omp has not cleared that bar and does not claim to.
`commandsFor` indexed a plain object with a string that arrives from outside the
app — Herdr's `pane.agent`, i.e. whatever the host process reports itself as. An
agent string that spells an inherited member ("constructor", "toString",
"valueOf", "hasOwnProperty", "__proto__") indexes to that member rather than to a
catalog entry, and a function is truthy, so the guard handed it straight back as
if it were a command array. command-palette.tsx calls `.filter` on what it gets,
so a pane reporting one of those names took the whole palette down with a
TypeError rather than falling back to "no commands, hide the button".
`Object.hasOwn` is what quick-replies.ts already does for its twin lookup and
what `adapterFor` does for the registry; this is the third of the three finally
doing the same thing.
… keyboard `locateComposer` decided two things at once — where the composer box is, and whether a phone reply may be typed at all — and it was getting both wrong in opposite directions. This settles what the rule is allowed to claim. No measurement, anywhere. The scanner shipped a display-width equality twice: first as `displayWidth(a) === displayWidth(b)` across the box's rows, then as an equality slackened by a per-cluster error bar. Both are unsound for one reason. omp padded those rows with ITS width table, we re-measure with OURS, and the two rows carry DIFFERENT content — the user's statusline template on the top border, their draft tail on the bottom one — so the comparison only ever held when both tables happened to agree about both strings. text-width.ts is documented as an approximation of wcwidth; a detector may not depend on that approximation being exact. The error bar could not have rescued it, because it cannot be built from these tables at all. `displayWidth` walks grapheme clusters and scores each by its BASE code point, while every width table a TUI actually links against (wcwidth, go-runewidth, Rust unicode-width) sums a cluster's code points independently. `👨💻` is 2 for us and 4 for them, `👨👩👧👦` 2 against 8, a keycap 1 against 2 — and the bar, built from the same ranges, scored all three as CERTAIN. It read zero exactly where the divergence was largest, while `🗑`, `▶` and every arrow scored as doubtful and donated slack the check had no business spending. What that bought was total and permanent: one ZWJ emoji in a statusline template, or typed into the operator's own message, and the scanner returns null on EVERY frame — no statusline, no draft chip, the box duplicated on the mirror, and every reply refused with "the input box isn't on screen" while no dialog exists. Against that, the check never discriminated: omp paints its pickers at the composer's own 189 cells, and all nine composer captures measure 189/189 too. So it is gone, `widthUncertainty` with it, and text-width.ts now carries the note saying why a width compared against a width another renderer chose over different content is not a fact at any tolerance. The nine pinned captures resolve to byte-identical indices without it. A tail anchor that is actually anchored at the tail. Removing the equality left the suggestion run below the bottom border bounded only by a cap of 64 and by having to be non-blank. That is fine for the strip, whose worst case is cosmetic, but `composerReady` was reading the same verdict — and what it was then claiming was "a composer bottom border exists somewhere in the last 64 rows", not "the composer has the keyboard". omp draws its Ask dialog inline at the tail with live transcript above it, so a dialog stacked onto the composer answered `true`: the phone showed an idle composer with a stranded draft, the dialog was cut off the raw mirror entirely, and a Send tap armed the pre-clear sweep against a live modal. The only thing standing between those two verdicts was a blank row omp happens to paint above a dialog — measured, and it really was the whole difference: gap 0 lifted, gap 1 declined. The rule that replaces it is a glyph predicate like every other one here. Every widget omp can put in front of the composer — the six pickers, the five ask screens, the welcome panel, a tool-result box, the `╭─── ✘ Error: … ───╮` banner — is a BOX drawn at column 0, and omp reserves column 0 for boxes: transcript is indented, and the slash palette's rows open with `❯ ` or a two-space gutter. So a box-drawing glyph at column 0 anywhere below the bottom border means something else owns the screen, and the whole shape is declined. It needs to recognise none of them individually, it keeps the modal on the raw mirror where the user can read and answer it, and it makes `composerReady` false on the screens it was invented for. Both palette captures still lift, which is what that run is there for. The cap stays at 64 and its comment stops arguing from the wrong quantity: it was sized against the pane's height, when what it bounds is rows BELOW the border, whose observed maximum is five. It is no longer load-bearing — read it as the bound on how much torn transcript the strip may eat, which is the cosmetic cost it really governs. `.` is not "any glyph". All three row predicates matched their body with `.`, which in JS excludes U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR. `rstrip` only takes those off the end of a row, so one anywhere else survives into the parsed text and declined the row silently — the exact permanent-null failure the width removal was supposed to end, reintroduced as a character class. They arrive by paste, from a PDF, a Word document or a JS string literal, and nothing on the send path normalises text. `[\s\S]*` in all three. Also here: `composerPrompt`, which names the composer's own `╰─ … ─╯` row so a destructive write can be BOUND to it (the reply path uses it in the next commit); the conformance suite gains a leg requiring any adapter that supplies one to name a region on exactly the screens its `composerReady` approves; and `stripChrome` returns the same reference for an empty buffer, which its own conservatism contract always said it would. What is not closed, and is now said where the tier is claimed: the whole lift hangs off ONE literal, the bottom border with its one-space gutters. The census is real — once per composer capture, nowhere in the other 49 — but it is a measurement of omp 17.2.12's renderer, not a proof about it, and omp already draws a two-row full-width box at column 0 whose text lives in the top border. A widget that ever labelled its BOTTOM border the same way would be read as a composer. What bounds that is the box rule: it can only mislead while it is the last box on screen, which is not the case that mattered.
…to it Before `pane.send_text` appends at the cursor, the composer sweeps whatever is stranded on the agent's input line: `ctrl+k` plus a run of Backspaces. Those are the most destructive keys this app sends and the only ones nothing downstream can withhold — the type-then-verify guard protects the SUBMIT key, and once a burst is on the wire it has already landed in whatever owns the keyboard. It was fired from the wrong evidence. The composer decided to sweep from `display`, which is a snapshot: a poll behind while the mirror follows the tail, and frozen outright while the user has scrolled back or opened find. So its own fail-fast (`dialogPresent`) could read false against a pane that had since put a dialog up, and the sweep went into that dialog — the AltanS#34 failure one step upstream of where AltanS#34 was fixed. The sweep now runs inside `sendGuardedReply`, as `onComposerSeen`, named for its precondition rather than its position. The permission is a callable, not a condition. `preflight` returns `{ refuse, runPreType }`, and `runPreType` is manufactured on exactly one branch: the one where a live read's `composerReady` answered true. The call site is `await runPreType?.()`. So `force`, a pre-flight read that threw, an adapter with no `composerReady`, the no-adapter one-shot path, and whatever path is added next all skip the sweep by construction rather than by anyone remembering to check — there is no list to keep in sync. That is what the three holes the previous shape left open had in common: each SKIPPED the read and then swept anyway, because the sweep was gated on "did the caller hand me a callback?" instead of on the evidence. Two behaviour changes fall out, both deliberate. `force` now overrides the REFUSAL while still paying for the read: it is armed by a `blocked` outcome, i.e. by the app having just proven a dialog owns the keyboard, so it is the last screen that may receive keys. It costs nothing real — a detector that cannot see the box cannot read our text back out of it either, so a forced send stalls the same way it would have. And an adapter with no `composerReady` no longer sweeps at all, which is what "no idea" should always have meant. Ordering is not a freshness bound. `runPreType` existing proves a read SAW the composer; it cannot prove the composer is still there when the keys land, because the read's answer describes the pane at the moment the bridge snapshotted it and the burst goes out a round-trip later, capped only by GET_TIMEOUT_MS. Every other keystroke path in this app already solves that: dialog-guard.ts binds its write to the region the guard verified, and the bridge re-reads immediately before `send_keys` and 409s `prompt_changed` when the region has gone. The reply pre-clear was the one destructive path not doing it. So the pre-flight now hands its EVIDENCE forward and not just its permission — `onComposerSeen({ promptRegion })`, the adapter's `composerPrompt` over the very buffer `composerReady` just approved — and the composer passes it as `expected_prompt`. For omp that region is the `╰─ … ─╯` row, which is literally the line the Backspaces are aimed at, so a refusal is not a proxy for "something changed": it is "the line I am about to erase is not the line I read". A refused binding aborts the send with nothing typed. An adapter that names no region keeps today's unbound write. TOCTOU on the other side of the burst is closed rather than deferred: when the hook reports it actually sent keys, the guard re-reads and re-checks `composerReady` before typing, refusing with `blocked` if the composer left the screen in the meantime. A hook that sent nothing pays for no second read. Finally, `locked` is re-read from a ref immediately before the burst. `send()` checks it once, up front, but the burst goes out on the far side of the pre-flight's round-trip, and unlike every other key this component sends it does not go through `pressKeys`, which has its own check. A pane that died or a device that lost write access inside that window was still getting it. What remains: read and write are separate round-trips, so a window remains by construction. The binding shrinks it from a network round-trip plus a human's reaction time to the two local RPCs between the bridge's re-read and its `send_keys`, which is where the removable risk lives; closing it entirely needs a conditional-input primitive herdr does not have, and its `revision` is a stub, so there is no CAS to lean on either.
…y adapter The `onComposerSeen` docblock framed `dialogPresent` as a snapshot that "can read false against a pane that has since put a dialog up" — a timing window. For an adapter that lifts no interactive kind there is no window. The fail-fast is `buildBlocks(...).some(b => b.kind !== "raw")`, so an adapter returning one `raw` block by construction can never make it true, whatever the mirror shows. Confirmed on a live pane with a full-screen picker filling the screen: `dialogPresent` was false while `composerReady` was the thing that refused the send. Worth stating where the hook's contract is written, because "one of two gates" and "the only gate" carry different weight when reading why this hook keys on the live read rather than on the caller having checked already.
The override test asserted the two things it is about — the retry TYPES, and it never sweeps — and then ended, while `sendGuardedReply` was still polling. The fake pane keeps answering with the omp picker, so type-then-verify runs its full POLL_ATTEMPTS × POLL_DELAY_MS (8 × 350ms, harness/guard.ts) and resolves `stalled` about 2.8 seconds later. Status is a MODULE-SCOPED singleton, cleared per test in `beforeEach`, so that write landed in whichever test happened to be running by then — a different victim each run, none of them near this file. Measured: this file alone is 5/5 green on main and failed 2 of 6 runs with the adapter in place, which is what a ~2.8s fuse into a 14s file looks like. So wait for the terminal outcome inside the test, the way the neighbouring refusal tests wait for theirs — the status sentinel, `didn't reach the input box`. The assertions that carry the test's meaning now also run on the far side of the whole poll window, so "never sweeps" is a claim about the entire send rather than about its first 50ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`composerPrompt` returns the `╰─ … ─╯` row so the destructive pre-clear sweep can be bound to it. The bridge only honours that binding within a window: `verifyExpectedPrompt` (bridge/prompt-binding.ts) accepts a match only when it ENDS inside the last DEFAULT_PROMPT_TAIL_LINES — 6 — NON-BLANK rows of the fresh read. A one-row region therefore tolerates five rows beneath it, and omp paints its slash palette BELOW the box: `omp--slash-palette--filtered.txt` has exactly five, i.e. zero margin, while MAX_SUGGESTION_ROWS admits sixty-four. One more palette row and the binding can never verify. Every free-text send on that pane 409s with "The input box changed while clearing it" while nothing is wrong and nothing has moved — fail-closed, but a refusal that is permanent, is not about anything the user did, and reads as the app being broken. The trigger is typing `/` and having enough commands match. So the adapter declines a region it knows the bridge cannot use: `suggestEnd - bottom - 1 > 5`. Every row in that span is non-blank by construction — step (a) rejects a blank inside the run — and everything past `suggestEnd` is trailing blanks the bridge's own normalization drops, so the count is exactly the bridge's count of the rows below the region. Null means an unbound write, which is precisely what this path did before it named a region at all: the sweep loses its binding, never its live-read pre-flight. The conformance suite gains the general form, next to the pairing leg: whenever an adapter names a region for a fixture, that region must end inside the last six non-blank rows of that fixture — checked by reproducing the bridge's two steps (rstrip, drop blanks; last match wins), since nothing in web/ imports bridge code. It reports the real numbers on the two palette captures (5 and 3), so the margin this commit is about is visible in CI rather than in a comment. Claude supplies no `composerPrompt`, so it registers the todo as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Excellent work, especially the reply-path fix: the live-read gate closes a hole that predates omp, and the review confirmed it's safer for every harness. I pushed two small commits onto your branch before merging: d76a4c1 pins down a test that could leak its guard poll into later tests, and 2c44255 has composerPrompt decline a binding the bridge's 6-row tail window could never verify (a tall slash palette would have made every send 409). Thanks! |
|
Thanks for the merge, and for the two follow-ups. The |
feat(harness): give omp an adapter, so a reply stops confirming its pickers
Adds a harness adapter for the Herdr agent string
omp(oh-my-pi v17.2.12), which currently has noadapter and falls through to the raw terminal mirror.
Tier 1 — no interactive block kind is emitted, so no tap can become a keystroke:
One unconditional return.
grep -rn "kind:" harness/omp/returns exactly that line, so a non-rawblock is unreachable by construction rather than by test luck.
What it changes for an omp user
reply-action.tsopens withif (!adapter) return oneShot(args), so an omp pane takes the legacyone-shot send — type and submit in one call. A reply sent from a phone while one of omp's pickers
holds the keyboard fires the submit key at that picker, confirming whatever row is highlighted.
Registering an adapter swaps that for type-then-verify; this one adds
composerReady, a pre-flightthat reads the pane before typing and refuses on a definite
false. All eleven modal captures in thecorpus answer
false.Also: omp's own composer box stops drawing underneath collie's on the mirror, a laptop-typed draft
becomes readable, and omp gets a slash palette (
commandsFor("omp")currently returns[], so thebutton never renders).
One change outside
harness/omp/Flagging this because it touches core rather than the adapter.
composer.tsx's destructive pre-clearsweep (
ctrl+k+ a Backspace run) fires beforesendGuardedReplyis called — before anything hasread the live pane. It decides from
display, which lags a poll while following and is frozen whilethe user has scrolled back, so a dialog opening in that gap takes the burst. This predates the branch
and isn't omp-specific; registering an adapter is just what makes it reachable on omp panes.
The change: the sweep runs only where a live read positively saw the composer, and the burst is bound
to the screen that authorised it. It's structural — the pre-flight returns a runner built on the one
branch where
composerReadyanswered true, soforce, a read that threw, and an adapter withoutcomposerReadyall getnulland skip it. The binding reuses the mechanismdialog-guard.tsalreadyuses (
expected_prompt, with the bridge's 409prompt_changed). Full reasoning is infix(reply): a destructive pre-clear needs a live read, and a binding to it.Happy to pull this out into its own PR if you'd rather review it separately, or drop it entirely
and ship just the adapter.
Verified on a live pane (2026-08-11)
Real omp v17.2.12 pane, Herdr 0.8.0, bridge built from this branch, driven through the real
sendGuardedReply+lib/api.ts+adapterFor("omp"), withcomposer.tsx'ssend()mirroredline-for-line as the caller. Every request logged, with the bridge's
audit.logas an independentrecord.
╰─ … ─╯row; guard re-read at +382 ms; submitted text verbatim, no concatenation./modelpicker upblocked. One request total — the pre-flight GET. Pane byte-identical before/after (cmp: 0 differing bytes over 26 179).forceretry, picker up/keysrequest at all); submit key withheld; no row confirmed.prompt_changed; zero/replyrequests; the burst never reached the pane.Not covered: one pane, one omp version, one terminal width, one modal shape. The tool-approval dialog
wasn't exercised (see below). I didn't reproduce the old
oneShotbehaviour onmainfor comparison,since that would have confirmed a
/modelrow and changed a persistent setting.Corpus
Twenty byte-faithful captures across three sandbox panes, never a real session: 9 composer states, 11
with a modal owning the screen. No picker's confirm key was pressed — every dialog was driven onto the
screen, captured, and dismissed with
Escape.Sanitized in place, length-preserving, so column alignment and display width survive byte-for-byte and
the captures stay usable as width fixtures. Rewritten: the environment (cwd, MCP server names, session
titles, a machine-local
skill:row) and vendor account state (which providers the account is signedinto). Preserved: the shape the detectors read. The corpus README publishes the greps that verify it.
I went with sanitizing since the captures came off a real machine into a public repo, and there's
precedent in the existing corpus. Let me know if you'd rather have them a different way and I'll
recapture.
What this deliberately doesn't include
corpus, so to be precise about what's proven:
rawis returned unconditionally, so it can't beup-levelled either way — but the claim that
composerReadyanswersfalseon it is inferred fromthe eleven modals that were captured, not measured. Capturing it is the first thing a Tier-2
follow-up owes.
menulift for omp's pickers —parseKeyHintFooterreturns[]for nine of omp'sfooters (omp writes
<key> <verb>, not<key> to <verb>), and widening the shared grammar wouldmove a contract the existing
/modelpicker is pinned against. Left as a tripwire test instead.asktool's select dialogs — declined on the fail-closed contract.ownFixtures: []the suite emits nineit.todos. Whatexecutes is conservative detection (raw-only over all 58 captures, in full) plus the new
composerPrompt/composerReadypairing. The real gating for this adapter isomp/chrome.test.ts,and the code says so rather than letting a green run imply more than it proves.
send_keysare two separate herdrRPCs, so a dialog opening between them is unguarded — the same residual the other guarded keystroke
paths carry. Bounded: the submit key is still withheld, so the worst outcome is an unsubmitted
message.
╰─ … ─╯is the composer's alone — it's a census (once in each of the 9 composercaptures, nowhere in the other 49), not a proof, and worth re-checking against a future omp version.
Tests
Against a pristine
mainexport, same runner andnode_modules: zero new failures, +364 passing(
48 failed | 1670 passed→48 failed | 2034 passed | 10 todo). The failing-file list is identicaland the 48 failing test names were diffed rather than counted — byte-identical sets, all the
pre-existing
localStorage is not availableenvironment fault on my machine.tsc --noEmitclean inboth packages; root
bun run test545 pass / 0 fail. Capability fence green. No.skip,.only, orTODOplaceholder in any new or modified file.One edit to
HARNESS_CONTRIBUTING.md: the CI-gate bullet, since the conformance suite gained a leg.CHANGELOG.md,package.jsonandherdr-plugin.tomlare untouched — the version seemed yours topick.
First contribution here, so tell me if any of this is shaped wrong and I'll rework it.