220944e26c69c6066a68d33ecd206efeb58f1784
1259 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5e957e1f58 | Merge remote-tracking branch 'gh/main' into wave3/protocol-session | ||
|
|
6c70dba910 |
Merge pull request #77 from fiddlerwoaroof/fix/render-frame-wave
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
fix: six render/frame defects — group membership, leading headers, one run rule, and the hidden status line |
||
|
|
6f98bfd995 |
Review round 1: green CI, the SortData hole reopened, the paging regression removed
Four findings from the adversarial review of #78. Two blocked; all four are
real and one of them was a straight error on my part.
1. CI WAS RED AND I NEVER RAN THE CHECKS. Nine comment runs over the
two-line cap, all mine, all introduced this PR; both checks exit 0 at
|
||
|
|
970bae8ad5 |
docs: the geometry-visibility evidence said three and listed four
`every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint`'s entry counted its `prepare` cases wrong, and the count is the load-bearing part: four of the thirteen assert nothing without a precondition, so an entry that reads "three" invites a reader to find the fourth redundant and delete it. Names why each one is needed — `ToggleGroup` checks membership and is otherwise a no-op, `TransposeAxes` on two identical axes is the identity — and states that clearing `dirty` after `prepare` rather than before is what stops the preparation from satisfying the assertion on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
bd3967cc7c |
fix: guard improvise-mai9 at the call site, and correct §3's group_of
Review of PR #77 found the mai9 fix unprotected and one doc paragraph contradicting another. Both were right; both are addressed here. The guard was aimed at the wrong thing -------------------------------------- The mai9 defect was in the WIRING — `draw_bottom_bar` built `format!("{}{}", mb.prompt, buf)` plus the cursor glyph and never consulted `status_msg`. Both tests called the composing helper `minibuffer_line` DIRECTLY, so nothing exercised the call site. Reverting that one line restored the bug in full with the suite green at 1411, reproduced here before rewriting. The helper did not even go dead: the test module's `use` kept it alive while it asserted nothing about the product. `a_status_message_reaches_the_bottom_row_while_a_minibuffer_is_open` now renders `draw_bottom_bar` through a real `Terminal<TestBackend>` and reads the row the terminal received. That is the only assertion that can fail for the real defect. `the_minibuffer_prompt_is_not_displaced_by_the_message` is beside it because a "fix" that dropped to `draw_status` would satisfy the first on the message alone. Injected fault: the pre-mai9 `format!` — both fail, the first naming the missing message and printing the row it did compose. And the negative arm could not fail, twice over ----------------------------------------------- `fill_line` computes `pad = width - left.len() - right.len()`, so with an empty status an unconditional `format!(" {msg} ")` produces a BYTE-IDENTICAL row at any width where the line fits. The `match status_msg.is_empty()` is observable only in the band where the prompt fits but `left.len() + 4` does not. Rewriting it against that band was not enough: the first attempt derived its lower bound by calling `minibuffer_line` itself, so the injected fault moved the bound with it and the loop skipped the failing band entirely. The bound is now spelled locally. A bound read off the function under test is not a bound. Injected fault: `right` built unconditionally. Now fails at width 16 with "an empty status still composed 20 columns". context/code/protocol.md contradicted itself, 270 lines apart ------------------------------------------------------------- §3 still said `group_of` answers "nearest preceding header", DELIBERATELY, and that encoding an end boundary "is filed rather than smuggled into" the anr9 migration — while §3a, added by this branch, says `GroupEnd` is the boundary. A reader arriving at §3 first would conclude this PR smuggled in exactly what that paragraph forbids. It did not; improvise-gd83 IS the filed ticket. §3a was written as an addition where it needed to be a CORRECTION, and gotcha 48 turned on its author — worse than the `bindings.rs` case only because both copies sit in one file. §3 now records that the rule changed and which ticket changed it, and §3a points at the correction rather than restating the history. The neighbouring paragraph was stale too: `body_from`'s argument no longer exists "only because the two front ends scroll differently". Filed, not fixed ---------------- * improvise-1wl0: `#[serde(default)]` on `AxisFrame::first` forges the permissive claim — `first: 0` asserts "this frame leads the axis", which is what `body_from` acts on, so a frame that loses the field deserializes as leading. Same argument that gave `Basis` no `Default`. No skew ships today. * improvise-h0iv: `group_runs` coalesces by name alone where `FrameGroup` has `cat` to fix it. Right call for a consolidation — both prior copies did the same, and changing the rule while moving it would have cost the agreement test its evidence — but a single home reads as decided, which is the new risk and the reason to file. Both are cited in `context/code/protocol.md` §3a, so the single home does not read as settled. 1412 tests pass (was 1411; +3 wiring tests, -2 helper-only tests). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
87696d8b9b |
command: guard the page-flip no-op, and stop the geometry arm walking the cells
Two review findings on #76, both about code this PR itself introduced. **`SetPageSelection`'s `moved &&` guard was unguarded.** Deleting it left the whole workspace green at 1416, and it is not an edge case: `PageNext`/`PagePrev` are odometer-style and emit a `SetPageSelection` for every page category on every keypress, so the non-carrying categories are always no-ops and a single one-item category re-selects the same item. Unguarded, each such keystroke bumps `cursor_epoch`, sends the host cursor home and broadcasts `ResetCursor` to every client on that view — improvise-75ld's "a no-op that refused everybody else", one tier down. `selecting_the_page_already_selected_invalidates_no_cursor` fails under exactly that deletion, and asserts the fixture is really on Q1 and the row labels really did not move first. **The `LayoutGeometry` arm no longer reads the wide fingerprint.** Pointing both arms at `document_content` was an asymptotic change on the held-key path, not the constant factor the first hand-back claimed. Benchmarked at 18,000 cells, release, 200 batches after warm-up: a `SetCell` batch 8.07 ms → 10.21 ms (+27%, a constant factor, since `record_fingerprints` already walked the cells), but a `SetNumberFormat` batch 78 µs → **2.63 ms, 33×** — O(config) to O(cells+config), covering `[`/`]`, axis cycle, hide/show, group toggle, transpose and prune toggle, ~380/sec where they had been ~12,800/sec. `document_config` comes back for that arm and `document_content` is now `document_config` plus the cells and formulas, read only by `CellData`. It costs no correctness because all five 748f-blind effects are `CellData` — but that is a reading, and a reading is what this PR exists to distrust, so `every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint` asserts it over all thirteen. Dropping `view.number_format` from `document_config` fails it naming `SetNumberFormat`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
9e37213cd4 |
fix(docs): re-record the four casts from a script, with no shell to leak
improvise-2oxt. All four casts under docs/casts/ opened and closed on `(0) Mac:<hostname> ~gf/u/edwlan/improvise` — a real hostname and home directory on what improvise-kh8 made the site root — and `formulas.cast` and `import.cast` showed that prompt as their visible OPENING frame. drill.cast also predated the records columns entirely, showing a single `Value` column where the current build draws a column per measure plus `+cat` and `+`. The repair is structural, not careful. `docs/record-casts.py` drives `improvise` on a pty it owns and writes asciicast v2 itself, so `improvise` is the pty's ONLY child and there is no shell for a prompt to leak from — nothing to remember to clean, and nothing that comes back when somebody re-records on another machine. Three other things stopped being accidents with it: the geometry is an input (120x37, the numbers docs/index.html already spelled, so they did not change), the pacing is a per-step table, and the header carries no `env` (which held $SHELL) and no `timestamp` (the recording machine's clock). Two decisions that each cost a re-record. No `:q` is typed at the end: the player loops, so the last frame is also the frame before the first, and a minibuffer reading `:q` is what it would show — the process is signalled instead. And each scenario had to be ARRANGED so the thing it names is visible: the drill cast drilled into a cell with no records behind it, and the pivot cast pressed `c` on a category already on the column axis. Both recorded cleanly and demonstrated nothing — true frames about the wrong thing — and neither was visible from the keystroke list. That is the method worth keeping: a cast can be replayed through a small ANSI reconstruction and READ AS TEXT, so "what does frame N show" has an answer rather than a guess. It is how the `+cat`/`+` columns in the new drill.cast were confirmed present, and how both dud scenarios were caught. The guard is `landing.spec.js`, asserted over the cast FILES: no header carries `env` or `timestamp`, the geometry is the one the page configures, and no chunk contains an OSC window-title sequence — the leak's signature, emitted by a shell once per prompt with the cwd in it and by `improvise` never. Asserted over the file rather than the rendering because a player draws a prompt as ordinary text, so a screenshot assertion would have to know what the hostname had been. BOTH clauses were injected separately, because the `env` one would otherwise mask the OSC one: the old cast fails on `env`, and the old cast with `env` stripped fails on the title sequence. docs/index.html loses the `p.dated` note admitting the drill cast was out of date, which is now false. Tests: 33 harness + 3 skipped (was 28 + 3). Rust and node unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
7f58800c0f |
Check improvise-lltt's deferral premise, and find it already false
improvise-lltt is deferred on a CONDITION its own note spells out — "it is
unreachable today ONLY because no shipped client emits Command::SwitchView"
— and asks for an interim guard asserting exactly that, so the silent
failure becomes loud at the moment the premise stops holding.
The guard fails on the first run. Two live paths, both in the browser's
own resolver (resolve.rs:359-368):
VIEWS: switch-view-at-cursor -> Command::SwitchView
VIEWS: create-and-switch-view -> CreateView + Command::SwitchView
and the other half was already known live: resolve.rs:331 composes
ModelCommand::TransposeAxes locally. So the scenario the note describes is
reachable today from the shipped view panel. lltt escalated P2 -> P1 with
the evidence on the ticket.
The guard lands as `the_keys_that_compose_a_switch_view_are_these`, an
assert_eq against those two names rather than an assert-empty: it pins what
is true, fails on a third live path, and says in its message that the list
is not an exemption list. When lltt is fixed the list empties and this test
goes with it.
Not fixed here on purpose — the honest fix is the protocol change lltt
describes, which is a work unit of its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
585f25bf78 |
Invalidate the cursor by observing the grid's frame, not by a per-effect table
improvise-ni6k. Twelve effects called `app.reset_cursor()`; that table is
the shape gotcha 24 names as the disease, and it had a hole the day it
was written. The observation replaces it: `view_frames` fingerprints
every view's drawn frame, `apply_effects` diffs it per model effect, and
the cursor resets when the view it is in reframes. No effect declares
anything, and `adopt_cursor_epoch` reads the same set.
FOUR THINGS THE TICKET DID NOT PREDICT, each one a fixture or a design
question rather than a line:
1. The frame fingerprint and `layout_version` answer DIFFERENT questions,
so they are two observations and not one. `AddCategory` of an empty
category moves no row or column but does change the coordinates a
client's CellKey hangs off — layout_version must bump, the cursor need
not. `view_shape`/`last_reshaped_views` (config) is untouched and still
drives `layout_version`; `view_frame`/`last_reframed_views` (drawn) is
new and drives the cursor. Unifying them broke two tests in both
directions, which is how the distinction was found.
2. The observation must run INSIDE the effect loop, not after it. At
end-of-batch it clobbered deliberate cursor placement in the same batch
— `[AddItem(measure), <place cursor>]` sent the cursor home, failing
three records-mode tests. In-loop, a later effect wins, which is the
same ordering property gotcha 45 gives `document_config`.
3. `GridLayout::new` does NOT apply `prune_empty`; `with_frozen_records`
does. A frame hash built on `new` cannot see a pruned row, which is
exactly the class improvise-7kvw is in.
4. Two per-effect no-op guards were carrying the whole weight and are now
orphaned: `effective_items` and `item_is_drawn` are deleted, along with
six inline `moved`/`drawn`/`real`/`existed` predicates.
COST, measured as the ticket required, `benches/reshape_observation.rs`
(new), one SetCell batch, same fixtures as `grid_projection`:
typical 400 cells 172µs -> 206µs (+20%)
large 9.6k cells 3.70ms -> 4.17ms (+13%)
18k cells 7.09ms -> 8.41ms (+19%)
pathological 90k 35.2ms -> 39.8ms (+13%)
The batch is dominated by `rebuild_layout`/`recompute_formulas`, not by
the fingerprint.
WHAT CLOSES AS A CONSEQUENCE: improvise-7kvw, asserted by
`paging_across_a_prune_that_drops_a_row_invalidates_the_cursor`.
AddFormula/RemoveFormula join the shared `reshaping_commands` table (a
formula target is a `_Measure` item, so it is a column — gotcha 7), each
proved reshaping by the table's own `assert_ne!` on the grid shape.
WHAT DOES NOT CLOSE, and this is the honest part: `SortData`. A pure
record reorder changes no label and no count, and records-mode `row_items`
are positional, so a row_items fingerprint cannot see it. I wrote the
mechanism (hashing `layout.records`' coords) and then REMOVED it, because
I could not build a fixture where `SortData` actually reorders anything —
records came back in key order whatever order the cells went in. Shipping
a confident comment over a mechanism I could not demonstrate is this
repo's named defect, so it is reported instead. See the hand-back.
Negative arms unchanged and still passing, including the refused-command
arm and `SetNumberFormat`; disabling the observation fails the whole
reshaping table, which is the proof it is what does the work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
47a070fec4 |
docs(clients): three "free" bindings priced, and what a parsing client costs
improvise-4bwa and improvise-cl7y. Neither can be CLOSED from this crate — both
land in `improvise-protocol` and `improvise-command`, which another agent owns
in this wave — so what lands here is the part that was actually missing: the
design of each, and a measurement nobody had taken.
improvise-4bwa. `view-back` is DECLINED in writing, which its own acceptance
criteria allow and ask for. It is not an enrichment at all: `ViewNavigate
{ forward: false }` emits `ApplyAndClearDrill` first, whose operand is
`drill_state.pending_edits` — session-local residue routed with no `SessionId`
— so a client that mirrored it would commit somebody else's uncommitted typing,
the exact thing improvise-3x3u removed. Blocked on ADR 001 layer 2.
The sharper reading, which the ticket did not have: `mirror_the_cmd_line_for
_line` is a rule about RESOLVERS, and a `Cmd` whose first effect is
unaddressable is not a `Cmd` a thin client may mirror at all. The doctrine does
not conflict with the decline — it does not reach it.
The other two are recorded with their shapes. `toggle-prune-empty` is one bool
on a per-view projection; the `ModelCommand` is composable today and it is the
SENTENCE beside it that a client cannot write, so mirroring it diverges on the
status line in half of all presses. `delete-formula-at-cursor` needs
`FormulaCache`'s ELEMENT TYPE to carry the target and its category — a `targets`
vector beside `formulas` would be two answers to "what is in the formula panel",
improvise-edp's disease and the one improvise-anr9 had to pay a type change to
undo.
improvise-cl7y. Its note leaves the dependency route's cost unmeasured, so it is
measured: `improvise-formula` added to this crate, `parse_formula` called from
an exported method so nothing is stripped, bundle built both ways.
today 503,854 raw 191,613 gzipped
with improvise-formula 634,515 raw 238,973 gzipped
cost +130,661 +47,360
(+25.9%) (+24.7%)
A quarter of the download for a status string — evidence for the other route,
the host addressing a status back to the composer as `ClientFrame::Save`'s
rejection already does. What the number does not settle is the more interesting
half: a parsing client is a SECOND DERIVATION of whether a formula is well
formed, agreeing with the host only while both call the same function. So it is
a floor, not the price of a feature.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
9421eb207c |
fix(docs): the landing page's casts are legible on a phone
improvise-o31u. On a 390px viewport all four players computed a terminal font size of 4.83258px — reproduced to five digits before the fix. The player DERIVES its font size from the width it is given, and 120 columns into the ~356px inside the page's gutters gives about 3px per character. Since improvise-kh8 this page is the SITE ROOT, so that is the first thing a phone visitor sees. Under `max-width: 900px` the player is allowed to overflow INSIDE `.player` (`overflow-x: auto`, an 880px floor on its child) rather than being squeezed to fit. Measured after: 11.9457px, matching PR #47's reverted measurement. The instrument is the finding and is worth more than the CSS. `getBoundingClientRect` reports a height for a `content-visibility`-hidden child and heading text overflows its box without widening it, so neither "the element is present" nor a box measurement can see this. The COMPUTED FONT SIZE is what a reader sees, so that is what is asserted. Two guards on the trade this fix must not make: the document's own `scrollWidth - clientWidth` stays 0 — a page that scrolls sideways on a phone is worse than one unreadable section — and `#cast-pivot` must be the thing that scrolls, which separates "contained the overflow" from "removed it by shrinking something else". The desktop arm is not ceremony either: the same query written without a `max-width` bound, or with the floor on `.player` rather than its child, changes 1280px too. Tests: 4 landing tests (was 2). Rust and node unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
852e3f8659 |
fix(web): a selection driven below the fold scrolls itself into view
improvise-jq8l. `web/app.js` called `scrollIntoView` NOWHERE, so a selection moved past the bottom of `#grid` stayed there, invisible, with no way back but scrolling by hand. What the ticket did not predict: this is NOT a projection bug and NOT a `row_offset` bug, and the acceptance criteria's talk of reconciling the absolute selection against the announced window describes work `render.rs` already does. The row had been sent. `#grid` has `overflow: auto`, `ROW_PX`/`COL_PX` UNDER-estimate (a real row measures 28.6px against the assumed 26) and inline group rows are deliberately not charged for at all, so the page renders more rows than its own box can show and the overflow is the DOM's. `block: "nearest"` is the whole option: it scrolls only when the element is out of view, and it scrolls the nearest container rather than the document. The expensive half was the fixture. Neither shipped document can reach the state — `web/demo.improv` is four rows, `harness/fixtures/grouped.improv` is eight, and a probe measured BOTH at `scrollHeight === clientHeight` in a 484px box. The assertion would have passed against a page that never scrolls, which is why the spec asserts the overflow exists before it presses a key. `harness/fixtures/tall.improv` is thirty rows in three groups; measured against the unfixed page it gives 105px of overflow with the selected cell 96px below the visible bottom and `scrollTop` still 0. Tests: 25 chromium harness (was 24), Rust and node unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
8f3a9768b7 |
fix: group membership, leading headers and one run rule on AxisFrame
improvise-gd83, improvise-0o6x and improvise-8qlr. One commit because they are one change to `AxisFrame`/`AxisEntry` — a variant, a field and a method — and all three move `tests/grid_frame_agreement.rs`, whose two ends must be changed together or the test stops comparing anything. improvise-gd83 — AxisEntry carries a group-end boundary ------------------------------------------------------- `expand_category` emitted a header on ENTERING a group and no boundary on leaving one, so `group_of` answered with the nearest preceding header rather than membership, and the TUI painted that span. Copilot was right on PR #23; this is the filing gotcha 44 said the fix needed. `AxisEntry::GroupEnd` is the boundary, carried onto the wire as `FrameEntry::GroupEnd`. Two arms, and a fix can close one and miss the other: * within one expansion — Jan[Q1], Feb[Q1], Sep — a `Some -> None` transition; * across expansions — `cross_product` expands the inner category once per prefix and each call starts with `last_group = None`, so Food/Bob[Family] is followed directly by Clothing/Carol with no transition to hook onto. That needs a `GroupEnd` at the END of an expansion whose last item was grouped. `GroupEnd` is NOT emitted between two groups: the next header already ends the previous run, and emitting one there moved `data_row_to_visual_skips_headers` and its column twin for no gain. Those two are untouched, which is the evidence the boundary is as narrow as it claims. A trailing marker is trimmed, since the axis itself ends the last run. Nothing draws a `GroupEnd` and it consumes no data index. `THE_MEMBERSHIP_CORRECT_SPANS` and its `assert_ne!` tripwire are gone, and the value they predicted is now asserted element for element — it was right to the element, which is worth recording. improvise-0o6x — a leading group header is drawn ------------------------------------------------ `body_from(idx)` started at the first ITEM, so at the top of an axis a leading `FrameEntry::Group` was dropped on the way to the screen, in both front ends. A collapsed group first on its axis therefore had its rows hidden and its header nowhere: no affordance to get them back. It is a boundary condition as the ticket suspected, but the boundary is not `idx == 0`. The windowing rule carries the nearest header ABOVE a scrolled window for `group_of` and `body_from` discards it — correctly — and a frame cannot tell that from the top of the axis using `idx` alone, because the browser's frame is already windowed and always passes `0`. So `AxisFrame` gained `first`, the absolute data index of its first item, and the rule is `first + idx == 0`. Without it the browser would draw an extra header row at every scrolled position that the TUI does not. RE-PINNED DELIBERATELY, the improvise-q0vb acceptance shape: `GROUPED_FRAME` in `src/ui/grid.rs` moves by exactly one line — the leading `▼ Co…` now draws above `East` and everything below shifts down one. The group line, the column headers, the separator, the data rows and the totals strip are byte-identical, which is the evidence this is the boundary condition and not a reshuffle. `harness/tests/grouped.spec.js` asserted TWO group rows against a fixture with three, naming this ticket on the line and saying the count becoming 3 would be the record of when it was fixed. It is 3, verified in Chromium. Three assertions in that file took "the first tbody row" to be a data row; a `firstDataRow` helper now skips the header, which is the same re-pin. improvise-8qlr — the run rule lives once ----------------------------------------- Column-group run coalescing was derived in `src/ui/grid.rs` and again in `improvise-wasm-client/src/render.rs::col_groups`, held in step by a comment saying so. `AxisFrame::group_runs`/`group_run_heads` is the one home and both ends call it. Runs still coalesce by NAME, which is what both ends did before and is preserved rather than quietly changed. The TUI keeps one divergence, stated: it labels its first VISIBLE column even mid-run, or a scrolled grid draws an unlabelled group line. The agreement test's TUI arm was `group_line.contains("▼ Q1")` — the unpinned arm the ticket names. It now compares the painted group line cell for cell against spans derived from the browser's own runs. The test is fixed, not deleted. Injected faults, each failing by name ------------------------------------- * gd83: delete either `GroupEnd` emission — the two new layout tests fail with the leaked group, and the column-header agreement test fails with them. * 0o6x: force `body_from`'s match to `false` — `the_whole_frame_is_unchanged` and the new leading-header agreement test fail. * 8qlr: the ticket's own recipe, forcing the TUI to emit the group label on every column. `cargo test -p improvise` was GREEN before this commit and now fails with ["▼ Q1","▼ Q1","",...] against ["▼ Q1","","",...]. What the ticket did not predict ------------------------------- A collapsed leading group has no item to carry its header into a window, so it reaches the frame only through `axis_frame`'s `pending` queue. The first version of the windowing change had `GroupEnd` CLEAR `pending` above the window, which deleted exactly that header — the affordance 0o6x exists to restore. `GroupEnd` is now queued like a header and clears nothing. Found by driving the new agreement test in both collapse states; the uncollapsed arm alone passes against the broken version. 1411 Rust tests pass (baseline 1405, +6). Playwright 28 passed + 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
94a3d1754b |
fix: the minibuffer row carries a pending status message (improvise-mai9)
`draw_bottom_bar` rendered the minibuffer INSTEAD of the status line, so a status message written while any text-entry mode was open was never drawn — not truncated, not overwritten, simply never rendered. A test asserting `app.status_msg == "..."` passed in every one of those cases, which is why this survived improvise-6t3's careful ordering of `CommitBuffer`'s effects so a failure message would win: it won a race to a surface nobody could see. Of the ticket's two candidates, the row is shared. `minibuffer_line` composes the prompt and buffer on the left and any pending `status_msg` right-aligned, so the bottom bar stays ONE row and the exact-frame geometry does not move. The alternative — making the caller structurally unable to report while its minibuffer is open — is what `CommitSave` does by hand today, and generalising it means repeating that at every text-entry commit. The ticket calls that a workaround; sharing the row costs one function and covers all eight modes at once, including the `:w /bad/path` path Copilot found on PR #27. The failure is now loud: `a_status_message_set_while_a_minibuffer_is_open_is_ on_the_bottom_row` asserts the composed row, not the field. Its negative arm is not decoration — a composition that appended unconditionally would pass the first test and draw two spaces of nothing on every minibuffer keystroke. Injected fault: the `false` arm returning `String::new()`, i.e. the old composition. The first test fails naming the missing message; the second passes. Known and out of scope: `fill_line` pads on `left.len()`, which is bytes, so the right-hand text sits two cells right of its mark whenever the left carries the multi-byte cursor glyph. `draw_status` has always done this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
fc059c7ce1 |
fix: mode_style matches AppMode exhaustively (improvise-zx8t)
The repo map promises of `AppMode` that "add a variant and the compiler names
every one you must fill in". That was true of the five enum methods and false
of `src/draw.rs::mode_style`, which swept eleven variants into a `_ =>`
wildcard — so `SavePrompt` took `ExportPrompt`'s DarkGray by accident rather
than by decision, and the next person adding a variant would have trusted the
sentence.
Of the ticket's two options, the first: `mode_style` is now exhaustive and
stays in `draw.rs`, because `Style` is a ratatui type (gotcha 26). The second —
a semantic colour method on `AppMode` in `improvise-protocol`, the
`AxisColor`/`MinibufColor` shape — is NOT taken here, and deliberately: the
ticket says to decide it together with improvise-k099, which asks the same
question of `hint_text`/`label` and is not in this unit. The repo map's promise
needed exhaustiveness; the semantic enum is a separate, larger answer and stays
open.
No colour changes. The fourteen default-chrome variants are spelled out with
the DarkGray they already had, so this is the wildcard becoming a list.
The guard is the compiler, not a test — a test enumerating today's variants
would not fail when a fifteenth is added. Verified by deleting the
`AppMode::Help` arm:
error[E0004]: non-exhaustive patterns: `&AppMode::Help` not covered
Also removes the doc comment calling the wildcard "a known defect", which this
commit makes false, and updates `context/code/tui.md`, which said the same.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
17e59335eb |
fix: one answer to "is this category virtual" (improvise-bidq)
`View::on_category_added`'s early return read
cat_name.starts_with('_') || self.is_records_layout()
— the same expression mixing both derivations one operator apart, the left a
name prefix and the right `CategoryKind`. A reader who checks this line sees
the enum being used and has no reason to look at the operand beside it. The
second site, `holds_a_regular`, had the same split.
`Model::add_category` does not validate names, so a user category literally
named `_Foo` was `CategoryKind::Regular` to `records_columns`, `cell_id` and
`is_empty_model`, and "virtual" to these two lines — seated on `Axis::None`,
never appearing in a pivot, while every other consumer treated it as ordinary.
Decided explicitly, as the ticket asks: `add_category` is NOT made to refuse a
leading underscore. The three reserved names are the ones
`CategoryKind::of_name` knows, `_Foo` is a legal document, and a user-visible
restriction is a bigger change than making one answer out of two.
Both sites now ask `CategoryKind::of_name(..).is_virtual()`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
05a28b0d45 |
Point the geometry test at the production hash, and test both abort arms
improvise-1xf4: `layout_geometry_effects_actually_change_view_config` hand-listed 6 of the 13 LayoutGeometry effects and measured them against `view_config_fingerprint`, a THIRD fingerprint existing only in test code beside the production `document_config` and `view_shape`. Since PR #70 decides `model_state.dirty` for LayoutGeometry solely from `document_config`, an effect writing something the test-only hash sees and `document_config` does not would pass and never mark the document dirty — i.e. never be saved. It is now `layout_geometry_effects_actually_change_document_config`, measured against `document_config` itself and listing all 13, counted off the `impl Effect` blocks rather than trusted. Two fixture facts the rewrite exposed and the old six never could: `ShowItem` on a visible item and `ToggleGroup` on a nonexistent group are no-ops, so `grouped_app` supplies a hidden item, a real group and a second view. `view_config_fingerprint` SURVIVES, deliberately, and is not a third derivation any more: it is used only by the NEGATIVE arm (`view_only_effects_leave_shared_state_alone`), where a hash stricter than `document_config` is what that test wants. The doc comment says so. improvise-2avl: PR #70 moved the `layout_version` bump out of the effect loop. Gotcha 40 records the in-loop position as load-bearing, and the argument that end-of-batch is safe for `layout_version` specifically was written into context/code/command.md and never tested. Both arms now are: a batch that reshaped before aborting must bump, one that aborted before reshaping must not. Proved independently — bumping unconditionally fails only the second, skipping `observe_reshape` on abort fails only the first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
1ea067c9ab |
command: dirtiness, aborts and the cursor — five edges in the effect layer
improvise-748f — a drill exited with no edits, and view-back on a document with no drill, marked it dirty. `Effect::kind` takes no `App`, so the classification cannot be state-dependent; the answer is to observe. The ticket proposed observing the per-record raw-cell fingerprint, and that is **unsound**: five of the ten `CellData` effects change the document without writing a raw cell — `AddItem`, `AddItemInGroup`, `SortData` (order is in the file), `AddFormula` and `RemoveFormula`. Observing raw cells would have left every one reading clean, which is a lost edit rather than a spurious prompt. So `document_config` is widened into `document_content` — cells in iteration order and formulas as well as the categories, items, groups and view configuration it already carried — and `CellData` joins `LayoutGeometry` in being read against it, in the loop, at the position gotcha 45 requires. `WholeWorkbook` stays declared, and that is the remaining over-report: a `LoadModel` does not move `file_path`, so the in-memory document really does differ from the file the path names. `start_drill_and_apply_clear_drill_with_no_edits` pinned the old behaviour and is inverted. improvise-z05r — `CommitExport` did not bail on an empty path where `CommitBuffer`'s three kinds all do, and `ExportCsv` did not abort the batch where `Save` and `SaveAs` both do. All three writers now report through one `report_file_write`, and the test names the three so a fourth has to come there. The commit reorders its batch too: the mode change goes at the head, because an abort at the tail strands the user in `ExportPrompt`, where the minibuffer is drawn *instead of* the status line and the export error is never seen (gotcha 21). improvise-7kvw — `SetPageSelection` renumbers the grid under `prune_empty`, because which rows survive pruning is a function of the page. It now resets the cursor in exactly that case, and only when the selection really moved: sending the cursor home on every `[`/`]` with pruning off is worse than the hole. improvise-pubt — the ticket argued the drill arm of `display_value_in` was probably unreachable with a foreign layout. It is reachable: records mode auto-detects from the axes, so an ordinary saved view gives `GridLayout::new` a records layout whose `cell_key` returns the synthetic key the arm matches. The test fails on the old code. `staged_drill_edit` now consults `drill_state` only for the host's own layout, by pointer identity, which is the question `grid_in` is actually asking. improvise-h074 — `resolve_view`'s fallback carries back the `.expect` message `Workbook::active_view()` had. The second half, the host-side `DeleteView` bypass of `adopt_deleted_views`, is untouched: it is a decision about `Sessions`/`Host`. Every guard here was proved by injecting the fault it names and watching it fail by name; `context/test-evidence/command.md` records each injection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
472f7e5548 |
command: one Panel→flag mapping, and a guard that can see a permutation
improvise-y4xg and improvise-qsmp are two shapes of one defect. `registry.rs` mapped `Panel` to a `CmdContext` field in two places and `App::cmd_context` copies the three flags out of `ViewState` in a third, and all three were demonstrated unguarded: swapping Formula and Category at the registry sites left the workspace at 1400 passing, and hardcoding the `category_panel_open` propagator to `true` left it at 1394. The registry mapping is gone — both sites call `CmdContext::panel_open(panel)`. The remaining two permutable sites are guarded from a path neither controls: the tests write `view_state`'s three fields **by name** and read back through the enum accessor on a context built by the real `App::cmd_context`. PR #70's review caught the weaker shape of this, where zeroing and reading through the same accessors passed a consistent permutation. Both faults were injected and both fail all three new tests by name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
628f43121d |
feat(web): the title bar is painted, a click survives an editor, and the twin door is gone
improvise-5jna, improvise-0x9u, improvise-seik — three browser-page defects with one shape: a thing that looks done from every check except running it. improvise-5jna. `doc_name`/`doc_dirty`/`doc_stored_as` were projected by z754 and painted by nothing. `render_surface.test.js` could not see it — it is a SUBSTRING SEARCH over the page's source, so a field named by a function nothing calls reads as painted, which is also how `model.minibuffer` shipped dead. So the guard is a harness assertion that loads the page and reads `#doc-name`, proved by writing it first (`element(s) not found`). The joining is `shell.js::documentTitle`, the TUI's own `name (stored_as) [+]` minus the chrome; the `[+]` is a node test, not a harness one, because this deployment persists on every accepted batch and clears the flag in the same round trip, so the marker is transient. UNPAINTED is now empty. improvise-0x9u. From an EDITING state a cell click did nothing at all: the mousedown moved focus, the input's blur ran `close`, `close` repainted, `replaceChildren` destroyed the `<td>` the mousedown landed on, and no `click` was ever dispatched. The old spec pressed Escape between the two clicks, so it only ever clicked from a non-editing page — this repo's characteristic defect. Worth recording: moving to `mousedown` is NECESSARY AND NOT SUFFICIENT, and the first attempt failed the same assertion with the same symptom. Removing a focused `<input>` fires `blur` SYNCHRONOUSLY, so `close` rebuilt the table from inside itself and detached the `<td>` again. `close` therefore no longer repaints, the `blur` arm checks `closed` first, and the handler `preventDefault()`s so no focus change happens at all — otherwise the default action focuses `#grid` after `input.focus()` and blurs the new editor. `close` restores the DRAWN text, since nothing repaints behind it any more. improvise-seik. `Client::viewport` was a wasm-bindgen door with no caller, kept for a reason `web/app.js` falsified in the same wave. It is one letter from `set_viewport` and fails SILENTLY — identical frame, forgotten window, every cursor key an upstream black hole. Deleted, along with the exported `init()` free function that duplicated `new Client()`. The guard is not the deletion: `wasm_surface.test.js` now reads the generated `.d.ts` and requires every declared method to be one `app.js` calls or to be named in `UNCALLED` with its reason. Proved by injecting a spare `static viewport(...)` into the built `.d.ts` and watching it fail by name. Tests: 1405 Rust (unchanged), 27 node + 4 bundle (was 26 + 3), 24 chromium harness (was 23). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
bc77e68f40 |
One classifier for a typed string, one exhaustive walk over Rejection
improvise-j7v6: the rule turning a typed string into a `CellValue` was written twice — `resolve::typed` (already moved into improvise-protocol since the ticket was filed) and `commit_regular_cell_value`'s if/else-if/else on the host's key path. The host now calls `improvise_protocol::typed`, which is `pub` for that reason. Per doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished), `a_typed_string_is_classified_as_the_host_classifies_it` did not survive as a comparison: type identity replaced its claim. It is re-scoped to the thing this crate still owes — the FRAME the string composes — and renamed `a_typed_string_composes_the_frame_that_carries_it`. The ticket's flagged risk (gotcha 34: a shared classifier that disagrees with the persistence layer is a third answer) is now asserted rather than argued: tests/typed_string_agreement.rs round-trips what `typed` calls text and what it calls a number through format_md/parse_md. Proved discriminating: dropping `pipe_quote` from the writer fails it. improvise-8uj8: `every_rejection_message_says_the_edit_did_not_land` drove a hand-written five of seven `Rejection` variants while claiming the fact held "over all of them". `Rejection::all` (the same cyclic walk `AppMode::all` uses) and `Rejection::refused_an_edit` (exhaustive) now drive it, and its claim is re-scoped to what is true: a rejection that refused an EDIT says nothing was applied, and the two Save answers — which refused no edit — say the document was not written. Renamed `every_rejection_message_says_what_it_refused`. Proved both ways: wording SaveFailed as "hmm" fails the test, which the old list could not have seen; adding a Rejection variant fails the build at basis.rs:101 and :126. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
e89b6467e1 |
Derive the AppMode lists from AppMode, and close minibuffer()'s wildcard
improvise-e1z5 (half) and improvise-g9jb.
`AppMode::minibuffer()` ended in `_ => None` where its sibling `panel()`
is exhaustive, so a text-entry mode could be added and be invisible to
`EnterMode`'s clear and to the `TextEntry` builder. It is now exhaustive.
`AppMode::all()` and `AppMode::text_entry()` are the one enumeration.
`all()` walks a total, cyclic `after()` rather than listing: with no
`None` arm, the only way to answer for a new variant is to name another,
which is what splices it into the walk. `text_entry()` filters `all()`
by `minibuffer().is_some()`, so the set is derived from the thing that
makes a mode text-entry rather than restated.
Four hand-written lists removed, enumerated by grep before the claim:
1. protocol/src/help.rs::all_modes() 17 removed
2. command/src/ui/app.rs::tests::all_modes() 16 removed — it was
missing SavePrompt, latently
3. protocol/src/resolve.rs::tests::text_entry_modes() 8 removed
5. wasm-client/tests/lockstep.rs::text_entry_modes() 7 removed — also
missing SavePrompt, latently
One list survives and is not a copy: command/src/command/keymap.rs's
`text_entry_modes_with_exits` carries commit/leave KEYS, which are not
derivable from AppMode. Its mode set now comes from `AppMode::text_entry()`
and only the keys are stated, in a match with no wildcard.
e1z5's second half needs no change: `strands_the_user` was deleted by
improvise-rmvx and replaced by name-driven `declined`. Only a stale doc
reference to it survives, at resolve.rs:1948.
Verified by injecting a throwaway `AppMode` variant: the build fails at
lib.rs:220 (`minibuffer`) and lib.rs:266 (`after`), neither of which
could have failed before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
922eb82d6b |
One home for the published basis, one fold for a mirror's active view
improvise-23qd: `ClientSession::basis_update` and the `#[cfg(test)]` `server_truth` each derived "which records does this client's grid name" from the same filter, and `server_truth` is the yardstick the fold tests measure against — so a change to which records get published moved the thing and its measure together. Both now call `published_basis`, and the property has a yardstick that is spelled out rather than derived: `the_published_basis_names_the_records_the_grid_draws_and_no_others` names North/South/East by hand and asserts an aggregate grid publishes none. Proved discriminating by injection: `.take(2)` on the one surviving filter fails that test and nothing else in the crate. improvise-w3bf: the four writers of `ClientSession::view.active_view` — `apply_switch`, `adopt_deleted_views`, and `reseed`/`connect` through `fresh_view` — each reproduced `reduce_view`'s one-line `SwitchView` arm. All four now run that arm. Proved by injecting a second statement into the arm and watching a probe read "now Default" from a pre-fix `apply_switch` and "now Second" after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
8a559b2686 |
Merge pull request #75 from fiddlerwoaroof/docs/orchestrator-batch
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
docs: the wave skill, the Copilot gate, and six doctrines a hard reset discarded |
||
|
|
9976486d69 |
docs: the help entry no longer contradicts itself
Found by review on #75. Renaming the heading to the test PR #72 restored made check-evidence green, and left the body three paragraphs down still saying "The test no longer exists" and "nothing replaced the assertion" -- one entry making mutually exclusive claims about whether the coverage exists. The checker could not see it, and that is not a gap in the checker: it validates heading-to-test and says nothing about the prose beneath, which is what a human reads. A mechanical check licenses exactly the claim it makes and no neighbouring one. The paragraph now records the gap as history -- gone for a release, invisible because the checker then read only ### headings and only Rust names, restored by #72 with improvise-igxp's stronger assertions -- and points at help.spec.js's own entry rather than promising or denying coverage itself. doctrine(a_checker_that_reads_headings_cannot_see_a_contradicting_body): a rename is a read of the whole entry, not an edit of its first line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
d2a54ac320 |
kb: restore six doctrines a hard reset discarded, and record how
Consolidates four doc branches and restores what resyncing lost. I wrote doctrines to kb.pl on local main, pushed each as a branch, then ran `git reset --hard gh/main` twice to resync -- discarding six clauses that existed only in the unmerged local commits. Nothing failed: kb_consistent passed on the reduced file, the counts simply read lower, and the loss was found only by grepping for clause names I happened to remember writing. That is the same shape as everything else this wave found. A count that looks plausible is not evidence, and the check that would have caught it -- diff the clause NAMES, not the total -- is the one this repo already applies to tests after a merge. doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch) states the two rules: write a fact once, on the branch that will carry it to main, not on a local main that is going to be resynced; and after any reset or force-move of a branch holding knowledge, re-count and diff clause names. Restored: every_wave_agent_gets_its_own_worktree, the_orchestrator_delegates_substantial_work, a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary, run_the_gate_ci_runs, an_observation_correct_about_what_changed_can_be_wrong_about_when_it_is_read, a_siblings_unmerged_pr_is_invisible_to_bd_search. Also carries: the wave skill, the Copilot-gate correction to AGENTS.md, the help evidence rename, and the code-review skill's missing -D warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
3d34b32085 |
Merge remote-tracking branch 'gh/docs/help-evidence-rename'
# Conflicts: # prolog-sessions/kb.pl |
||
|
|
65e8f2fb42 | Merge remote-tracking branch 'gh/docs/copilot-gate' | ||
|
|
3c5836c9d9 |
Merge remote-tracking branch 'gh/docs/wave-skill'
# Conflicts: # prolog-sessions/kb.pl |
||
|
|
debad21556 |
docs: run the gate CI runs, and bindings.rs is run
Two one-line corrections the wave's last review surfaced, both about a claim being stated in more than one place. .claude/skills/code-review/SKILL.md told every reviewer and every wave agent to run `cargo clippy --workspace --all-targets` with no `-D warnings`. Clippy exits 0 on warnings, so the committed local gate was green on exactly the input CI is red on. This is not one person's shell mistake, it is the instruction: a reviewer following the skill as written would report clean and be wrong, which happened on PR #71. Also adds --no-fail-fast, since fail-fast hides blast radius. README.md was a sixth copy of "bindings.rs is never run", which PR #70 corrected in five places -- its grep missed README, and PR #72 then rewrote the sentence four lines above and carried the wrong half forward. Neither PR is at fault individually; it is an interaction between two branches, and it is the exact failure the corrected fact is a monument to. Plus doctrine(a_weak_check_and_a_strong_one_agree_until_the_defect): five instances this wave, one mistake -- a check written against the failure you imagined rather than the outcome the user sees. The weak and strong checks agree on every input anyone tried and diverge only on the input that is the defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
279bf3a848 |
Merge pull request #71 from fiddlerwoaroof/wave/browser-keys
fix(web): an advertised key is never silent, a formula is visible, and +cat commits |
||
|
|
df8ee0d135 |
fix(protocol): Keymap is a test-only import, and my clippy check was not CI's
CI's `cargo clippy --workspace --all-targets -- -D warnings` failed on an unused `Keymap` import: `bound_commands` takes a `&Keymap` and lives in the test module, so the non-test lib build has no use for it. Moved into `mod tests`. Worth recording WHY I did not catch it locally: I ran clippy without `-D warnings` and grepped its output for `^(error|warning)\[`, which matches a lint with a NAME in brackets and misses a bare `warning:` — which is exactly what `unused_imports` emits. A check whose filter cannot see the failure reads as a clean run, which is the shape this repo keeps finding. Run the gate CI runs, not one that looks like it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
5fa554d712 |
docs: the help evidence heading names a test that exists again
PR #61 deleted the help spec; PR #69 demoted its evidence heading to prose as Retired; PR #72 restored the coverage under a DIFFERENT name. So main briefly carried a heading whose claim was false in both directions -- the prose said retired while the test was back, and restoring the heading verbatim would have named a test that does not exist. Renamed to #72's actual test. check-evidence: 535 entries, all resolve; 162 tests cite the file and all have one. Worth noting the sequencing, because it is the interesting part: #69 is the PR that TAUGHT the checker to see #### headings and JS test names at all, so it is simultaneously the thing that would catch this and the thing whose demotion kept the merged tree green. The demotion was correct when written and stale by the time it merged. Plus doctrine(a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary): asserting WHICH refusal fired is only as strong as that message being unique across every refusal the code emits, so enumerate the whole vocabulary rather than the line you are looking at -- and where no message can discriminate, assert the outcome, and guard the backstop separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
fe25952aed |
Merge pull request #69 from fiddlerwoaroof/wave/tooling-gates
fix(tooling): six checks that did not check (vz54, 5ray, lyyc, x4wv, 2f97, dj1l) |
||
|
|
be01339039 |
Merge gh/main (4e48a6e, PRs #70 and #72) into wave/browser-keys
Two doc conflicts, both pure appends at the same place, both resolved by keeping BOTH sides: - `context/code/clients.md`'s boundary table: #72 added the help-overlay row where this branch added the key→screen row. Six became seven, not six with one of them replaced. - `context/test-evidence/clients.md`: both branches appended entries. `README.md`, `harness/build.sh`, `harness/playwright.config.js` and `.gitignore` are byte-identical to main's — this branch never touched them, so #72's measured engine-coverage claims are intact rather than restored stale. The check that matters here is NOT the count. #72 added three spec files while this branch added one, so a total would have masked a loss. Diffed harness test NAMES across the merge instead: 16 before, 23 after, **nothing lost**. 1398 + main = 1405 Rust passing; harness 28 passed + 3 skipped; node 26. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
cd094b6dfb |
fix(kb): the dj1l cases discriminate, and a replaced rule cannot silently become an add
Five findings from the second review round, and the first is this PR's own standard applied to its own P1. **The two fixes improvise-dj1l names had no discriminating test.** Reverting `has_key` to unification, or dropping `canon_key` from the duplicate gate, left kb-test at 27 passed. Case 11 was carried by `check_total` refusing with a DIFFERENT message, and case 10 by the other half of the fix. Both now assert which refusal fired (`said()`), plus two cases aimed at each half: a rm key with a variable in a non-final position must be refused as ABSENT rather than matching everything, and a keyed FACT whose key holds a variable must ADD without excluding the siblings it would unify with. Each proved by reverting exactly one fix and watching exactly that case fail. **Keying a rule on its whole clause made `replace` on an edited rule silently become `add`** -- both arms live, the stale one still firing, and `check_total` cannot see it because `NO + NF` is correct arithmetic for an add. Reachable on the 15 `kb_error/1` arms and the `kb_text/2` rules, so editing the check improvise-2f97 adds is the first thing to hit it. `check_rule_replace` refuses and names the route (rm, then add); the negative arm -- replace of a rule present verbatim -- is tested, or the refusal would just be "replace never works on rules". **The `decision(` arm was dead code.** `cited_name` took the first field, and the repo spells `decision(<ticket>, <name>)`, whose ticket id fails `name_shaped/1` on the hyphen -- so that marker could never fire for a real citation. A second `cited_name` clause takes the second field. All three markers are now exercised. Minor, from the same round: `_JS_TEST` no longer accepts `test.only`, which disables every other test in its file, with a case asserting a heading cannot be satisfied by one; and `node-test-test.sh`'s last case no longer duplicates an earlier one. Filed rather than fixed: improvise-w8ih, extracting `web/assemble.sh`'s gate so its own absence is visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
4e48a6ecb4 |
Merge pull request #70 from fiddlerwoaroof/wave/effect-classification
fix(command): a counter is bumped on an observed change, not a declared intent |
||
|
|
4aa4e88ce4 |
fix(protocol): Ignored is a black hole too, and a decline cannot shadow working code
PR #71 review, blocking finding — and it was demonstrated rather than argued. `KeyOutcome` has TWO silent variants and the new guard watched one. It asserted `resolve(..) == Upstream(event)`, but `KeyOutcome::of(vec![])` returns `Ignored`, which puts nothing on the screen either. Degrading only the three declines no browser spec presses — add-record-row, delete-formula-at-cursor, view-back — to `Step::Declined(Vec::new())` left 1397 Rust, 19 harness and 26 node tests passing. Three of the six commands this change exists to make audible could go completely silent with the whole evidence base green. The generalisation is the part to keep. `KeyOutcome::of` turning an empty `Local` into an honest `Ignored` is good design and also a LAUNDERING step: every wrong answer that composes nothing arrives wearing the variant that is usually innocent. A guard's discriminator has to be STATE — did anything the user can see change — never the shape of the return value. That is why the `+cat` injection failed in `both_ends`, which compares documents and cursors, and the decline injection passed everywhere, where nothing compared a screen. Widening it costs ten entries, all one boundary case: `move-panel-cursor` mirrors a `Cmd` that returns nothing when the cursor does not move. `furnished` now carries three of everything with all three panel cursors seated at index 1, so the residue is zero rather than parked in the named list. Seating them at 0 and calling the `Up` results expected would have hidden the real defect in the same list as the boundary case. `nothing_declined_is_also_composed` closes the other direction, which was a doc comment. `step` reaches `declined` first, so an arm in `composed` for a declined name is unreachable — a future ticket implementing `view-back` would leave the browser saying "not available yet" over working code with nothing noticing. It is derived from `declined` itself over every `(name, args)` bound in every mode, parents and prefixes and sequence steps included, so a seventh decline needs no edit here. It found a real overlap on its first run: `enter-mode` is answered by both. Rather than move the check, both halves now hold the rule — `composed` returns `None` for a stranded mode — so removing one fails towards `Upstream` rather than towards a dead room. `Keymap::parent()` added so a test can walk the fallback chain. 1397 → 1398 Rust; harness 19 + 3 skipped; node 26. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
14757a753f |
fix(command): the observation goes in the loop, or Save cannot clear it
Review of PR #70 found a regression the 1400-test suite did not name. `observe_reshape` ran AFTER the effect loop and re-raised model_state.dirty from the document_config comparison, while `Save` clears it INSIDE the loop. So gotcha 45's ordering property -- "Save clears it in loop order, so a batch that edits and then saves ends clean" -- survived for CellData/WholeWorkbook and was broken for LayoutGeometry. [SetNumberFormat, Save] -> reported a successful save, document dirty [SetCell, Save] -> fine on both Reachable from `:format ,.2f . w` and from any Binding::Sequence pairing a geometry effect with a save. The TUI draws "Saved to ..." while :q refuses; the worker, whose persist trigger IS the flag, pays a whole-document OPFS rewrite. The exact failure class this PR set out to close, one level up. The fix is position, not logic: the comparison is taken after each LayoutGeometry effect against the value held after the previous one. `layout_version` stays after the loop, which is not the same hazard -- nothing clears it, and an aborted batch's effects never ran, so an end comparison sees only what did. Neither arm of that is tested; filed. Two more review findings: a_panel_command_writes_that_panel_and_no_other asserted THROUGH the mapping it tests -- zeroing via panel_open_mut and reading via panel_open, the same four accessors. A consistent permutation of all four is a self-consistent bijection and passed. It now reads the three field pairs by name once per panel, and the consistent permutation fails it. It had been catching only a non-injective mapping, which is precisely the injection the evidence recorded. And improvise-56ek's claim of "no second copy left to agree with" was false when written: resolve.rs carried a fourth copy of the same Panel -> ViewState match, in the same crate on the same type. Deleted; its three call sites go through ViewState::panel_open. The claim is corrected rather than overstated -- registry.rs maps Panel to CmdContext fields, a different type, and the Panel -> (cursor, length) pairs are a different derivation and lockstep's subject. 1401 tests pass. Both new guards proved by injection: reinstating the after-loop comparison fails the ordering test by name with 454 others passing; a consistent Formula<->Category permutation across all four accessors fails the panel test, where before it did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
40fc0c7dc7 |
Merge pull request #72 from fiddlerwoaroof/wave/harness-coverage
test(harness): the help overlay, a grouped fixture, the landing page, and Firefox |
||
|
|
67ce2df421 |
docs: the Copilot gate is gone, and AGENTS.md told agents to wait for it
Measured: PRs #12, #21 and #27 carry 2, 8 and 4 Copilot reviews; #61, #66, #68 and #70 carry none. Both request paths report success and register nothing -- the GraphQL mutation returns clientMutationId null, the REST call returns 200 with requested_reviewers empty, and no review_requested event appears. So "merge only once Copilot passes" was an instruction to wait forever on a review that was never requested. An absent review and an unrequested one look identical from outside, which is review_is_requested_not_inherited seen from the requester's side. Review is now an adversarial agent the orchestrator dispatches, naming the head SHA it reviewed; the Copilot mechanics are kept for the day it returns. Found by the PR #70 agent, which noticed its own review request had produced nothing rather than assuming the review was queued. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
0b7a2351b5 |
fix(tooling): a barren file is refused by node's own report, not by grepping for test(
Two findings from the PR #69 review, one class: a lexical `test(` scan is satisfied by things that execute no assertion -- a commented-out call, the words in a string, `test.skip`/`test.todo`, and an empty `test.describe`. scripts/node-test.sh drops the scan and reads node's report instead. `# pass` is 0 for a skip-only file, a todo-only file and an empty suite; and a file that registered no test is reported as one passing test WHOSE NAME IS THE FILE PATH, which is both how a barren file is detected and why `# tests` alone reads as the file count. That naming convention is an assumption about node, so the self-test pins it: a node that stops doing it fails the case rather than letting the guard go quiet. Six cases added, including a barren file hiding among passing ones. --check-evidence had the parallel hole: _JS_TEST accepted `.skip`, `.todo` and `.describe`, so a quoted heading could be satisfied by disabled coverage or by an empty suite. Now runnable leaves only (`.only` included), with three cases asserting that a skipped, todo or suite-only name leaves the heading orphaned while a test nested in a suite still satisfies one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
5c3c796308 |
docs: a wave skill, so the brief stops being retyped
Four agents were dispatched without worktree isolation and all edited the same working tree: one git status held one agent's effect.rs, another's harness/build.sh and a third's scripts/node-test.sh. Any commit would have swept up three units' half-finished work. The brief had carried that rule before and lost it, which is what a retyped brief does. It is now a skill: worktree isolation first, partition by crate rather than ticket count, the base commit by name, the baseline, the never-write list, the injection discipline, pr-watch --expect, and attribution. Two doctrines: every_wave_agent_gets_its_own_worktree records the tell and the per-agent recovery (copy only what an agent can PROVE it touched -- two units can legitimately both be in improvise-protocol); the_orchestrator_delegates_substantial_work records the user's rule and the non-obvious half of it, that orchestrator work lands in the shared primary tree and collides with agents who own that ground. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
d1ce2ecbc3 |
fix(web): an advertised key is never silent, a formula is visible, and +cat commits
improvise-rmvx, improvise-fdt6, improvise-gcvb — three faces of the same
thing: a browser offering something and then doing nothing.
rmvx. `the_command_names_that_still_need_the_host_are_these` was built with
`in_mode(AppMode::Normal)`, so it only ever asked about Normal-mode bindings:
`add-record-row` (RecordsNormal) and `delete-formula-at-cursor` (FormulaPanel)
were outside its reach entirely and could never have failed it. A named list
that silently scopes itself to one mode is a guard that looks broader than it
is. `every_advertised_command_answers_in_its_own_mode` crosses
`help::advertised` — command NAMES, so no hint string is tokenised — against
`resolve` in every mode, over a fixture furnished enough that an `Upstream` can
only mean an unhandled name. It found a seventh instance the ticket had not
predicted: `view-back` is advertised in RecordsNormal.
Six names now decline out loud, each with its own true subject, out of one
`declined` table; `stranding` returns the subject rather than a bool, so `:`
says the command line is unavailable instead of doing nothing. A declining step
takes its whole `Binding::Sequence` with it, which is what keeps `o` ("add a
record row, then edit it") from opening an editor on a row nobody created.
fdt6. `commit-buffer formula` already resolved; what did not exist was any way
to SEE the typing. `RenderModel::editing` is scoped to the two cell-editing
modes and reads the "edit" buffer by name, so FormulaEdit, CategoryAdd, ItemAdd
and the three prompts opened and swallowed every character. `RenderModel::
minibuffer` is `AppMode::minibuffer()` plus the buffer that config names, read
through `improvise_protocol::read_buffer` so gotcha 11 keeps its one home, and
`web/app.js` paints it.
gcvb. The records-mode early return was broader than its reason. A
`CellId::Create` commits the typed NAME as one existing `ModelCommand`, reads
no `record_col` and writes no cell, so the return narrows rather than
disappears. `+cat` must NOT advance: an inserted column renumbers every index
to its right and the advance was computed from the pre-commit layout, so a
client that advanced anyway would land one column off, silently. Both halves
proved by injection against `CommitAndAdvance` in `tests/lockstep.rs`.
Every new guard was proved by injecting the fault it names. The one worth
repeating: `web/tests/render_surface.test.js` went green over a page that
painted nothing, because the string `model.minibuffer` was inside a function
nothing called. The static check is a completeness check, never an execution
check — `harness/tests/keys.spec.js` caught it in Chromium on the first run.
1394 → 1397 Rust tests; harness 17 → 19 passed, 3 skipped; node 26.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
f5198c9142 |
test(harness): gate the grouped reads on a painted head
Self-review under the code-review skill: the three grouped tests read the head and the row labels in one shot, which cannot wait, while the grid is painted from the first projection and reframed by the viewport the page then announces. openGrouped now gates on the head's shape, its first group and the first row's innermost label with retrying assertions. 15/15 under --repeat-each=3, and all five injections re-run afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
f9580a33af |
docs: what the harness now reaches, and the evidence for each assertion
The engine-coverage and measurement passages in context/code/clients.md, the boundary table (four boundaries become six), and README's caveat -- which named the grouped and multi-level DOM as never executed, and it now is. A fact stated in more than one place is a grep, not an edit (gotcha 48). context/test-evidence/clients.md records, per test, what fault was injected to prove it can fail: the blanked help keys, the five grid-frame injections, the 404'd casts and the dead front-door link, and the mis-wired item-row click -- plus the two-command test-name diff that found that last one. Playwright: 26 passed + 3 skipped, 34.4s / 33.7s (was 17 + 3, 15.1s); the landing page's cast playback is most of the difference. node --test web/tests 26, cargo test --workspace 1394, both unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
ab96f3651f |
test(harness): Firefox runs the standalone deployment, and only that
improvise-bck's criterion (1) reads "Chrome and Firefox" and the standalone deployment was the evidence offered for it -- but shell.spec.js ran on Chromium alone (improvise-zkkw). The recorded reason is that routeWebSocket is slower and flakier across three engines. That argument is about the four mocked-peer tests and was never made about the two beside them: the standalone block mocks nothing, and the engine's OPFS is already exercised by opfs.spec.js. So the mocked-peer describe is tagged and the firefox project widens its testMatch with grepInvert: /@mocked-peer/ -- a tag, not a third copy of the file. Measured: 2.0s for the pair, 10/10 green under --repeat-each=5. Nothing was quieted; had it flaked, the answer would have been to leave it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
b748bb680c |
test(harness): the landing page is fetched, played and followed
docs/index.html is copied to the site root by web/assemble.sh, so the harness has served it all along with nothing asserted about it (improvise-bgtf). Counting the four .ap-player elements proves nothing: autoPlay is false, so no cast is fetched until play is pressed, and a page whose every cast path 404s constructs four players and throws nothing. The spec asserts that no .cast has been requested BEFORE pressing anything, then presses play on each of the four and waits for rendered lines and the recorded session's own banner -- and separately that four DISTINCT casts answered 200, which the rendering cannot say. Re-injecting ./casts/ -> ./cats/ fails it at the first player. The second test follows the footer link rather than asserting its text: whether ./app/ resolves is a fact about the assembled tree. Injecting a typo into the href fails it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
09ada9c1b5 |
test(harness): a grouped, multi-level fixture, and the first browser run of anr9
web/demo.improv has no groups and one category per visible axis, deliberately (gotcha 46), so the multi-level header, the group span, the collapse indicator and the inline group row -- exactly the code improvise-anr9 added -- had never been rendered anywhere a test could see (improvise-plcx). harness/fixtures/grouped.improv is served beside the shipped demo by harness/build.sh; web/demo.improv is untouched. Two categories on each visible axis, three row groups with one collapsed, two column groups. A missing seed is NOT an error -- web/worker.js answers a 404 with an empty model -- so every assertion is about labels the fixture really carries, never that a grid exists. Five injections, five distinct failures, patched in harness/serve so no rebuild stands between the fault and the run: slash-joining the level tuples; colSpan = 1 on the group headers; a stray cell on the inline group row; indicator(false) hard-coded; and the collapse removed from the document. One thing the ticket did not predict: the row frame's FIRST group header is never drawn, so the spec asserts two group rows where the document has three, with improvise-0o6x named on the line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
ba5c0ee059 |
test(harness): the help overlay is asserted as content, and a lost click guard returns
PR #61 deleted "? opens help, and its rows are the shared ones" and nothing replaced it (improvise-78v6): paintHelp, help, help_page and help_titles reached no assertion at all. What contract.spec.js carried instead said only that a dt was VISIBLE, and an empty dt is a visible dt (improvise-igxp). help.spec.js reads the <dl> as the pairs it is and asserts text: the five titles, Welcome's prose rows (which carry no dd at all, by design), and three key/description pairings off the keymap. The second test asks whether the string is TRUE rather than present -- it reads the key for "Jump to last row" out of the DOM, closes the overlay, presses it and asserts the selection, so the spec holds no second copy of the keymap. contract.spec.js's two help lines are strengthened from toBeVisible to text. Injecting the fault igxp names -- blanking keys.textContent in paintHelp -- left all three contract tests passing before; it now fails that file and both tests here. The test-name diff 78v6 asks for finds a fourth loss: "a click on an item row filters, as Enter does". Only #status distinguishes the item arm of click_category_row from the category arm, so a click wired to the wrong one passed the whole file. Restored, and proved by injecting the mis-wiring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |