User ruling. A worktree is not durable storage: an agent finished a whole
ticket with every gate green and lost all of it, uncommitted, when its
worktree was deleted during a disk cleanup. The brief template now requires a
WIP commit after the first green test and after every meaningful step, and
separates "do not merge to main" from pushing the agent's own branch, which is
encouraged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
scripts/kb add wrote onto a working-tree kb.pl that had lost 276 of its 801
clauses and reported success, because kb_consistent checks consistency, not
completeness (improvise-usus). Every write now runs kb-merge.pl `complete`
on the candidate before kb_consistent. It refuses unless every clause HEAD
has is still present by key, or its removal was recorded by `kb rm` in a
per-worktree ledger in the git dir, which prunes itself on every write.
The candidate is now built beside kb.pl, so publishing it is a rename(2)
rather than a possible cross-device copy that a kill could truncate.
Exit codes: 0 done, 1 refused, 2 usage, 3 HEAD's kb.pl unreadable. kb-test.sh
now builds its fixtures from HEAD and adds cases 18-22. Argument in
context/code/kb-tool.md, evidence and rivals in context/test-evidence/tooling.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
A SEMANTIC merge conflict that auto-merged cleanly: PR #160 added
`vocabulary_is_composed.rs`, whose `NOT_COMPOSED` list names `SetYanked`
with the verdict "improvise-lut7: nothing resolves yank, so paste reads a
permanently empty slot". This branch is the fix for improvise-lut7, so
that entry is now false.
git reported no textual conflict — the two sides touched different files —
and `the_uncomposed_list_names_nothing_that_is_composed` caught it on the
merged tree, which is exactly the both-directions check #160 added it for.
Worth stating because the two PRs agree on the diagnosis: the list cites
the ticket this branch closes, and the guard is what turned that agreement
into a build failure rather than a stale sentence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
AGENTS.md's brief template now carries the gate list, because the one I had
been handing agents said `cargo fmt --all`, which reaches only one of this
repo's two workspace roots — and `cargo fmt -p opfs-contract` from the root
exits 1 as a usage error indistinguishable from a real verdict
(improvise-b4ks). Both spellings fail in the direction that looks like
success. The gate is `scripts/fmt-workspaces.sh`.
kb: the improvise-6qet ruling (a collapsed group is one aggregate row), and
the CI lane's doctrine on stubs and fixtures.
Also recovers kb.pl from a truncation: the working tree had lost 276 of 801
facts, most likely to a `kb replace` killed mid-write at a session boundary,
and `kb add` had written onto it with kb_consistent passing. Restored from
HEAD; this commit is 6 insertions, 0 deletions. Filed as improvise-usus.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ChatGPT Codex on PR #161, and the finding was correct. The structural
guard counted `requested(name).is_some()` as "answered", but `chord`
maps `Step::Request(_)` to `None` — so a future prefix binding for
`save` would have PASSED the guard while the request was discarded at
runtime. That is the exact shape improvise-lut7 was filed about: a green
derivation over a broken thing.
The guard now refuses a request-backed prefix name. `prefixed` is split
so the binding step is `chord(state, event, Option<&Binding>)`, which
lets a test drive a binding the shared table does not contain — the
regression the review asked for. It shows the outcome is the disarm
ALONE, and asserts `requested("save").is_some()` first so the guard's
refusal clause is known to be able to fire.
Two faults injected. Binding `save` behind `g` in `default_keymaps` —
the scenario the review described — fails the guard, naming `save`. And
making `chord` return `KeyOutcome::Request(r)` (the review's other
suggested option) fails the new test, because the disarm is then lost:
that is the evidence the alternative trades one stranding for another.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-k84e, scoped to what PR #109 left: a frame whose LoadModel
SUCCEEDS, rebases every mirror and emits Snapshots, and is THEN rolled back
by a later command in the same frame.
The document half was already covered by
a_rolled_back_load_leaves_no_ghost_view_and_no_false_status. This covers the
Sessions half, which nothing touched: mutating_frame clones the whole
Sessions before the batch and undo_frame puts the clone back, so the reseed
is undone with it and the computed Snapshots are discarded rather than sent.
It reproduced nothing — the mechanism is sound, as the bead predicted. Its
value is the fault it can see. Injected: `self.sessions = mirrors;` replaced
by `let _ = mirrors;`. The test failed on the projection assertion, naming
the asker's mirror and printing the whole correcting batch — the restored
document being re-sent to a client left holding the discarded one, which is
a client hearing about a load that never happened.
Three things keep it non-vacuous: a separate control asserting the fixture's
load really does rebase both clients, the generation check made before
anything is concluded from the rollback, and a bystander seated where a
wrong relay could move it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
ChatGPT Codex on PR #159: both formatter invocations end `|| rc=1`, so any
failure after the rustfmt probe was reported as "misformatted" although no
verdict was produced. Correct, and the narrower form of the bug this gate
exists for.
Measured: an unparseable source exits 1, identical to a diff; an unknown
rustfmt.toml option is only a warning (exit 0).
Narrowed rather than re-classified, because telling a diff from a parse error
means parsing rustfmt's prose, which is the check-adjacent-to-its-subject this
repo keeps getting wrong. The line the split needs is "did the gate run":
exit 1 names a file and blames the tree; exit 2 names none and blames the
invocation, and every exit-2 cause is checked above the loop.
Two more rivals injected: deleting the `cargo fmt --version` probe, and
turning `|| rc=1` into `|| return 2`. The first exposed a vacuous test -- a
cargo stub that failed for every argument also breaks `cargo metadata`, so the
run exited 2 on the metadata arm and the probe could be deleted with the suite
green. The stub now delegates every subcommand but `fmt`, which is the shape a
missing rustfmt component actually has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`render_model` built `editing` as
`is_editing().then(|| buffers.get("edit").cloned().unwrap_or_default())`,
so an `AppMode::Editing` that arrived without its `SetBuffer` painted
`Some("")` — a blank editor drawn over a cell that still holds a value.
The page opens an `<input>` seeded from that string, so the next Enter
commits the empty string and the value is gone. An ABSENT buffer and an
EMPTY one are two different facts, and `unwrap_or_default` collapsed them.
Reachable because the pair is two `Command`s rather than one: this
client's own resolver emits `SetBuffer` then `ChangeMode`, and a
`Snapshot` or a host batch can carry the mode without the buffer.
The fallback is `improvise_protocol::edit_seed`, which is the EXISTING
home of "what an editor opened here starts with" — `seed_at` at the
cursor, the function `enter-advance` already seeds from. A second
reconciliation of the scroll offset inside `render.rs` would have been
gotcha 42's own trap, so this costs one `pub` rather than a copy.
Two tests, and the second is the one that matters for the shape of the
fix: an empty buffer is a state the user typed their way into with
backspace, so refilling it would make the last backspace un-doable. The
over-broad repair — "empty or absent, refill" — fails that test alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`resolve` answered `Binding::Prefix` with `KeyOutcome::Upstream`
unconditionally, and `Host::handle` answers `ClientFrame::Key` with an
explicit empty vec (gotcha 47) — so `g`, `y` and `Z` were black holes and
everything behind them was dead. `grid_move`'s `GridMove::ToStart(true)`
arm was correct and unreachable, so a browser user could reach the last
row and never get back to the first.
Arming is now a `Command::SetTransientPrefix`, which was already on the
wire and already folded by `reduce_view`; nothing composed it. The second
key is resolved in the sub-keymap through `Keymaps::prefix_keymap`,
mirroring `App::dispatch_key` line for line — including that the prefix
is spent whether or not the second key matched, because leaving it armed
strands every key after it in a sub-keymap the user has forgotten about.
Every outcome of a chord therefore carries the disarm, so the second key
can never be `Upstream`: there would be nowhere to put it. The two names
that reach no composition, `wq` and `toggle-col-group-under-cursor`, are
answered by `declined` instead (improvise-rmvx: silence was worse), and
`every_name_bound_behind_a_prefix_is_answered_by_this_client` makes that
structural — a future prefix-bound name nothing answers fails the build.
`p` could never do anything because nothing could put a value in
`ViewState::yanked` (improvise-lut7) — a *state* reason no name-level
derivation can see, which is why paste appeared on neither the declined
list nor the host-needed one. `yank` now composes, off the `edit_text`
the host already projects, which is the value `YankCell` evaluates.
The three prefixes leave `the_command_names_that_still_need_the_host_are_these`.
Not predicted by either ticket: `tests/lockstep.rs` — the instrument for
"host and client agree on a keypress" — passed unchanged through all of
this, because no chord had ever been driven through it. Every key it
pressed was a single key. `the_g_chord_returns_to_the_first_row_on_both_ends`
closes that gap and compares both ends after *each* press.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-m0sx. `Command::ClearBuffer(String)` was declared on the wire and
composed by nothing in production: its only occurrences were the enum, the
fold arm, and two of reduce.rs's own tests. Its fold arm removed the buffer
key, while the only two implementations of its own name — the host's
`clear-buffer` command and resolve.rs::write_buffer — both compose
`SetBuffer { value: "" }`, which inserts. So the variant was invented rather
than derived and disagreed with both ends.
Deleted rather than given an effect. Adding a ClearBuffer effect to justify
the variant reverses decision(improvise-cqi, command_set_derived_not_invented),
and it would buy nothing: both readers of the map end in unwrap_or_default(),
so an absent key and an empty one are indistinguishable to every reader.
The guard is the generalisation. vocabulary_is_composed.rs reads the variant
list off serde's derive — the one enumeration the compiler maintains — and
requires each name to be constructed in non-test source, or named in a short
NOT_COMPOSED list with its verdict, checked in both directions.
It found two more orphans nobody had connected to the vocabulary, both
already filed as resolver gaps rather than inventions: SetYanked
(improvise-lut7) and SetSearchMode (improvise-fo0w item 1).
Proved discriminating twice: removing SetTileCatIdx's three production
composers failed the first test naming it, and listing the composed
SetBuffer in NOT_COMPOSED failed the second.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-b4ks. From the repo root `cargo fmt -p opfs-contract -- --check`
exits 1 with "package `opfs-contract` is not a member of the workspace" and
never runs --check, so a caller reading $? cannot tell "misformatted" from
"nothing was checked". Reproduced at 7cef9f8.
scripts/fmt-workspaces.sh discovers every [workspace] root and checks each
with `-p <member>` scoped by that root's own --manifest-path. Exit 2 is a
usage error or an unrunnable cargo; exit 1 is a formatting verdict.
Both obvious spellings were measured and both fail in the direction that
looks like success: `--all` crosses the boundary, because it formats local
path dependencies and harness/opfs-contract depends on improvise-io by path
(an injected fault in src/draw.rs was reported by the harness root); and the
bare --manifest-path form is blind to workspace members, because the repo
root is a package as well as a workspace (an injected fault in
crates/improvise-core exited 0).
scripts/fmt-workspaces-test.sh is the check_the_checker. Four rivals were
injected and each failed it. Its fixture had to be corrected twice -- a
missing path dependency and a virtual outer manifest -- and both vacuities
were found only by injecting the rival, never by reading.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
From PRs #143/#146/#147/#150/#152/#156/#157 and the adversarial review of the
first five.
The finding worth the review's cost: `Repr::Nonfinite(&'a str)` could only be
produced by a deserializer that borrows from its input, so the new wire
spelling round-tripped through `from_str` and `from_slice` and nothing else —
the same JSON document escaped as `inf` was refused, and `to_value` did
not round-trip its own output. Both of the PR's tests used `from_str`, which
is precisely the one path that borrows.
Four vacuity shapes, each confirmed by injection rather than argued:
- A disjunctive assertion can accept the bug it guards — one branch here was
exactly the error message the defect produces.
- A law asserted over an immutable `CmdContext` is a tautology: a `Cmd` is
pure over its context by construction, so `execute` twice without applying
effects asserts `f(x) == f(x)` and no rival can fail it. Applying the
effects REFUTED this one rather than rescuing it.
- A rival must fail the property, not the setup — `AddFormula` and
`RemoveItem` are both `CellData`, so the obvious test proved the neighbour.
- Every agent appends to the same evidence file at EOF: four conflicts in ten
PRs. Loud, but the resolution is the hazard, since `--check-evidence` cannot
see a dropped entry.
kb_consistent: TRUE, 803 facts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Conflict was context/test-evidence/command.md only — the third evidence-file
conflict in this batch, since every agent appends at EOF. Both sides kept and
verified line-by-line against both parents.
1727 passed / 0 failed over 38 binaries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`Repr::Nonfinite(&'a str)` could only be produced by a deserializer that
borrows from its input, so the new wire spelling round-tripped through
`from_str` and `from_slice` and through nothing else. Measured on the
pre-fix head:
from_str {"Number":"inf"} -> Ok
from_str {"Number":"inf"} -> Err (the SAME JSON document)
to_value then from_value -> Err (does not round-trip its own output)
from_reader -> Err
The escaped form is the serious half: escaping is not a distinction JSON
makes, so the wire accepted a subset of the encodings of the spelling it
claims to share with the file. `Cow<'a, str>` fixes all four; the serialized
form is unchanged.
The PR's own tests could not see this — both use `from_str` exclusively,
which is precisely the one path that borrows. A check adjacent to the
question it names, inside the change that defines the question.
Also tightened `a_string_that_is_not_one_of_the_three_tokens_is_refused`: its
second disjunct accepted `data did not match any variant`, which is exactly
the message the above bug produces, so it passed in both worlds.
Conflict was `context/test-evidence/core.md` only, against #143. Both entries
kept and verified line-by-line — neither test cites the file, so a dropped
entry would pass `--check-evidence`.
Gates on the merge: 1728 passed / 0 failed / 7 ignored over 38 binaries,
clippy -D warnings clean, fmt clean, and `cargo check --target
wasm32-unknown-unknown -p improvise-core` clean — the one gate the review
asked for and could not run.
Found by adversarial review (confirmed by injection, not reasoned).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Conflict was `context/test-evidence/gp.md` only — this PR and #146
(improvise-ipqu) appended at the same point. Both entries kept, verified
line-by-line against both parents, because --check-evidence cannot see a
dropped entry: it checks that every heading names a running test, and neither
of this PR's tests cites the file, so losing one would pass the gate.
The format string was broken across source lines with no `\` continuation, so
eighteen spaces were baked into what a user reads on --predict against a
corrupt model. Found by adversarial review, which printed the message rather
than reading the source.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`Dirty::of(ModelChange::CellData)` reports `formulas: true`. That was deliberate
over-reporting in the cheap direction, costing one redundant `Projection` and
buying nothing — until improvise-g3no made `Model::remove_item` drop the formulas
targeting the item. `effect::RemoveItem` is `CellData`, so a deletion can now
take a formula out of the document, and tightening that arm would leave every
connected client's formula panel naming a formula the document no longer has,
forever, with nothing in the stream to correct it.
`removing_a_category_empties_the_clients_formula_panel` covers the
`LayoutGeometry` twin only. This is its item-level sibling.
The setup is deliberately not a mirror of the sibling's. Written that way —
connect, `AddFormula`, `RemoveItem` — it does fail under the rival, but on the
SETUP assertion, because `AddFormula` is itself `CellData` and with the arm
tightened the formula never reaches the client at all. That answers whether
`AddFormula`'s projection arrived, which is the neighbouring question. So the
formula is added before `Sessions::connect` and the client takes it from the
bootstrap seed, which no `Dirty` gates; the rival then lands on the assertion
that means something. The sibling does not have this problem against its own
rival and is left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
From PRs #144/#148/#151. `AGENTS.md`'s remote note gains the worktree-base
vector: a branch cut from the orchestrator's local `main` inherits whatever
that checkout merged but did not push, and nothing in the branch says so —
which is how #148's diff came to name the three orchestrator-only files and
read as a rule violation rather than a base problem.
Four facts. A fold test must fold the batch the producer really emits — a
`Binding::Sequence` flattens into one vec, and a per-step batching cannot see
intra-batch order at all. A type name is not a field, measured: one 14-site
rival left 14 of 16 assertions passing, while an assertion on a *fieldless*
effect is complete rather than smoke. A registry with two arms demotes the
weaker caller, and neither direction is safe by default. And a clean text
merge can delete an evidence entry that no gate checks.
Two gate traps: `--check-evidence` prints the comment cap FIRST, so reading it
through `tail -1` drops the half that was always there; and a compound command
refused by the worktree shell guard runs NEITHER part.
kb_consistent: TRUE, 798 facts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`law_toggle_group_involution` called `execute` twice against the SAME immutable
`CmdContext`, so it asserted `f(x) == f(x)` for a deterministic `f` — an
assertion no rival of any kind can fail. It was vacuous twice over: its fixture
`two_cat_model()` has no groups, so both sides were the empty string.
Applying the effects between the calls, which is what the bead asked for, does
not rescue the law — it refutes it. A collapsed group contributes no
`AxisEntry::DataItem`, and `row_group_for` is indexed by data row, so with two
groups on the row axis and the cursor at row 0: `z` collapses Q1, `z` again
names Q2 (the neighbour, not the group just collapsed), and a third press
returns nothing because the axis has no rows left.
So this replaces the law with three tests of what the command does, each naming
a category and a group by name. Both rivals were run: an `execute` that always
returns `ToggleGroup { Month, Q1 }` — the stateless one the old test exists to
miss — passes the old law and fails two of the three; an `execute` returning
`vec![]` fails all three.
The behaviour those tests now pin is unchanged and is filed as improvise-6qet:
a collapsed group is drawn but not addressable, so there is no way back to it
from the grid. It is the third bead in that family after improvise-lhi4 and
improvise-0o6x, both of which fixed the header's visibility rather than its
addressability.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`view-forward` was bound in NO keymap, in either front end, from the day it was
written. The TUI reached it as `:view-forward`; a browser could not reach it at
all, because `declined()` refuses to enter CommandMode at the door. It survived
because pressing `<` twice is the only way to observe the forward stack, and
until PR #115 no client maintained one.
THE KEY IS `,`, AND `>` IS WHY THE OBVIOUS ANSWER IS WRONG. `<` is `view-back`
and `>` is `drill-into-cell`, so the pair that LOOKS symmetric is not one: `>`
moves within the grid, `<` moves between views. Binding the forward half to
anything reading as `>`'s partner would deepen the confusion that hid the gap.
`,` is the unshifted twin of `<`, so both halves of the history sit on one
physical key, and it carries no direction-in-the-grid meaning.
IT IS DELIBERATELY NOT A CTRL- KEY. Ctrl-R, the universal "forward through a
history" spelling, is browser reload. The keymap is SHARED between the two front
ends, so a key chosen for the terminal is one the browser must live with, and
improvise-clso is already open for exactly this against Ctrl-S.
The absence assertion PR #117 placed in
`the_forward_step_mirrors_its_cmd_and_no_key_reaches_it` is INVERTED rather than
deleted: renamed `..._and_one_key_reaches_it`, it now asserts the binding exists,
keeps `view-back` as the control, and pins the key itself so the binding cannot
drift while the presence check stays happy.
Reasoning: context/code/tui.md.
Evidence: context/test-evidence/{command,clients}.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Conflict was context/test-evidence/command.md only: improvise-gjat (PR #144)
and improvise-lm5e appended different sections at the same point. Both sides
kept — the entries are disjoint, 16 from lm5e and one from gjat — and verified
line-by-line that neither side lost content, because a clean-looking textual
resolution is exactly how evidence entries go missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
The comment added with the previous commit ran to three lines and
scripts/check-comment-runs.py failed the `test` job on it. The reason it was not
caught locally is worth recording: --check-evidence is ADDITIVE, so the run does
answer the cap, but the output was read through `tail -1` and the cap's line is
the FIRST of the two. A gate filtered down to the line you expect is not a gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`App::switch_to_view` is guarded — it writes `active_view` only if the document
has the view. `SwitchView::apply` pushed the leaving frame onto
`view_back_stack` BEFORE calling it, under the prediction `previous != self.0`.
That predicate answers "did the user ask for a different name", where the
history means "did this client go somewhere". The two differ on exactly one
input: a name the document has not got. So `:view NoSuchView` left the user
where they were with their own current view pushed onto their own back stack —
`<` then "went back" to where it already was, and the real frame behind it
needed a second `<` nobody knew to press.
The wire path was already correct and is not the model for the fix: `reduce_view`
cannot check (the client holds no `Workbook`), so `apply_switch` relays the exact
inverse `[ViewBack, ForgetView]` (improvise-6c6q). The host holds the document,
so it does not need an inverse — it needs to look. `apply` now clones the
previous name, switches, and pushes only if `active_view` actually differs
afterwards.
That is `ReturnToNormalUnlessMoved`'s rule one effect over, and the same answer
`cursor_epoch` gives instead of matching on `CycleAxis` and `document_generation`
gives instead of matching on `LoadModel` (gotchas 24, 39, 45): observe the state
that moved, never predict it from the request. It also subsumes the case the
prediction was written for — switching to the view you are already on changes
nothing — so the special case is gone rather than joined by a second one.
A guard spelled here as `workbook.views.contains_key(&self.0)` would have been
correct today and a second copy of `switch_to_view`'s own condition, free to
disagree with it the day that condition grows a clause.
Reasoning: context/code/command.md. Evidence: context/test-evidence/command.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH