901a99e8dc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
229 lines
551 KiB
JSON
229 lines
551 KiB
JSON
{"_type":"issue","id":"improvise-bpq2","title":"Client-side key resolution: what must the projections carry?","description":"USER RULING (2026-09-19): keys are converted into commands IN THE BROWSER; the keymap is a client-only concept the server must not know. Follows from the standing principle -- the model is shared, the UI is not, and a keymap is UI. It also buys something real: two clients may have DIFFERENT bindings over the same document, which is impossible if the server owns the keymap.\n\nThis inverts improvise-gsw conclusion (which had keys travelling upstream unresolved) and closes the keypress-door ticket. The work is now to make client-side resolution POSSIBLE.\n\nTHE PROBLEM TO SOLVE. A binding names an INTENT resolved against a CmdContext -- Model, GridLayout, cursor, yanked cell, expanded cats, mode. A Command is the RESOLVED MUTATION, e.g. SetSelected { row, col }. A thin client holds ViewState plus the widget caches. So for each binding ask: can a client holding only that state compose the Command? Where the answer is no, the fix is to ENRICH THE PROJECTION, not to move resolution to the server.\n\nKNOWN GAPS, from the gsw agent own analysis of what it could not resolve:\n * CURSOR MOVEMENT -- GridCache is indexed by position WITHIN THE VIEWPORT, so a client cannot distinguish \"last visible row\" from \"last row\" and cannot clamp. It needs the total row/col count, or an explicit \"can I move further\" fact.\n * PANEL CURSOR MOVEMENT -- move-panel-cursor walks a flattening that depends on expanded_cats. Either the cache carries the flattened list, or the client re-derives it -- and re-deriving is how two ends drift.\n * MODIFIED CHARACTERS -- KeyPattern::AnyChar matches a Char under ANY modifiers on the host. A client mirroring that is fine now that the client owns the keymap outright; the old constraint (local resolution may only ever be a strict subset) DISSOLVES once there is no host keymap to be a subset of.\n\nMETHOD: enumerate the bindings, not the commands -- the keymap is the thing being made client-side, so walk it. doctrine(binding_dump_diff) already says most bindings have no direct test, so a green suite is not evidence here either.\n\nALSO IN SCOPE: move ClientMessage/ServerMessage into improvise-protocol. The vocabulary is there and the framing that says which a JSON blob is, is not; the ws-server will want the same envelopes and two crates should not define them.\n\nBlocks improvise-1ey.","notes":"\n--- THE COMPOSABILITY LIST (improvise-q08 agent, 2026-09-19) ---\nWhich Commands a thin client holding ViewState + the five widget caches CANNOT compose. This is the real content of this ticket.\n\nHARD BLOCKERS:\n1. SetCell { key: CellKey } and ClearCell(CellKey) -- THE BIG ONE, and it blocks\n the most basic action there is. A client sees a grid of LABEL STRINGS indexed\n by viewport position. A CellKey is the full sorted coordinate tuple: every Row\n and Column category, plus every Page AND Filter selection (gotcha 27). Nothing\n in GridCache says which category a row label belongs to; nested row axes\n flatten several categories into one label; the page/filter constraints are not\n projected at all; and records mode encodes a COLUMN INDEX in _Dim (gotcha 1).\n There is no client-side derivation. NOTE THIS BLOCKS improvise-1ey DIRECTLY --\n its stated scope is \"type a number into a cell and see the value update\".\n2. ToggleGroup { category, group } and AddItemInGroup -- CategoryRow.items is a\n flat Vec\u003cString\u003e; groups, group membership and collapsed state are not\n projected. The server does the cat_tree flattening.\n3. RemoveFormula { target, target_category } -- FormulaCache is Vec\u003cString\u003e of RAW\n text. The client would have to re-parse to get target (dragging\n improvise-formula into a thin client), and target_category is simply absent, so\n a formula written [CustomCat] cannot be addressed at all.\n4. LoadModel(PathBuf) / ImportJsonHeadless -- paths name documents in the HOSTs\n Storage (gotcha 2) and nothing projects a listing, so a client cannot compose a\n path it has reason to believe exists.\n5. ApplyAndClearDrill / AcceptWizardModel -- nullary so constructible, but nothing\n projects drill or wizard state, so a client cannot know when either is\n meaningful. (Both are already off the wire per improvise-3x3u.)\n\nCOMPOSABLE BUT TRAPPED:\n* SetSelected { row, col } is ABSOLUTE; GridCache is VIEWPORT-RELATIVE. The client\n must add row_offset/col_offset from its own ViewState -- it has them, but the two\n index spaces are not distinguished by any type and getting it wrong is silent.\n* The grids TOTAL row/column count is not projected, so a client cannot clamp its\n cursor or size a scrollbar.\n\nFINE TODAY: SetAxis/CycleAxis/TransposeAxes/TogglePruneEmpty/SortData/\nCleanEmptyRecords, AddCategory/RemoveCategory, AddItem/RemoveItem/HideItem/\nShowItem/SetPageSelection WHEN DRIVEN FROM THE CATEGORY PANEL (not from the grid\ncursor -- same reason as 1), CreateView/DeleteView/SwitchView/ViewBack/ViewForward,\nAddFormula, SetNumberFormat (though the CURRENT format is in no cache, so it cannot\nbe pre-filled), and the whole view half.\n\nSUGGESTED ORDER: item 1 first and alone -- it is what 1ey needs and it is a\nprojection design question (how does a client name a cell?), not a plumbing one.\n\n--- ADR 002 ACCEPTED (user, 2026-09-19): see context/adr/002-naming-a-cell.md ---\nA projected cell carries an OPAQUE SURROGATE KEY, and its ABSENCE means the\ncell is an aggregate:\n CellDisplay { text, numeric, error, key: Option\u003cCellId\u003e }\nSome -\u003e a stored cell this client may set, named by a token it echoes back.\nNone -\u003e an aggregate: drill, do not set.\n\nWHY THE NULLABILITY IS THE POINT: a client cannot compose SetCell for an\naggregate because there is NO KEY TO PUT IN IT. That is make-invalid-states-\nunrepresentable (design-principles 3), strictly stronger than a server-side\nrejection. It also mirrors a fork the model ALREADY makes -- EditOrDrill in\ncmd/mode.rs branches on exactly this -- so the wire projects an existing\ndistinction rather than inventing one. And it follows the precedent\nCellDisplay::numeric set, whose doc says the server decides \"because it is the\nside that knows\".\n\n_INDEX WAS CONSIDERED AND REJECTED, and the reason is worth keeping: right\nontology, unsafe representation. CategoryKind::VirtualIndex is documented as\n\"Items are 0, 1, ... N where N = number of matching cells\" -- POSITIONAL over\nthe currently matching set, not an identity. _Index = \"3\" names a different\nrecord after a filter change, an insert or a delete, and a different one again\nin another view. A client echoing a stale _Index writes to the WRONG CELL,\nSILENTLY -- worse than a surrogate that fails to resolve, and the same hazard\nclass as viewport-relative-vs-absolute and the stale cursor under a renamed key.\n\nBUT KEEP WHAT IT POINTS AT: the surrogate should name the RECORD, not the cell.\nA record is the coord tuple minus _Measure (record_row_identity), which is\nalready ADR 001 unit of atomicity and already what record_versions is keyed by.\nAddress a cell as (record surrogate, measure) and ONE IDENTITY serves both\naddressing and staleness, instead of each ADR inventing its own.\n\nOPEN, for the implementer to decide with evidence: hash vs interned id (the\ncodebase interns Symbols already); how the server resolves a surrogate back --\nnote that REGENERATING FROM THE CURRENT LAYOUT means a stale surrogate simply\nfails to resolve, which IS ADR 001 stale-basis rejection at cell granularity,\nso the two compose; and what DRILL addresses with, which this does not answer.\n\n--- SURROGATE DECIDED (user, 2026-09-19): hash of the normalized record, maintained as an index ---\nNOT a hash of the cell -- of the RECORD (the coord tuple minus _Measure), which\nis already ADR 001 unit of atomicity and already what record_versions is keyed\nby. A cell is (record hash, measure).\n\nWHY: it makes stale edits cheap to identify, three ways at once. The surrogate\na client already holds IS the lookup key for its own version, so record_versions\nbecomes HashMap\u003cRecordHash, u64\u003e -- fixed size, no allocation, no CellKey\nreconstruction on the hot path. A basis is a list of (record hash, version)\npairs: compact on the wire, O(1) to check. And a record that no longer exists\nsimply fails to resolve, so that whole class of staleness needs no version\ncomparison at all.\n\nIt is well-defined because CellKey ALREADY has a canonical form -- CellKey::new\nsorts by category name and equality/hashing rely on it (gotcha 5). Normalized\nmeans that form with _Measure removed; the normalization is a property the type\nguarantees, not a new convention.\n\nRESOLUTION IS A MAINTAINED INDEX, IN DataStore -- a hash is one-way, and\nregenerating from the layout would be O(visible cells) per resolve AND would\nonly cover the visible window, while a client may hold a surrogate for a record\nthat scrolled away. DataStore is the right home: it already interns every name\ninto a SymbolTable and already maintains HashMap\u003c(Symbol,Symbol), HashSet\u003cInternedKey\u003e\u003e\nfor hot-path lookups. The RecordHash index is the same shape, updated at the same\npoints, and that existing (Symbol,Symbol) index is what makes INVALIDATION\ntractable -- when a category or item disappears, the records mentioning it are\nfindable without a scan.\n\nTWO THINGS THAT FALL OUT: the index and record_versions want to be ONE structure\n(RecordHash -\u003e (record, version)) since both are keyed the same and invalidated\ntogether, giving both ADRs their answer in one lookup. And structural vs data\nstaleness separate cleanly: does not resolve = the record is GONE (reject,\nnothing to write to); resolves with a differing version = the record CHANGED\n(reject as stale, offer force). Regenerating from the layout would have\nconflated those, because a record scrolled out of view would look identical to\none that no longer exists.\n\nTHREE SILENT-BREAKAGE TRAPS, each producing a WRONG WRITE rather than an error:\n * NOT DefaultHasher / RandomState -- randomly seeded PER PROCESS, so hashes\n would differ across a restart and between processes.\n * NOT #[derive(Hash)] as the definition -- Rust guarantees no stability across\n compiler versions or platforms. Hash an explicit BYTE SERIALIZATION of the\n sorted (category, item) pairs.\n * Pick the WIDTH BY CONSEQUENCE: a collision is a silent write to the wrong\n record, which is exactly why _Index was rejected. 64 bits hits birthday risk\n near 2^32 records; 128 bits costs 8 more bytes per VISIBLE cell (~8KB on a\n 50x20 viewport). Argue it, do not default to u64 for convenience.\n\n--- CellId IS A TWO-VARIANT ENUM (user, 2026-09-19), not Option ---\n enum CellId { Record(RecordHash), Aggregate(AggregateHash) }\nSetCell takes a Record and nothing else; Drill takes an Aggregate and nothing\nelse. An aggregate surrogate is a hash of its COORDINATE CONSTRAINT -- the row\nand column coordinates plus page/filter selections -- so it costs one hash per\ncell, the same width and the same DataStore index as a record.\n\nWHY NOT Option\u003cCellId\u003e with None meaning aggregate: an aggregate is NOT the\nabsence of an identity, it is a different KIND of identity. Option made SetCell\nsafe and left Drill with nothing to address, which is exactly how drill\naddressing became an open question. Two variants close it by construction and\nmake BOTH operations unrepresentable-if-wrong rather than just one.\n\nDEFERRED, filed separately: shipping a bounded keyset per aggregate for hover\npreviews. The unbounded version is untenable -- the payload would scale with the\nDATA while the rest of a projection scales with the VIEWPORT, ~16MB to render\n1000 cells over a million-record document -- and drill-on-demand works without\nit.\n\n--- ADR 002 SPINE LANDED (2026-09-19). What remains here is the KEY RESOLUTION half. ---\nMerged: RecordHash/AggregateHash, the record index in DataStore, CellId on\nCellDisplay, SetCell/ClearCell taking a surrogate, and Client::set_cell /\nset_cell_at in the wasm client. 1017 tests, 16 faults injected and all fired.\n\nSO A CLIENT CAN NOW COMPOSE A WRITE -- improvise-cr3 can call set_cell_at\ntoday. What is NOT done, and is this ticket own subject: WIRING IT TO A\nKEYSTROKE. The wasm client still forwards editing keys upstream unresolved,\nwhich is exactly the thing the user ruling (keymap is client-only) says must\nchange. The composability list earlier in these notes is still the map; item 1\n(SetCell) is now answered, items 2-5 are not.\n\nDECISIONS THE IMPLEMENTER MADE THAT ARE WORTH KNOWING:\n * The wire form of a surrogate is a 32-char HEX STRING, not a number. serde_json\n writes u128 as a bare number and a JavaScript host parses every JSON number as\n f64, so anything above 2^53 comes back CHANGED. This trap was not in the ADR.\n * CellId::Record carries RecordCell { record: RecordHash, measure: Option\u003cString\u003e },\n not a bare hash. The ADR says both \"hash the record\" and \"a cell is (record\n hash, measure)\"; both cannot hold if the hash covers the measure, because then\n record_versions could not be keyed by it -- which is the reason the ADR chose\n the record over the cell. Carrying the measure beside the hash satisfies both.\n * id: None is NOT \"aggregate\" (that is Some(CellId::Aggregate)). It means no\n projected cell: a padded/invalidated GridCache slot, or a records-mode\n COORDINATE column, which renames across the whole row (gotcha 1) and is a\n different intent from SetCell.\n * A zero cell count is a TOMBSTONE, not a removal -- dropping the entry would let\n a delete-and-recreate reset the version to 0, so a client holding version 0\n against the OLD record would be told its basis is current and its write\n applied. resolve_record filters on liveness; record_version reads through.\n * Resolution is INDEX FIRST, LAYOUT SECOND. The index covers every record holding\n data on screen or not; the layout answers cells with NO DATA YET -- and typing\n into an empty cell IS improvise-1ey.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:42:49Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:18:33Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-c4er","title":"[bug] There is no server door for a keypress; the asker gets no commands back","description":"Found by improvise-gsw (2026-09-19) and it is THE seam the browser MVP depends on. Blocks improvise-1ey.\n\nA browser client cannot resolve keys locally -- a binding names an INTENT resolved against a CmdContext while a Command is the RESOLVED MUTATION (see the improvise-gxi close reason and gotcha 38). So the client sends the raw keypress upstream as ClientMessage::Key { code, modifiers }.\n\nTHE GAP: Sessions::apply(app, id, cmd) takes a Command. ui::reduce::reduce_key(app, key) exists but returns only PROJECTIONS -- not the view-slice commands the ASKER must fold into its own ViewState. With keys travelling unresolved the host owes the asker back the commands its own keypress produced, and nothing returns them.\n\nNOTE this is NOT Applied::Diff.broadcast, which is what the OTHER clients fold (gotcha 36). The asker needs its own, and there is nowhere for them to come from.\n\nSUGGESTED SHAPE (from the gsw agent): Sessions::apply_key(app, id, key) -\u003e Applied, with the askers own view-slice commands in the result. Derive it rather than inventing -- doctrine(derive_the_enum_from_the_effects). The keypress is already KeyCode + KeyModifiers, which Command::SetTransientPrefix puts on the wire today; note improvise_protocol::KeyEvent itself does NOT derive Serialize/Deserialize, only its two components do, which is worth fixing at the same time.\n\nAlso from gsw: ClientMessage/ServerMessage envelopes currently live in the client crate because the VOCABULARY is in improvise-protocol but the FRAMING that says which a JSON blob is, is not. Moving them is a one-file lift and the ws-server will want the same ones -- do it here rather than letting two crates define them.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:37Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:42:47Z","closed_at":"2026-09-19T23:42:47Z","close_reason":"WRONG PREMISE -- CLOSED AND INVERTED by a user ruling (2026-09-19): keys are converted into commands IN THE BROWSER, and the keymap is a CLIENT-ONLY concept the server must not know at all.\n\nThis ticket asked for a server door for a keypress (Sessions::apply_key). There must not be one. The reasoning follows from the standing principle -- the model is shared, the UI is not -- and A KEYMAP IS UI. If the server knows the keymap, two clients cannot have different bindings, and that is a thing this architecture should permit: a browser client with browser-native keys and a TUI with vim keys, over the same document.\n\nSo the gap this ticket named does not exist. The asker never needs its own commands handed back, because the asker COMPOSED them and already knows what it sent. ClientMessage::Key should not exist; a client sends Commands only. ui::reduce::reduce_key stays, but it belongs to the native TUI -- which is a client that happens to run in-process -- not to the server client-facing surface.\n\nTHE REAL WORK THIS DISPLACES INTO: a client must hold enough PROJECTED STATE to resolve a binding into a Command. That is a requirement on what the projections carry, not a reason to move resolution serverward. Filed separately.\n\nThe one part of this ticket that survives independently: ClientMessage/ServerMessage envelopes belong in improvise-protocol rather than the client crate -- the vocabulary is there, the framing is not. Refiled with the projection work.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-acsn","title":"[bug] A document swap over the wire leaves the client's own cursors stale; nothing tells the host a rebase is owed","description":"Wave-4 review (2026-09-19), CONFIRMED by probe. Latent -- no host wires Sessions yet -- but newly reachable from the wire.\n\nModelCommand now lets a client send LoadModel / ImportJsonHeadless / AcceptWizardModel, the WholeWorkbook commands, for the first time. The SERVER mirrors self-heal: ClientSession::project reseeds on the document-generation bump, so the server is honest. But cursors live in the CLIENT own ViewState, and that is corrected only by a Snapshot -- whose only producer, Sessions::rebase, has NO NON-TEST CALLER in the tree (grep finds one hit, a test). Applied carries only { dirty, broadcast }, neither of which tells the host a rebase is owed.\n\nProbe (two sessions, MemoryFs, Model(LoadModel) where the incoming document active view is also named Default):\n generation 0 -\u003e 1, broadcast = None\n bystander mirror cursor before project = (3, 0)\n bystander mirror cursor after project = (0, 0)\nSo the server has it at (0,0) while the bystander client keeps cursors[Default] = (3,0) from the OUTGOING document -- the gotcha-24 cursor-vs-geometry mismatch that apply_switch own doc comment says this shape exists to prevent, now arriving through another client.\n\nFIX: give Applied a way to say the document was replaced -- carry the generation, or a rebase flag -- so a host CANNOT forget. Pre-existing in kind (a keypress-driven LoadModel had the same gap), which is why the fix belongs on Applied rather than on the command.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:02Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:24:25Z","closed_at":"2026-09-19T22:24:25Z","close_reason":"Applied went from a record to an enum: Applied::Diff { dirty, broadcast } | Applied::Rebased { snapshots }. The enum IS the mechanism -- a host that wants the Dirty must match, and matching names the case where a Snapshot is owed. Changing the shape made the compiler name all five existing read sites, which is the point. Rebased carries no Dirty and no broadcast, because each Snapshot already carries a whole view slice plus the seed reproducing the new document; a diff against the old one would describe nothing that still exists. Sessions::apply_model snapshots document_generation before and after apply_command and calls rebase itself, exactly as apply_switch adopts the accepted view itself -- rebase gains its first non-test caller.\n\nTHE TRIGGER IS THE GENERATION, NOT THE VARIANT: a LoadModel on an unreadable path leaves the document alone, and reseeding on the variant would cost a client its cursors for a FAILED load. Both arms are tested per doctrine(one_arm_of_a_symmetric_rule...): a_command_that_leaves_the_document_in_place_is_not_a_rebase and a_load_that_fails_is_not_a_rebase. Injections both directions: never-rebase fails the swap test (left 3, right 0); always-rebase fails four tests including the failed-load arm.\n\nFIXTURE TRAP WORTH KNOWING, recorded in the repo-map: the first version of the test PASSED, because the existing other_document() renames the active view Default-\u003eSheet, so apply_model broadcast a SwitchView, the client folded it, and the stale cursor was read under a key nothing had written -- right by accident. A new fixture keeps the view NAME and changes only the shape, so there is nothing to hide behind.\n\nRejected: Applied { dirty, broadcast, rebase: bool } -- the host that forgets to call rebase is the host that forgets to read the flag; carrying the generation -- same problem plus it asks the host to remember the comparison; a hook on the LoadModel effect -- Sessions is unreachable from an effect, which is why the generation bump exists at all.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-oi9b","title":"[bug] 24 of 28 model_effects mappings are unguarded; a wrong mapping passes the whole suite","description":"Wave-4 review (2026-09-19), CONFIRMED by injection. Test gap, not a live bug -- the mapping is correct today.\n\nui/reduce.rs::model_effects maps each ModelCommand back to its effect. Injecting wrong-but-still-Model mappings left cargo test --workspace GREEN:\n ModelCommand::HideItem -\u003e effect::ShowItem PASSES\n ModelCommand::CreateView -\u003e effect::DeleteView PASSES\n ModelCommand::AddItem -\u003e effect::RemoveItem PASSES\n ModelCommand::ViewBack -\u003e effect::ViewForward fails (caught incidentally by the project.rs lockstep test)\n\nWhy: every_model_command_names_an_effect_that_declares_a_model_change cannot distinguish two effects of the SAME EffectKind, and a_model_command_lands_where_its_keypress_lands drives only three variants (TransposeAxes, TogglePruneEmpty, AddCategory). That leaves 24 variants whose mapping nothing checks -- and every inverse pair (Hide/Show, Add/Remove item, Create/Delete view, SetCell/ClearCell) is exactly the case the surviving tests cannot separate.\n\nFAILURE SCENARIO: a rename or re-order during a future refactor silently re-points a wire command at its INVERSE. A client sending Model(HideItem) un-hides instead, and because the Dirty is identical every other client is told a consistent lie.\n\nFIX: extend a_model_command_lands_where_its_keypress_lands to a table over every_model_command(), or -- cheaper and total -- assert the variant name equals the effect type name. The mapping is name-for-name by construction, so that assertion covers all 28 at once. This is the same class the author already fixed once, when a one-category fixture could not tell TransposeAxes from CycleAxis.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:01Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:24:24Z","closed_at":"2026-09-19T22:24:24Z","close_reason":"Guarded by name, covering all 28 at once. every_model_command_names_the_effect_of_the_same_name asserts each variant returns exactly one effect whose TYPE NAME equals the variant name -- read off the effect derived Debug, since Effect is a trait object and type_name is out of reach through it. The mapping is name-for-name by construction, so a variant whose effect must be named something else is a variant that stopped being derived: it should fail there and be argued about.\n\nThe gap was reproduced first: AddItem-\u003eRemoveItem, HideItem-\u003eShowItem and CreateView-\u003eDeleteView applied SIMULTANEOUSLY left cargo test --workspace at 918 passed, 0 failed -- three wire messages doing the exact inverse of what they say, suite fully green. Each injection was then re-run individually against the new test and each fails with the names printed.\n\nThe name READER is itself checked (the_effect_name_reader_reads_all_three_debug_shapes), because the delimiter after the identifier differs per Debug shape -- nothing, ( or space-brace -- and its own injection was verified to fail.\n\nRejected extending the behavioural test to a table over all 28: most variants have no single keypress to compare against, so you would invent one or drive the effect directly, at which point it stops being an independent yardstick. The behavioural test keeps its three genuinely key-driven cases and its two-category pivoted() fixture.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-kvyt","title":"[bug] A view switch moved only the asking session's mirror, staling every bystander's cursor","description":"Found by GitHub Copilot on PR #10 (2026-09-19) -- its second real finding on this codebase -- and fixed on the wave-2 branch in 8475b2d.\n\nSessions::apply moved the workbook active view through apply_command and then moved the mirror of the ADDRESSED session alone. But every client grid is built by grid_of from workbook.active_view(), so a bystander session left naming the outgoing view had its cursor read under a stale key: it was projected the NEW view rows positioned by the OLD view scroll. That is gotcha 24 cursor-vs-geometry mismatch, arriving through somebody else keystroke rather than its own. And the bystander client-side slice -- which folds the same commands precisely so that mirror and client cannot drift -- was never handed the switch to fold at all.\n\nFIX: the accepted view reaches every mirror, and apply returns Applied { dirty, broadcast } rather than a bare Dirty. broadcast is the command the host must forward to the other clients, whose slices live on them. It names what the document ACCEPTED, which also corrects a client that optimistically folded a view name the workbook turned down. apply_switch takes no SessionId -- the switch is addressed to no one session, and that absence is the statement.\n\nProven first: a_view_switch_moves_every_sessions_mirror_not_just_the_switchers fails on the old door, with the bystander mirror naming Default while the workbook names Second.\n\nIMPORTANT INTERACTION, do not lose this: this fix is correct for TODAY model, where active_view is shared in lockstep. The user has decided (improvise-g0f) that active_view becomes PER-CLIENT -- the model is shared, the UI is not. Under that split a view switch becomes the asker business again and this broadcast goes away or changes meaning. improvise-jytm owns the split and MUST revisit this; see the note added there.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T21:53:03Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:53:04Z","closed_at":"2026-09-19T21:53:04Z","close_reason":"Fixed on the wave-2 branch in 8475b2d, in PR #10, with the failing test written first. Found by Copilot, accepted in full, and the reasoning is in the commit message and this ticket.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gw0m","title":"[bug] Replacing the document leaves every ClientSession mirror keyed to the old one","description":"Wave-2 review (2026-09-19), CONFIRMED by probe.\n\nGotcha 24 says 'replacing the document must clear the map' -- LoadModel/AcceptWizardModel call cursors.clear() on App's ViewState. Nothing clears ClientSession::view.cursors or ClientSession::view.active_view, and Sessions has no hook for a workbook swap.\n\nProbe: client on view Alpha scrolled to row_offset 3; install_workbook swaps in a 2-row document whose view is Beta. Fails twice on unmodified wave-2: 'the session mirror still names the OLD document's view (left Alpha, right Beta)' and 'the session still scrolls by the old document's cursor (left 3, right 0)'.\n\nUSER-VISIBLE: after ':e other.improv', a remote client is scrolled past the end of a document it has never seen. Viewport::window clamps to 3..3 and the client renders an EMPTY GRID, with no command that will ever bring it back.\n\nFIX: give Sessions a document-swap hook that resets each session the way connect does (reset mirror, re-seed with Dirty::EVERYTHING), and call it wherever the workbook is installed. The re-seed path already exists -- this is about making install_workbook reach it.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:40Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:10:57Z","closed_at":"2026-09-19T21:10:57Z","close_reason":"Fixed via a document generation, because the hook as specified is uncallable: an effect holds \u0026mut App and Sessions is the host's -- no App in the tree owns one. So App::document_generation is bumped ONLY by install_workbook (already the single writer for the cursor clear), ClientSession records the generation it was seeded at, and project() dispatches on equality: equal takes the existing diff (extracted unchanged as project_diff), unequal reseeds. seed became reseed, now also re-pointing the mirror at a fresh view, shared by connect and the swap path. Sessions::rebase(\u0026App) is the explicit client-facing half, and its doc says so: the server's mirrors are already safe via the generation, but a client's own view slice lives on the client and only a fresh Snapshot tells it to drop a cursor naming a view of a document that is gone. document_generation sits on App under the session-local residue tag, not on ViewState -- it describes the document's identity over a process lifetime, not a client's view (gotcha 30). Both faults injected and confirmed: deleting the increment fails the swap test; stubbing rebase to Vec::new fails the other. Rejected an on_document_swapped hook a host must remember to call -- that is the failure mode being fixed.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-oks8","title":"[bug] ClientSession::apply accepts SwitchView and desyncs the session mirror","description":"Wave-2 review (2026-09-19), CONFIRMED by probe. Reintroduces the gotcha-29 desync that the sibling module works to avoid.\n\nClientSession::apply is 'pub fn apply(\u0026mut self, cmd: \u0026Command) { reduce_view(\u0026mut self.view, cmd); }', documented as 'the same function the client runs, so the two cannot drift'. It is the ONLY per-session command door Sessions exposes. Meanwhile reduce.rs::apply_command goes to lengths to route SwitchView through App::switch_to_view, with a test and a gotcha-29 citation.\n\nFeed ClientSession::apply a SwitchView and the session's mirrored active_view moves while workbook.active_view and app.layout do not -- and viewport() then reads the cursor under the NEW key. The very next project() emits a grid built from the OTHER view.\n\nProbe on unmodified wave-2: 'the client that switched was sent grid rows [[\"North\",\"South\"]] built from the OTHER view's layout' -- mirror says Second, rows are Default's.\n\nThis falsifies kb.pl's derived_by_fold(view_state, command_stream, 'both ends run reduce_view over the same commands, so the server mirror cannot drift').\n\nFIX structurally, not by convention: make the session door go through apply_command, or have apply reject/route SwitchView, so a transport author cannot get this wrong by not knowing. NOTE the user has since decided (improvise-g0f) that active_view is PER-CLIENT -- coordinate with improvise-jytm, since the right shape here depends on that split.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:40Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:10:56Z","closed_at":"2026-09-19T21:10:56Z","close_reason":"Fixed by moving the door rather than adding a rule. ClientSession::apply became private fold(); the mirror's ViewState is private behind view(); a transport can no longer reach it. New Sessions::apply(app, id, cmd) is the single door: a command with a DOCUMENT HALF (SwitchView today) routes through reduce::apply_command -- the sanctioned writer per gotcha 29 -- and the mirror then takes workbook.active_view, i.e. what the document ACCEPTED rather than what was asked, so a switch to a nonexistent view leaves the mirror put. Everything else is folded into that session's mirror only.\n\nDeliberately does not hard-code lockstep anywhere: both halves move inside one call, so whichever way improvise-g0f lands there is exactly one body to change and improvise-jytm can change it without touching any transport. Second test added for the standing principle -- a_clients_view_only_command_moves_only_that_clients_mirror -- and its fault injection is the obvious wrong simplification (route EVERYTHING through apply_command), which fails it on the App's own cursor. Rejected: splitting Command in the read-only protocol crate; leaving apply public with a 'do not send SwitchView' comment (that is the convention being removed); refusing SwitchView (the door should honour it).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-t9p6","title":"[bug] Dirty::of(LayoutGeometry) under-reports formulas; RemoveCategory strands deleted formulas on every client","description":"Wave-2 review (2026-09-19), CONFIRMED by probe against unmodified wave-2. This is the SILENT failure mode the producer's design explicitly guards against -- under-reporting, not over-reporting.\n\nproject.rs Dirty::of maps LayoutGeometry to {grid, category_tree, view_list, tile_bar} with formulas:false. RemoveCategory is LayoutGeometry (effect.rs). But Model::remove_category ends with self.formulas.retain(|f| f.target_category != name) -- deleting a category deletes every formula targeting it, and the formula panel is never told.\n\nScenario: user adds 'Total = Revenue + Cost' targeting category Region, then deletes Region. The document has 0 formulas; the client's FormulaCache still shows 1, INDEFINITELY -- nothing else in the stream will ever dirty formulas unless another formula happens to be added.\n\nProbe output on unmodified wave-2: 'UNDER-REPORT: client still shows [\"Total = Revenue + Cost\"] (dirty.formulas=false), left: 1 right: 0'.\n\nFIX: set formulas:true on LayoutGeometry. That is consistent with Dirty being 'the widgets one kind can reach', and over-reporting costs only work. Giving RemoveCategory its own classification is the alternative but multiplies kinds.\n\nSAME SITE, LOW: the doc comment justifying the mapping says 'ModelChange::CellData is worn by AddCategory'. AddCategory is LayoutGeometry in this very branch. That comment is the only record of the mapping's derivation and is already stale against its own diff.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:39Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:10:56Z","closed_at":"2026-09-19T21:10:56Z","close_reason":"Fixed: formulas:true on the LayoutGeometry arm. Failing test first -- removing_a_category_empties_the_clients_formula_panel -- which also asserts the DOCUMENT dropped the formula, so the client is shown to be wrong rather than the assertion. Pre-fix: left [\"Total = Revenue\"] right []. The arm is spelled out rather than ..NOTHING and carries a comment on why it is not written as EVERYTHING: the two coincide today but are derived from different effects. The doc comment was re-derived from the effects as they actually are, removing the stale 'CellData is worn by AddCategory' claim (untrue since the reclassification). The asymmetry is now written down: a redundant Projection::Formulas costs one message, a missed one is wrong forever. Rejected giving RemoveCategory a WholeWorkbook kind -- it would fix one site by lying about the effect's scope.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-8tk","title":"ZZ / :wq quits even when the save fails, silently discarding the model","description":"SaveAndQuit (crates/improvise-command/src/command/cmd/mode.rs) returns [Save, ChangeMode(Quit)] unconditionally. The Save effect reports failure only by setting view_state.status_msg — it never emits AbortChain — so the Quit lands regardless, and src/draw.rs:90 breaks out of the loop before another frame renders. The user never sees the error.\n\nWORST CASE — no file path, total silent loss:\nStart improvise with no file argument, do a session of work, press ZZ (bound in the normal-mode Z prefix, keymap.rs:562). Save takes the else branch, sets 'No file path — use :w \u003cpath\u003e', and the app exits. autosave_if_needed() cannot rescue it either: it early-returns unless model_state.file_path is Some. Everything the user typed is gone, with no prompt and no visible message.\n\nSECOND CASE — save errors on an existing path (read-only dir, full disk, bad permissions): same silent exit. Here a *.autosave file may exist, but only if 30s had elapsed since the last autosave, and the user is not told to look for it.\n\nNote ZZ is a vim reflex, so this is easy to hit by muscle memory.\n\nFIX SKETCH: Save needs to report failure to the effect chain, not just to the status bar — emit AbortChain (or have SaveAndQuit consult a save result) so the Quit is skipped and the error stays on screen. Decide separately what ZZ should do with no file path: most likely open the export/save prompt rather than exiting.\n\nPer AGENTS.md, write the failing test first. The command layer is pure, so assert on the emitted effects; the abort behavior itself is already covered by the AbortChain test at effect.rs:1373.\n\nBLOCKS improvise-git (replace SaveAndQuit with Binding::Sequence): a Sequence has no way to stop between steps, so converting first would cement this bug into the keymap where it is harder to fix. Fix this, then decide whether git is still worth doing.\n\nFound while grooming the backlog 2026-08-13, evaluating whether git/aaw/s13 could be combined.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-14T01:07:58Z","created_by":"Edward Langley","updated_at":"2026-08-29T17:34:46Z","started_at":"2026-08-29T17:31:50Z","closed_at":"2026-08-29T17:34:46Z","close_reason":"Save effect now sets abort_effects on failure (no file path, or write error), so the Quit chained by wq/ZZ is skipped and the error stays on screen. Extracted save_to_current_path() -\u003e Result\u003cString,String\u003e. Three tests in ui/effect.rs: two regression guards (no-path and failed-write both abort) plus one guaranteeing a successful save still quits.\n\nNote for improvise-git: a Binding::Sequence flattens into a single effect vec applied by one apply_effects call, so abort_effects DOES carry across sequence steps. Its stated blocker ('a Sequence has no way to stop between steps') no longer holds — it can proceed.\n\nFollow-up filed as improvise-lae for the no-file-path UX (prompt for a path rather than only erroring).","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-1cz","title":"export_csv emits unquoted CSV — commas in values corrupt output","description":"persistence/mod.rs:581-630 builds CSV by joining labels and values with bare commas (col_labels.join(\",\"), row_values.join(\",\")) with no RFC 4180 quoting/escaping. Any comma in a row/col label, page label, or cell value shifts columns. This is guaranteed to fire when the view uses a comma-grouping number format (e.g. ,.2f): display_text() renders 1200 as |1,200.00|, so every formatted value over 999 corrupts the row. Fix: use the csv crate (already a dependency) or quote fields per RFC 4180. Found in systematic review 2026-06-09; distinct from improvise-k8i (import side).","acceptance_criteria":"A view exported with a comma-grouping number format (,.2f) and an item label containing a comma re-imports with identical column structure. Failing test first: export a grid where display_text yields |1,200.00| and a row label contains a comma; assert the CSV parses to the original shape (use the csv crate to read it back).","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:57:49Z","created_by":"Edward Langley","updated_at":"2026-06-10T04:45:52Z","closed_at":"2026-06-10T04:45:52Z","close_reason":"Fixed in 3418162/47ad9e6 (test-first): export_csv via csv::Writer honoring number format; malformed numbers -\u003e CellValue::Error; formula [Category] suffix moved into pest grammar; parse errors report line/col + offending line. 155 io tests green.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-4yc","title":"Malformed numbers in .improv file silently become 0.0","description":"crates/improvise-io/src/persistence/mod.rs:434 uses value_pair.as_str().parse().unwrap_or(0.0). Malformed number literals (e.g. '3.14.15', '1e999', '--5') become CellValue::Number(0.0) with no error. This is the only production unwrap/unwrap_or in the parse path and silently corrupts data. Fix: return a parse error with line context, or at minimum log a warning and use CellValue::Error.","acceptance_criteria":"Loading a .improv file with a malformed number (e.g. 12.3.4 or 1e999) surfaces a parse error or CellValue::Error naming the offending text — never silently 0.0. Regression test demonstrates the current silent-zero behavior first.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:17Z","created_by":"Ed L","updated_at":"2026-06-10T04:45:53Z","closed_at":"2026-06-10T04:45:53Z","close_reason":"Fixed in 3418162/47ad9e6 (test-first): export_csv via csv::Writer honoring number format; malformed numbers -\u003e CellValue::Error; formula [Category] suffix moved into pest grammar; parse errors report line/col + offending line. 155 io tests green.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-k8i","title":"CSV import silently drops columns when row widths mismatch","description":"crates/improvise-io/src/import/csv_parser.rs:14-20 uses ReaderBuilder with .flexible(true). Rows with fewer columns than the header are truncated with no warning. User data is silently lost on import. Fix: either enforce .flexible(false) for strict RFC 4180 parsing, or detect mismatched row widths and surface a warning to the import wizard.","acceptance_criteria":"Importing a CSV whose rows are shorter/longer than the header either errors or surfaces a per-column warning with a count of affected rows — never a silent drop. Test feeds a 5-column header with 4-value rows and asserts the warning/error mentions the missing column.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:15Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:38Z","closed_at":"2026-06-10T04:46:38Z","close_reason":"Fixed in f0b9227/77a5124 (test-first): short CSV rows padded with explicit nulls + per-column warnings threaded to status line (stderr on CLI path); wizard pre-checks 12-category limit at advance with actionable message rendered in UI; ImportJsonHeadless parses the file exactly once via extracted json_import_records helper.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-6kj","title":"Pest parse errors lack file:line context for users","description":"persistence/mod.rs:294 wraps pest errors as 'Parse error: {e}' which shows Pair offsets like 'Pair(6..12), expected \"=\", found \",\"' instead of a line preview. Users debugging a malformed .improv file have no way to find the offending line. Fix: extract (line, col) from pest::error::Error via line_col(), include the line contents and surrounding context in the wrapped error.","acceptance_criteria":"A syntactically invalid .improv file produces an error containing the source line number (and file path when loading from disk); pest spans already carry this. Test asserts the error message contains the expected line:col.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:13Z","created_by":"Ed L","updated_at":"2026-06-10T04:45:53Z","closed_at":"2026-06-10T04:45:53Z","close_reason":"Fixed in 3418162/47ad9e6 (test-first): export_csv via csv::Writer honoring number format; malformed numbers -\u003e CellValue::Error; formula [Category] suffix moved into pest grammar; parse errors report line/col + offending line. 155 io tests green.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-hmu","title":"TAB/Enter on bottom-right of records view should insert record below (TAB→first cell, Enter→same column); o inserts below cursor","description":"Current records view data entry is awkward. On last row + rightmost cell: TAB should add record below + go to first cell of new row + edit. Enter should add below + stay in same column + edit. 'o' should insert below cursor like Vim (not always at end). Red-green-refactor: add failing test first, then minimal implementation in layout.rs + related command/effect code, then refactor duplication. Builds on improvise-rbv and improvise-c5v.","status":"closed","priority":1,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T10:37:52Z","created_by":"Edward Langley","updated_at":"2026-04-16T05:08:00Z","closed_at":"2026-04-16T05:08:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-fpo","title":"Dynamic _Measure: formula targets auto-included","description":"_Measure should dynamically include all formula targets without add_formula manually adding items. Formula refs should resolve against Model::formulas as fallback. CommitFormula should not need to pick a target category for _Measure.","status":"closed","priority":1,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T10:14:14Z","created_by":"spot","updated_at":"2026-04-09T20:52:01Z","closed_at":"2026-04-09T20:52:01Z","close_reason":"Dynamic _Measure, optional target_category, atomic formula eval on load, skip virtual category persistence","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-h4r","title":"Replace ad-hoc parser with pest grammar","description":"The pest grammar at src/persistence/improv.pest defines the .improv format spec. Replace the hand-written line-scanner in parse_md with a pest-derived parser that walks the grammar's parse tree. This eliminates drift between spec and implementation.","status":"closed","priority":1,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T09:03:53Z","created_by":"spot","updated_at":"2026-04-09T09:32:38Z","closed_at":"2026-04-09T09:32:38Z","close_reason":"Replaced ad-hoc line scanner with pest-derived parser. Grammar at improv.pest is now the single source of truth for both parsing and test generation. Grammar-walking generator uses pest_meta to read the grammar AST at test time and produce random valid files as proptest entropy.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-5xd","title":"Rename Measure to _Measure (virtual category)","description":"Measure should be a virtual category (_Measure) like _Index and _Dim. This exempts it from the 12-category limit and follows naming conventions. Requires updating: import wizard, evaluate_aggregated hardcoded reference, persistence parser, demo.improv, and CategoryKind.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T08:52:11Z","created_by":"Edward Langley","updated_at":"2026-04-09T09:40:23Z","closed_at":"2026-04-09T09:40:23Z","close_reason":"Measure renamed to _Measure (VirtualMeasure kind). Fixed-point formula evaluation implemented via recompute_formulas cache. Formulas now resolve correctly with hidden dimensions.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ubu","title":"Circular formula references produce ERR instead of stack overflow","description":"Added depth limit (16) to formula evaluation. Circular/self-referencing formulas return CellValue::Error(\"circular\") instead of infinite recursion. Errors propagate through expression tree via Result. Already fixed.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-09T08:35:30Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:36:08Z","closed_at":"2026-04-09T08:36:08Z","close_reason":"Fixed in commit 56bf736","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-di3","title":"Bug: AddFormula doesn't add target item to category","description":"When adding a formula interactively (e.g. Margin = Profit / Revenue), the formula is registered but the target item (Margin) is never added to the target category (Measure). The grid layout never creates cells for it, so the formula is invisible. Found during demo recording.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-09T08:24:58Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:25:15Z","closed_at":"2026-04-09T08:25:15Z","close_reason":"Fixed: AddFormula::apply now calls category_mut().add_item() before registering the formula. Regression test added.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-n1h","title":"1.5 Create examples/demo.improv and examples/demo.csv","description":"Create two synthetic example files with obviously-fake data. demo.csv: ~30-50 rows with Date, Region, Product, Customer, Revenue, Cost columns. demo.improv: result of importing demo.csv, optionally with interesting default view and sample Profit formula. Data must be obviously synthetic.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:53Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:16:33Z","close_reason":"Created examples/demo.csv (40 rows, obviously-fake data with fictional companies) and examples/demo.improv (generated via headless import with Profit formula and a useful default view). Added import command to README.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-bv1","title":"1.4 Audit CSV quote handling","description":"Verify csv_parser correctly handles RFC 4180 quoted fields: embedded commas, escaped quotes. Ensure it uses the csv crate, not manual split. Add unit test round-tripping a row with embedded comma and escaped quotes. If fundamentally broken, add Known Limitations note to README instead.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:51Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T06:58:41Z","close_reason":"Audit complete. The csv crate handles all RFC 4180 cases correctly: embedded commas, escaped quotes, embedded newlines. Added 4 regression tests. No bugs found.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2fr","title":"1.3 Verify publish-readiness (cargo publish --dry-run)","description":"Run cargo publish --dry-run from inside nix develop. Fix any errors or warnings (missing license file, files too large, dependency issues). Do not actually publish.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:49Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:21:22Z","close_reason":"cargo publish --dry-run passes cleanly: 72 files, 952KB, no errors or warnings.","dependencies":[{"issue_id":"improvise-2fr","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T21:09:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-km8","title":"1.2 Fix Cargo.toml metadata","description":"Add missing package fields: description, repository, homepage, documentation, readme, keywords, categories, license. Description: 'Terminal pivot-table modeling in the spirit of Lotus Improv'. Keywords: tui, pivot, spreadsheet, data, improv. Categories: command-line-utilities, visualization.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:48Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:20:17Z","close_reason":"Metadata already present: description, license, repository, homepage, readme, keywords, categories all populated.","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-902","title":"1.1 Audit and remove or update context/SPEC.md","description":"Compare context/SPEC.md against actual code. If spec contradicts code significantly, delete it. Optionally move salvageable conceptual content to docs/design-notes.md with a staleness disclaimer. Do not try to bring spec into sync.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:46Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:10:05Z","close_reason":"Audited SPEC.md against code. Found minor inaccuracies (storage internals, wizard step count, search mode representation) but no major architectural contradictions. Deleted SPEC.md since it was largely redundant with repo-map.md and design-principles.md. Salvaged product vision and non-goals into docs/design-notes.md with staleness disclaimer.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-tik","title":"Phase 1: Repository hygiene","description":"Raise the quality floor of what a stranger sees when they land on the repo. Subtasks cover spec audit, Cargo.toml metadata, publish-readiness, CSV audit, and example files.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:45Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:22:34Z","closed_at":"2026-04-09T07:22:34Z","close_reason":"All 5 subtasks complete: SPEC.md audited, Cargo.toml metadata fixed, publish dry-run clean, CSV audit done, example files created.","dependencies":[{"issue_id":"improvise-tik","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T23:37:31Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-902","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-bv1","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-xks6","title":"One home for the client/server frame envelopes","description":"Two crates now define the same frames independently, which is a live divergence risk:\n improvise-wasm-client::wire -- ClientMessage / ServerMessage\n improvise-ws-server::wire -- ClientMsg / ServerMsg\nThe ws-server agent asked for one version to be landed and will delete its wire.rs and re-export; the change on its side is two type names.\n\nPUT THEM IN improvise-protocol. Both sides already depend on it, and this is the one thing there that is FRAMING rather than vocabulary -- the protocol says what a Command is, but not what distinguishes one JSON blob from another on the wire.\n\nTHE SHAPE, as the ws-server landed it and the client should adopt:\n client -\u003e server: Viewport { rows: usize, cols: usize } | Command(Command)\n server -\u003e client: Snapshot(Box\u003cSnapshot\u003e) | Commands(Vec\u003cCommand\u003e)\nNotes that matter: Snapshot is BOXED because clippy large_enum_variant fires otherwise and a Snapshot is sent once per connection while Commands is every frame. Server-\u003eclient is ONE shape carrying both projections (as Command::Project) and a peer broadcast, because on the wire they are the same thing and a client folds both -- a separate Projections variant was rejected for that reason.\n\nViewport is the only addition to the shared vocabulary and it is derived, not invented: the extent is the one session input no Command carries by construction (gotcha 40).","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:46:23Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:46:23Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-l4qx","title":"[bug] A text cell is invisible whenever any category is on the None axis","description":"Found during improvise-q08 (2026-09-19), PRE-EXISTING, and it is the NORMAL case rather than an edge one.\n\nModel::evaluate_aggregated takes the aggregating path when none_cats is non-empty, and that path does filter_map(as_f64) -- so a CellValue::Text aggregates to NOTHING and never becomes a CellDisplay. _Index and _Dim sit on Axis::None in a default view, so none_cats is non-empty almost always.\n\nWHY IT MATTERS: .improv has pipe-quoted text cells as a FIRST-CLASS FEATURE -- the format doc gives Region=West, Measure=Revenue = |pending| as an example -- and they do not render. A user can write a text value, save it, load it and not see it.\n\nThe q08 agent hit this while building a fixture, worked around it there, and noted it in the round-trip test comment. Costing a test iteration is how it surfaced; nothing in the suite covers it.\n\nWrite the failing test first: a text cell in a default view (so _Index/_Dim are on None) must render its text. Then decide what aggregation of text should MEAN -- filter_map(as_f64) silently dropping it is clearly wrong, but \"show the single value when there is exactly one, and something explicit when there are several\" is a product decision, not an obvious fix. Note gotcha 7: apply_agg and eval_agg already differ on empty input, so this family has precedent for being decided rather than defaulted.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:46:09Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:24:05Z","closed_at":"2026-09-20T00:24:05Z","close_reason":"FIXED. aggregate_cell_values in model/types.rs, shared by evaluate_aggregated and aggregate_raw (which had byte-identical bodies precisely so they could not drift -- improvise-kdp).\n\nTHE RULE IS UNANIMITY, and the ORDER is load-bearing:\n empty -\u003e None (absent, unchanged -- follows apply_agg)\n ALL NUMERIC -\u003e apply_agg -\u003e Number (unchanged; checked FIRST)\n all values equal -\u003e that value\n otherwise -\u003e Error(\"mixed\")\n\nReducing several copies of one value is the identity, so that reduction has an answer even when the values are not numbers -- which covers the case the ticket is actually about. Two different words, or a word among numbers, have no reduction, and gotcha 7 already has precedent for saying so out loud (eval_agg returns Err(\"empty\") for AVG/MIN/MAX over nothing rather than inventing a value). So this is consistent with BOTH existing rules: emptiness follows apply_agg (absent, not zero), undefined-reduction follows eval_agg (explicit error, not silence).\n\nTHE ORDERING IS THE WHOLE DIFFERENCE from the ticket own first suggestion. \"Show the single value when exactly one matches\" taken literally BREAKS Count, whose entire point is to not be the value. The all-numeric branch is tested first, so Count of a lone number is still 1. Proven by injection: swapping the branch order failed exactly one test, a_lone_number_still_counts_as_one_rather_than_passing_through, which is why that guard exists.\n\nREJECTED: \"show the first\" (depends on DataStore hash iteration order, so the displayed value would be arbitrary AND look authoritative while hiding the others -- worse than the blank); \"show a count\" (changes the type of the answer; a count rendered under a Sum measure is indistinguishable from real data); and making the display path differ from the formula path.\n\nBEHAVIOUR CHANGE TO KNOW: a formula ref over MIXED values now errors \"mixed\" instead of \"ref:name\". Still an error, but a truer one, and it no longer silently sums the numbers while dropping the text. Text-valued refs still error exactly as before. Fixed-point convergence untouched.\n\nFailing test first: a_text_cell_renders_in_a_default_view, which also asserts !none_cats.is_empty() to confirm the premise that a DEFAULT view really does put _Index/_Dim on None -- the normal path, not an edge one. 1024 tests pass; three faults injected, all fired.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-vjms","title":"Decide whether a category panel shows the virtual categories","description":"Found by improvise-35e (2026-09-19). A GENUINE BEHAVIOUR DIVERGENCE, which is why that ticket stopped rather than migrating the widget.\n\nThe TUI category panel builds from cat_tree::build_cat_tree, which walks Model::category_names() and therefore draws _Index, _Dim and _Measure. CategoryRow/category_rows carry only regular_category_names(), and CategoryTreeCache own doc says \"Only the categories a user may see -- the client does not know the _-prefixed virtual-category convention\".\n\nSo migrating the category panel to the cache would SILENTLY DROP THREE ROWS from the TUI panel. That is a behaviour change, not a relocation, so doctrine(characterization_survives_the_move) says stop and ask.\n\nTHE DECISION IS improvise-edp: either the browser panel grows the virtual categories, or the TUI panel loses them. Note gotcha 7 -- _Measure items are explicit items UNION formula targets, so a client showing _Measure needs measure_item_names semantics, not a raw item list; that is part of the cost of the first option.\n\nWhichever way it goes, the widget then also needs a tree flattener over CategoryTreeCache -- today build_cat_tree takes \u0026Model.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:06:54Z","closed_at":"2026-09-20T00:06:54Z","close_reason":"DECIDED (user, 2026-09-19): the TUI keeps the virtual categories AND the browser gets them too. CategoryTreeCache grows to carry _Index, _Dim and _Measure, so both panels render the same thing and the cache stays the single source for what a category panel shows.\n\nWHAT THIS COSTS, and it is the part to get right: the client now has to be told about the _-prefixed convention, which the caches doc currently says it does not know. In particular _MEASURE ITEMS ARE DERIVED, not a raw list -- gotcha 7: they are the explicit items UNION the formula targets, and add_formula does NOT add items to _Measure. So category_tree_of must ship membership computed through measure_item_names()/effective_item_names(), never category(\"_Measure\").items, or the panel will silently omit every formula target. That is the same trap improvise-ete was closed for proposing.\n\nConsequences to carry into improvise-edp:\n * CategoryTreeCache and its doc comment change -- the \"the client does not know the _-prefixed virtual-category convention\" line is now false and must go.\n * build_cat_tree can then migrate off \u0026Model, which is what unblocks the category panel and therefore edp.\n * A characterization test should pin that the cache carries all seven rows for a default workbook (4 regular + 3 virtual) and that _Measure includes a formula target that is not an explicit item -- one fixture that would fail under the raw-list implementation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-pauh","title":"Make the beads flake pin actually bind in agent worktrees","description":"Two mitigations, BOTH wanted (user, 2026-09-19). Manual direnv allow is not sufficient on its own -- see the scaling problem below.\n\nTHE PROBLEM. flake.nix pins beads to github:gastownhall/beads/v1.3.0 precisely to control bd version. A git worktree does NOT load that pin: .envrc is unallowed in a fresh worktree, so a process there falls through to ~/.nix-profile/bin/bd. Verified 2026-09-19 with a scrubbed DIRENV env: \"Found RC allowed 0\". Today the profile bd is ALSO 1.3.0, so nothing breaks -- which is exactly why a careless test passes and why this is worth fixing before it bites.\n\nTHE FAILURE MODE IS SILENT. See the bd-1-0-2-dev-embedded-dolt-dependencies memory: bd VERSION SKEW SILENTLY EMPTIES THE DEPENDENCY GRAPH in .beads/issues.jsonl. No error; the loss is invisible until someone asks what blocks what. The flake pin exists to prevent this and an unallowed worktree bypasses it.\n\nWHY MANUAL ALLOW DOES NOT SUFFICE: agent worktrees are created FRESH PER DISPATCH. Allowing the ones that exist right now does not help the next one, so any manual pass is stale the moment another agent starts. Confirmed today -- allow entries were created at 16:35 and a worktree from earlier still reported allowed 0.\n\nMITIGATION 1 -- make the allow automatic. direnv allow (or an equivalent: a whitelist.prefix entry in ~/.config/direnv/direnv.toml covering the worktree root, which would cover every future worktree in one stroke and is probably the better answer) at worktree creation. Note whitelist.prefix is currently [] per direnv status.\n\nMITIGATION 2 -- assert the version rather than trust it. Have .claude/hooks/session-start.sh compare bd version against the flake pin and FAIL LOUDLY on a mismatch. doctrine(force_the_failure) applies: the whole point is that the skew is silent, so the check must be the thing that is not. Read the pin from flake.nix rather than hard-coding it, or the assertion rots the first time the pin moves.\n\nReading with a mismatched bd is safe; it is MUTATING commands that corrupt. If a cheap distinction is available, guard those specifically.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:39:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:30:40Z","closed_at":"2026-09-20T00:30:40Z","close_reason":"Half 2 landed: section 0 of .claude/hooks/session-start.sh asserts bd matches the flake pin, reading the pinned version OUT OF flake.nix so there is no second copy to rot on the next bump. Fails loudly (exit 2 + banner) on mismatch, on an unparseable pin, AND on an unreadable bd version -- an assertion that cannot answer is not a pass. Placed BEFORE the CLAUDE_CODE_REMOTE gate, because the local worktree is where the problem lives; the cloud path re-checks after installing bd and still completes tracker sync and cargo warm. It blocks nothing -- reads are safe, and the banner names the mutating commands specifically.\n\nDemonstrated both ways rather than asserted: a stub reporting 1.2.4 fires the banner; a stub reporting 1.3.0 is silent; an unparseable version fires; a flake.nix pinning a bare commit fires the cannot-read-the-pin arm. With the real binaries BOTH of today candidates pass (the flake store path and ~/.nix-profile), which is exactly the ticket point -- today pass proves nothing.\n\nHALF 1 IS THE USER TO MAKE, and is reported rather than done: create ~/.config/direnv/direnv.toml (it does not exist today) with [whitelist] prefix = [\"\u003crepo root\u003e\"]. One prefix covers the main checkout and every future worktree since worktrees live under .claude/worktrees/ inside it. Verified without touching the user file by pointing DIRENV_CONFIG at a scratch copy: same worktree went from not-allowed to allowed. There is NO repo-side alternative by design -- direnv refuses to let a repository authorize its own .envrc, and that refusal IS the security model; a .claude/settings.json env block prepending the flake store path was considered and rejected because it hard-codes a machine-specific nix hash that rots on every bump and is less trustworthy than the pin it claims to enforce.\n\nCORRECTION THIS TICKET SURFACED: the evidence in its original description read \"Found RC allowed 0\" as the broken state. It is the opposite -- direnv prints the AllowStatus enum where 0=Allowed, 1=NotAllowed. The conclusion still holds, on better evidence: this agent worktree (created after the users allow batch) reports allowed 1 and its allow file does not exist on disk.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3al1","title":"ADR-001 c: a rejected batch keeps its values staged, and class-1 force re-composes","description":"ADR 001 layer 1, step c. Depends on step b. This is the half that makes rejection usable.\n\nTWO OBLIGATIONS, both pulled forward from layer 2 by the user force requirement:\n1. The rejected batch VALUES STAY STAGED in the session, so rejection never costs the user their typing. This is the drill staging mechanism doing what it already does; only PUBLISHING the staged keys is layer 2.\n2. FORCE, for conflict class 1 (two writers, same record): re-compose the same INTENT against the current state and submit a fresh batch with a CURRENT basis. One keystroke.\n\nFORCE IS A RE-COMPOSITION, NOT A BYPASS. Never apply a batch whose basis is stale -- a mechanism whose only purpose is to violate the invariant is the invariant deleted with extra steps.\n\nRULE: FORCE NEVER CREATES STRUCTURE. That rule is what keeps class 2 out of this ticket.\n\nTESTS: a rejected edit leaves the staged value intact; force after rejection lands the value and the basis is current at apply time; force does not re-run against the stale basis.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:31Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:27:31Z","dependencies":[{"issue_id":"improvise-3al1","depends_on_id":"improvise-fkfg","type":"blocks","created_at":"2026-09-19T23:27:45Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-fkfg","title":"ADR-001 b: ModelCommand carries its basis; Applied gains a rejected case","description":"ADR 001 layer 1, step b. Depends on step a.\n\nModelCommand carries the basis it was composed against -- (document_generation, layout_version, and the record_versions of the records it touches). Sessions::apply rejects a batch whose basis moved.\n\nApplied grows a THIRD case beside Diff and Rebased. An enum, not a flag: matching is what forces a host to handle it, which is exactly the reasoning gotcha 39 records for rejecting Applied { .., rebase: bool }.\n\nTHE TIERS ARE THE POINT: a CellData batch is stale if LAYOUT moved, because the shape its keys hang off changed. That is the corruption class -- A deletes category Region while B commits a key naming Region=East -- detected without any lock, and it is why a single counter is not enough.\n\nRejection is a NORMAL OUTCOME, not an error. The client already has the new state via projections.\n\nTESTS, both arms per doctrine(one_arm_of_a_symmetric_rule...): a stale CellData batch after a layout change is rejected; a fresh one is not; a batch touching an untouched record is not rejected by another record moving; a WholeWorkbook bump invalidates everything. Beware doctrine(a_rename_can_hide_a_stale_key) -- hold identifiers constant and vary only what makes the basis stale.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:30Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:27:30Z","dependencies":[{"issue_id":"improvise-fkfg","depends_on_id":"improvise-3x3u","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-fkfg","depends_on_id":"improvise-5a5y","type":"blocks","created_at":"2026-09-19T23:27:44Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-5a5y","title":"ADR-001 a: version tiers on App (layout_version, record_version)","description":"ADR 001 layer 1, step a -- the foundation. No wire change, no UX; testable alone.\n\nAdd beside App::document_generation (which already exists and is bumped by install_workbook):\n * layout_version -- bumped by any effect declaring ModelChange::LayoutGeometry\n * record_version -- per RECORD, bumped by a CellData effect touching that record\nA record is the coordinate tuple EXCLUDING _Measure -- record_row_identity(wide, coord_group_excluding_measure), already in kb.pl. NOT the cell: gotcha 1 says editing a category column moves every measure cell in the row, so a cell-level unit is violated by a single legal edit. The unit must be at least as coarse as the coarsest legal write.\n\nThese are SHARED DOCUMENT STATE, so they live on App next to document_generation, never on ViewState (gotcha 30).\n\nBump them from the same place Dirty is computed, so the taxonomy has one reader: App::apply_effects already does dirty.union(Dirty::of(effect.kind())). Do not add a second classification table -- gotcha 35.\n\nTESTS: a LayoutGeometry effect bumps layout_version and not record_version; a CellData effect bumps only the records it touched; an aborted batch (gotcha 21) bumps nothing it did not run. Prove each by injection.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:28Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:34:34Z","closed_at":"2026-09-19T23:34:34Z","close_reason":"App::layout_version and App::record_versions added beside document_generation, with accessors and a free record_of(\u0026CellKey) that makes record_row_identity a function. 933 tests passing.\n\nTHE DESIGN PROBLEM WORTH KEEPING: an EffectKind says THAT a batch writes cells, never WHICH. So layout_version reads cleanly off the taxonomy in the apply_effects loop, but record_versions cannot. Rejected adding a per-effect \"which records\" method -- a 50-effect burden and exactly the second table gotcha 35 warns against -- in favour of OBSERVING THE DATA: a per-record XOR fingerprint taken when the batch declares a CellData effect, diffed after the loop. The O(cells) pass is bounded by the O(cells) rebuild_layout() that already ends every batch and is paid only on editing batches.\n\nTwo documented semantic consequences: a write landing the value already there bumps nothing (correct -- no basis was invalidated, so rejecting for it would be a rejection for nothing); and a LayoutGeometry effect that drops cells bumps their records when sharing a batch with a CellData one (an over-bump, the safe direction, the same asymmetry Dirty::of is deliberately sloppy in).\n\nA REAL BUG THE TESTS CAUGHT DURING DEVELOPMENT: the first bump_changed_records chained before.keys() with after.keys() and incremented per name, so any REWRITTEN record counted as two writes. It is a HashSet now. Precisely the class of error a weaker fixture would have hidden -- the fixture is deliberately two categories, two measures, two records, because a one-record fixture cannot tell \"the record it touched\" from \"every record\" and a one-measure fixture cannot tell a record from a cell.\n\n7 tests including a_coordinate_rename_is_one_write_to_each_record_not_one_per_measure (the test that earns the record unit) and the_version_tiers_are_not_in_the_view_state. Five faults injected, each fired.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3x3u","title":"Resolve the two unaddressed nullary wire variants before concurrency lands","description":"ADR 001 names this as a prerequisite. ApplyAndClearDrill and AcceptWizardModel are routed by Sessions::apply with NO SessionId, while drill_state and wizard live on the single authoritative App. So client B sending Model(ApplyAndClearDrill) commits client A uncommitted staged edits, and Model(AcceptWizardModel) installs the workbook A half-finished wizard built -- replacing the document under everyone.\n\nThat already collides with the user ruling on improvise-tom1 (staged edits are private to whoever staged them); committing them on someone else behalf is the stronger version of the same violation. ADR 001 makes it untenable outright, because layer 2 turns staged keys into reservations held BY a session -- and a command that commits them cannot be unaddressed.\n\nTWO HONEST OPTIONS:\n (a) make them addressed -- carry a SessionId, and the effect acts only on that session staged state, which means the staging map moves off the single App and onto the session;\n (b) remove them from the wire vocabulary and keep them key-path only.\n(b) is cheaper and consistent with tom1. (a) is what layer 2 eventually wants if staged state becomes per-session anyway. Decide deliberately -- note that (b) is not a permanent answer if multi-client drill is ever wanted.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:55Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:34:33Z","closed_at":"2026-09-19T23:34:33Z","close_reason":"DECIDED: option (b) -- ApplyAndClearDrill and AcceptWizardModel removed from ModelCommand (28 -\u003e 26 variants). Their effects are unchanged and still reached by the key path.\n\nThe bug was DEMONSTRATED before the fix, not assumed: a temporary test against Sessions::apply -- client A stages a drill edit, client B sends Model(ApplyAndClearDrill) -- PASSED on the pre-change code, committing A uncommitted 99 into the document and clearing A drill session.\n\nRejected (a), carrying a SessionId: it is the honest long-term form but requires the staging map to move off the single App onto the session, which IS ADR 001 layer 2. Landing it inside the correctness floor would make the branch unreviewable. The doc comment says so, so (b) is not mistaken for permanent.\n\nTHE MORE DURABLE PART IS THE CORRECTED CRITERION. Gotcha 38 kept these on the grounds that \"the command is portable even where the operand is not\". Portability was the wrong question. Gotcha 36 already states the right one: an unaddressed command is correct exactly when the change is addressed to NO ONE session -- and these are addressed to precisely one, which is never the sender.\n\nThe demonstration cannot survive its own fix (the variant it sends is gone), so the surviving guard is structural: no_model_command_commits_another_clients_session_local_state walks every_model_command() and forbids either effect, plus a companion asserting both effects still exist and are still model changes, so the first cannot pass by naming nothing. Both proven by injection.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-xwi6","title":"Concurrent edits: basis check (ADR 001, layer 1 — the correctness floor)","description":"Implements layer 1 of context/adr/001-concurrent-edits.md. User decision 2026-09-19: record-level atomicity with optimistic concurrency per batch, no causal clock.\n\nTHE FRAMING THAT MATTERS: the server already serialises. Sessions::apply -\u003e apply_command -\u003e App::apply_effects against one App, so a total order EXISTS. This ticket is not about ordering; it is about noticing that a client composed a batch against a basis that has since moved -- the same stale-basis shape as gotcha 24 (cursor) and gotcha 39 (session mirror), one level up.\n\nSCOPE:\n1. Version tiers, living beside document_generation on the authoritative App (they are shared document state, NOT ViewState -- gotcha 30):\n * document_generation -- exists, bumped by WholeWorkbook\n * layout_version -- bumped by any LayoutGeometry\n * record_version -- per record, bumped by CellData on that record\n A record is the coordinate tuple EXCLUDING _Measure: record_row_identity(wide, coord_group_excluding_measure), already in kb.pl. Not the cell -- gotcha 1 says editing a category column moves every measure cell in the row, so a cell-level unit is violated by a single legal edit. The unit must be at least as coarse as the coarsest legal write.\n2. ModelCommand carries the basis it was composed against.\n3. Sessions::apply rejects a stale batch. Applied grows a third case beside Diff and Rebased -- an enum so matching FORCES a host to handle it, which is gotcha 39 reasoning and the reason a bool was rejected there.\n4. Rejection is a normal outcome, not an error: the client already has the new state via projections and re-composes.\n\nWHY THE TIERS ARE THE POINT: a CellData batch is stale if LAYOUT moved, because the shape its keys hang off changed. That is the corruption class -- A deletes category Region while B commits a key naming Region=East -- detected without any lock. Reuses ModelChange so there is no second classification to drift from gotcha 35 table.\n\nTESTS: the arms, per doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) -- a stale CellData batch after a layout change is rejected; a fresh one is not; a batch touching an untouched record is not; a WholeWorkbook bump invalidates everything. Beware doctrine(a_rename_can_hide_a_stale_key): hold identifiers constant and vary only what makes the basis stale.","notes":"\n--- REFINEMENT (user, 2026-09-19): a rejected edit must be forceable ---\nFORCE IS A RE-COMPOSITION, NOT A BYPASS. Never apply a batch whose basis is\nstale -- a mechanism whose only purpose is to violate the invariant is the\ninvariant deleted with extra steps. Force re-composes the same INTENT against\nthe current state and submits a fresh batch with a current basis.\n\nWhat force can mean differs by conflict class, and this is the part to get\nright:\n class 1 (two writers, same record) -- YES, trivially. Re-compose and apply;\n the user has seen the other value and chooses to overwrite. One keystroke.\n class 2 (shape change vs cell edit) -- NO. The intent no longer type-checks:\n the key names a coordinate that no longer exists. Applying anyway writes a\n CellKey naming a deleted category, which is the ghost-coordinate\n corruption this whole design exists to prevent.\n class 3 (formula shadows a value) -- yes, with a warning: the write lands,\n the displayed value is still derived.\n\nRULE: FORCE NEVER CREATES STRUCTURE. Re-instating a category someone deleted\nso an edit can land is a different and larger intent than the user expressed;\nit must be an explicit action, not a side effect of forcing a cell edit. When\nan intent cannot be re-composed, the UI owes the user what CHANGED and the\nconcrete alternatives -- re-target, or re-create the structure first -- rather\nthan a bare refusal.\n\nTWO THINGS THIS PULLS FORWARD FROM LAYER 2, into this ticket:\n 1. The rejected batch values STAY STAGED in the session, so rejection never\n costs the user their typing and force is cheap rather than a retype. This\n is the drill staging mechanism doing what it already does; only PUBLISHING\n the staged keys stays in layer 2.\n 2. A forced overwrite must be VISIBLE to the client it overwrote. Otherwise\n force is silent data loss with a confirmation step in front of it. The\n loser edit was already committed, so this is a notification rather than a\n conflict -- but without it, \"someone else won\" is indistinguishable from\n \"my edit never happened\".\n\n--- DECOMPOSED 2026-09-19; this is now the umbrella, do not implement it directly ---\nADR approved by the user. Layer 1 is split into five steps with real dependencies:\n improvise-5a5y a: version tiers on App (no wire change, testable alone)\n improvise-fkfg b: ModelCommand carries its basis; Applied gains a rejected case\n improvise-3al1 c: rejected batch keeps its values staged; class-1 force re-composes\n improvise-vb6l d: when an intent cannot be re-composed, show what changed (the expensive one)\n improvise-2wi8 e: a forced overwrite is visible to the client it overwrote\nPrerequisite: improvise-3x3u (the two unaddressed nullary wire variants) blocks b.\nLayer 2: improvise-7b8v (advisory reservations) is blocked by c, because c is where the staging buffer lands.\nThe design rationale stays here and in context/adr/001-concurrent-edits.md; the steps carry only what their implementer needs.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:53Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:27:49Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gvnb","title":"[bug] A geometry command resets the App cursor but not the session mirrors","description":"Found during improvise-kmgs (2026-09-19). Pre-existing for the key path; the model half of the wire vocabulary makes it reachable remotely, which is what raises it to P2.\n\nCycleAxis and TransposeAxes call app.reset_cursor() because renumbering rows or columns invalidates the cursor (gotcha 24). But each ClientSession keeps its OWN cursor in its mirrored ViewState, and nothing resets those. So a bystander is projected renumbered rows at its old scroll -- gotcha 24 cursor-vs-geometry mismatch, arriving through somebody else command.\n\nThis is the same SHAPE as improvise-kvyt (a view switch moved only the asking session mirror) and should be fixed the same way: the geometry change is addressed to no one session, so every mirror owes the reset. See doctrine(lockstep_binds_every_session_not_the_asker) -- ask of any shared value, who else derives something from it. Here the derived thing is the cursor whose validity depends on the grid shape.\n\nTest it the way kvyt was tested: a bystander session, not the one that issued the command.","notes":"\n--- CONFIRMED AGAIN on PR #11 by Copilot, and deliberately NOT fixed there ---\nThe PR agent reproduced it and declined to fix it in that PR with three\nreasons, all of which stand:\n (a) Applied::Diff.broadcast is a single Option\u003cCommand\u003e and there is NO\n cursor-reset command -- a ViewCursor is selected plus both offsets, so\n this needs a new wire variant, derived not invented.\n (b) it read the policy question as improvise-g0f, \"deliberately unanswered\".\n (c) A PARTIAL FIX IS WORSE THAN NONE: resetting only the server-side mirrors\n would make the diff logic believe each client already holds origin-based\n content and send nothing -- leaving clients stale PERMANENTLY instead of\n until their next scroll. Note that carefully when implementing.\nObserved effect today: Viewport::window clips (start = offset.min(total)), so\na bystander gets a BLANK grid rather than garbage -- blank until it scrolls.\n\n--- THE POLICY QUESTION IS ANSWERED; (b) rests on a stale reading ---\nimprovise-g0f is decided (per-client active view), and the standing principle\nsettles this case directly: THE MODEL IS SHARED, THE UI IS NOT. Two things\nthat sound alike and are not:\n * Another client transposing must NOT move my cursor -- where I am looking\n is UI, and it is mine.\n * It MUST invalidate my cursor, because the grid my cursor indexes just\n changed shape. Validity is derived from shared geometry even though\n position is private.\nSo the answer is neither \"reset everyone to origin\" (that moves my UI on\nsomeone else behalf) nor \"leave it\" (that indexes a grid that no longer has\nthose rows). It is: the geometry change invalidates every client cursor, and\neach client re-establishes its own -- which is exactly what a cursor-reset\nwire variant should mean, and why it is a variant rather than a broadcast of\nsomebody else position.\n\nSame family as improvise-kvyt and doctrine(lockstep_binds_every_session_not_the_asker):\nask of any shared value, who else DERIVES something from it. Here the derived\nthing is the validity of a private cursor.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:11:31Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:50:15Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-tym1","title":"[bug] folds(n, 0) panicked and mse divided by a length it had not scored","description":"Copilot finding on PR #10 (2026-09-19), partly real, fixed in 27a4cbd with both tests written first.\n\nfolds(n, 0) did not merely misbehave -- it PANICKED on out[pos % 0]. k arrives from a CLI flag, so zero is a value a host supplies, and gotcha 37 says gp-tree is a library a TUI or a wasm worker may embed, where a panic is not a usage error message. Now k.max(1), matching fit()s existing restarts.max(1).\n\nmse divided by x.len() rather than the number of rows it actually scored, so a length mismatch between the parallel slices silently skewed the result through zip truncation. Now it counts the rows it scored. Bit-identical where the slices match -- Sum for f32 is the same left fold -- so wave 3 nine-scenario byte-identical CLI evidence still holds.\n\nProven first: the folds test panicked; the mse test failed 16.912476 vs 17.802607.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:01:09Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:01:10Z","closed_at":"2026-09-19T22:01:10Z","close_reason":"Fixed in 27a4cbd, merged to main in PR #10 as 336533b.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-tom1","title":"Drill pending edits are invisible to remote clients, and there are now three answers to 'what text is in this cell'","description":"Wave-2 review (2026-09-19), CONFIRMED by reading.\n\nproject.rs cell_display resolves records-mode text through layout.records_display(row,col). The TUI grid widget overlays staged drill edits (src/ui/grid.rs, drill_state.pending_edits) and so does App::display_value_at_cursor. SetDrillPendingEdit is ViewOnly, so Dirty is empty too. A remote client in drill mode sees neither the staged edit nor any update for it, until ApplyAndClearDrill (CellData) lands.\n\nThis is the 'second, silently divergent description' the producer's own module header warns against: three answers to what text is in a cell (GridLayout::display_text, src/ui/grid.rs, project::cell_display) and only two agree.\n\nNote the tension with the user's principle 'the model is shared, the UI isn't': pending drill edits are UNCOMMITTED writes belonging to whoever staged them (that is why gotcha 30 keeps drill_state off ViewState). So the answer may legitimately be 'a remote client SHOULD NOT see them' -- in which case the defect is only the three-way divergence, not the invisibility. Decide which before coding.","notes":"\n--- DECIDED (user, 2026-09-19): NO ---\nA remote client must NOT see another client staged drill edits. This follows\nfrom the standing principle -- the model is shared, the UI is not -- and from\ngotcha 30, which keeps drill_state off ViewState precisely because pending\nedits are UNCOMMITTED WRITES BELONGING TO WHOEVER STAGED THEM. They become\neveryone business only at ApplyAndClearDrill, which is CellData and already\nreaches every client.\n\nSO THE INVISIBILITY IS CORRECT AND IS NOT THE BUG. Do not \"fix\" it by teaching\nthe producer to read drill_state; that would put one client uncommitted writes\non another client screen and contradict the decision.\n\nRE-SCOPED -- the defect that remains is the THREE-WAY DIVERGENCE in what text\nis in this cell:\n * App::display_value_of -- editable RAW text, resolves a staged drill\n edit, via layout.resolve_display\n * project::cell_display -- FORMATTED text + numeric/error flags; in\n records mode calls layout.records_display\n and then INFERS numeric-ness by\n text.parse::\u003cf64\u003e() -- the one place this\n module guesses rather than knows\n * src/ui/grid.rs -- formats for the terminal, also overlays\n pending edits\nThree answers, two of which agree. The cheap collapse identified during the\nreview is between the first two, on the records branch only: both want\n\"resolve a synthetic record key through the layout\", so cell_display records\narm can share the resolve_display path via a helper on GridLayout, keeping the\nPENDING-EDIT lookup out of it. That removes the duplicated resolution and,\nwith a real CellValue in hand, the parse::\u003cf64\u003e() inference -- while leaving\nthe staging map session-local and unread by the producer, as decided.\n\nIt is a behaviour change on the records projection, so it wants the failing\ntest first and a check that no existing records test changes meaning.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:41Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:03:13Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-jytm","title":"Split active_view: document default vs per-client authoritative (improvise-g0f)","description":"Implements the user's improvise-g0f decision (2026-09-19): two clients MAY view different views.\n\nTODAY (gotcha 29): Workbook::active_view and ViewState::active_view are two copies of one fact, kept identical by App::switch_to_view and App::install_workbook, guarded by the_two_active_view_names_never_diverge. The second copy exists because reduce_view runs on a client holding no Workbook.\n\nTARGET: Workbook::active_view = the document's default, i.e. what a freshly connected client opens. ViewState::active_view = authoritative for that client. They may differ.\n\nWORK:\n1. Replace the_two_active_view_names_never_diverge with a test of the NEW contract -- do not simply delete it. A deliberate split with no guard is how this silently regresses. Assert that a fresh session opens the document default, and that one client switching does not move another.\n2. Re-examine SwitchView's EffectKind. Under lockstep it is a Model change; per-client it looks like ViewOnly (it must not reach other clients). Check what else SwitchView does before changing the kind -- gotcha 3, a mis-declared kind is silent.\n3. ClientSession::project must emit that client's whole widget set on a switch: every projection is view-dependent, and the grid mirror describes the OLD view.\n4. Persistence: Initial View in .improv is the document default, which is now exactly the right meaning -- confirm the writer still takes it from Workbook and not from any client.\n5. Update gotcha 29 in context/repo-map.md (orchestrator owns that file) and the kb decision recording the old lockstep rule.\n\nUnblocks improvise-cqq.","notes":"\n--- GUIDING PRINCIPLE (user, 2026-09-19) ---\n'The model is shared and the UI isn't.' g0f follows from this rather than\nbeing an independent choice: which view you are looking at is UI, not\ndocument. The principle is already embodied in EffectKind -- ViewOnly is UI\n(reaches nobody else), Model(...) is document (reaches everyone) -- so the\nsplit should make active_view agree with that existing rule rather than\nintroduce a new mechanism.\n\nDO NOT RULE OUT A FOLLOW MODE. The user explicitly wants the option of\nbinding two UIs together later (one client mirroring another's view, cursor\nand scroll) and explicitly does NOT want it implemented yet. Design\nconsequence for THIS ticket: keep per-client UI state addressable per\nsession -- ClientSession already does this -- so a follow relationship can\nlater be expressed as one session DERIVING its ViewState from another's.\nDo not 'simplify' by making any UI state globally shared to get follow\nbehaviour for free; that would trade the principle for the feature and is\nthe one shape that forecloses it.\n\n--- MUST REVISIT improvise-kvyt WHEN DOING THIS (2026-09-19) ---\nCopilot found on PR #10 that a view switch moved only the ASKING session mirror,\nstaling every bystander cursor: each client grid is built by grid_of from\nworkbook.active_view(), so a bystander left naming the outgoing view was\nprojected the NEW view rows positioned by the OLD view scroll. Fixed in 8475b2d\nby broadcasting the ACCEPTED view to every mirror, with Sessions::apply now\nreturning Applied { dirty, broadcast } so the host can forward the switch to the\nother clients, whose slices live on them.\n\nThat fix is correct ONLY for the current lockstep model. Under this ticket split\n-- active_view per-client, the model is shared and the UI is not -- a view switch\nbecomes the asker business alone and the broadcast either disappears or changes\nmeaning entirely. Do NOT keep it by inertia: re-derive whether anything still has\nto reach the other sessions when the asker switches, and if the answer is nothing,\ndelete the broadcast and the test that pins it rather than leaving a mechanism\nwhose reason has gone. If the answer is something (e.g. a follow-mode client\nbound to the switcher), that is the shape follow mode wants -- see the\nmodel-shared-ui-not-shared memory, which says explicitly not to foreclose it.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T21:00:51Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:53:13Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-kmgs","title":"Derive the model half of the Command vocabulary from the EffectKind::Model effects","description":"From improvise-gxi (2026-09-19). Blocks that ticket's ACs 4 and 5, which the producer agent correctly declined to force.\n\ngxi asks for 'keymap lookup returns Command values rather than Box\u003cdyn Cmd\u003e' and 'CmdRegistry::dispatch_command(cmd: \u0026Command, ...)'. Neither is reachable today: the Command enum's client-\u003eserver half is exactly the ViewOnly portable-slice mutations, because decision(improvise-cqi, command_set_derived_not_invented) derived it from those effects and nothing else. There is no SetCell, AddCategory, SetAxis or Save in it. So a keymap cannot return a Command for most bindings, and reduce_full over a Command cannot drive a model mutation. The key path remains the only door the model has, and ui/reduce.rs's module doc says so.\n\nDO THIS THE SAME WAY cqi did the view half -- derive, do not invent. Enumerate the effects wearing EffectKind::Model(...) (29 of them; the agent's recipe: awk '/^pub struct /{n=$3} /ModelChange::/{print n}' over ui/effect.rs), let that set BE the enum, then finish gxi ACs 4-5 on top. Inventing variants ahead of the mutations produces a protocol nobody can implement against -- doctrine(derive_the_enum_from_the_effects).\n\nOnce this lands, reduce_full becomes the single door and the two-door note in ui/reduce.rs can go.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:56:08Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:11:22Z","closed_at":"2026-09-19T22:11:22Z","close_reason":"ModelCommand = the 29 EffectKind::Model effects minus SwitchView = 28 variants, in improvise-protocol.\n\nEXTRACTION VERIFIED RATHER THAN TRUSTED, and the ticket own awk recipe was WRONG: it reports 30. The extra is HelpPageSet, which is ViewOnly -- it is the last pub struct before #[cfg(test)] and the test module mentions ModelChange::, so the running variable bleeds across the module boundary. Exactly the failure doctrine(derive_then_verify) records. The agent brace-matched every impl Effect block instead; the orchestrator independently confirmed 50 impls, 29 wearing Model(..).\n\nSwitchView rejected as a NEW variant, not as a mutation: Command::SwitchView already exists in the view half with its document half routed through apply_command/App::switch_to_view, and naming it twice would give two spellings for one mutation with only one wired to apply_switch. AcceptWizardModel and ApplyAndClearDrill were CONSIDERED for rejection on gotcha 30 grounds and kept: both commit session-local staged state, but the COMMAND carries none of it -- it is nullary and the protocol crate never names ImportWizard or DrillState. Rejecting them would have meant inventing a rule nothing in the codebase states.\n\nNo second mutation path: model_effects(\u0026ModelCommand) maps back to the effect and apply_command runs it through App::apply_effects, so effects stay the only mutation site and Dirty comes from the effect own EffectKind. Sessions::apply routes Command::Model to apply_model, which takes no SessionId and shares adopt_active_view -- ViewBack, ViewForward and a document-replacing LoadModel all end in App::switch_to_view, so every mirror takes the accepted name (doctrine(lockstep_binds_every_session_not_the_asker)). 7 tests, each proven by injection -- including one that did NOT fire at first because the shared fixture had a single category, so swapping axes and rotating Row-\u003eColumn landed on the same view; it got its own pivoted fixture and the doc comment records why.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-cu7s","title":"[bug] Formula target keeps its pipe delimiters, so a quoted target lands on the wrong _Measure item","description":"From the wave-1 gp-tree review (2026-09-19). CONFIRMED by probe. Likely PRE-EXISTING and user-facing, not introduced by the gp work.\n\nimprovise-formula/src/parser.rs:55 does target = Some(inner.as_str().trim().to_string()), taking the RAW span; formula.pest:20 documents this deliberately ('The target keeps its raw text (including pipes, if any)'). So parsing '|Total Revenue| = ...' yields Formula { target: \"|Total Revenue|\" } -- 15 characters including the pipes -- whereas an Expr::Ref for the same name is correctly unquoted.\n\nConsequence: Model::add_formula then targets a _Measure item whose literal name contains pipes, which never matches the user's item 'Total Revenue', and on save the writer quotes it a SECOND time. Probe confirmed targets 'Total Revenue', 'SUM', 'a|b' and '' all come back pipe-wrapped.\n\nINVESTIGATE FIRST -- the reviewer found this via improvise-gp, but the same parse path serves the formula minibuffer and the .improv '## Formulas' load path. Check whether a user typing a pipe-quoted target in the TUI, and a .improv file containing one, hit the same bug. If so this is a user-facing data bug, not a gp-only one, and its priority should rise.\n\nFIX LOCATION IS A DECISION: unquote in improvise-formula so target matches Expr::Ref's treatment (consistent, but changes a documented grammar behaviour and anything relying on the raw span), or unquote at each call site. Whichever, improvise-gp must stop asserting the broken shape: lib.rs:267-276 an_awkward_target_name_is_quoted_and_round_trips asserts parsed.target == \"|Total Revenue|\" and calls it correct.","notes":"\n--- FIXED, AWAITING MERGE (2026-09-19) ---\nThe fix is on branch wave-1-review-fixes and is in PR #9\n(https://github.com/fiddlerwoaroof/improvise/pull/9), which is open pending\nCopilot review. Moved to in_progress rather than left open so nobody picks\nthis up again -- see the agent-issue-drift-pattern memory. Close it when #9\nmerges, not before.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:43:11Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:36Z","started_at":"2026-09-19T21:15:04Z","closed_at":"2026-09-19T21:50:36Z","close_reason":"Merged to main in PR #9 as e1acac691d70992b5dfcce9f11ce2c2b50fa1c29 (2026-09-19). Verified a real merge commit with both parents, not a squash, so all 18 branch commits are reachable on main per AGENTS.md's preference for merges. Copilot's second review on that PR: approval recommended, findings none. All eight non-skipped CI checks green, including claude-review. Synced to origin and gitea.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-35jb","title":"every_bound_command_name_is_registered checks the name but never that the args parse","description":"From the wave-1 review (2026-09-19). The most useful finding of the review.\n\nkeymap.rs's every_bound_command_name_is_registered walks every binding and checks the command NAME is in registry.names(). It never checks that the bound ARGS parse. But Keymap::dispatch does registry.interactive(name, args, ctx).ok()? -- so a binding whose args are unparseable is ALSO a silently dead key, which is the exact failure mode the test was written to prevent.\n\nimprovise-s13 made this live: it moved two bindings out of the covered class (nullary) into the uncovered one (arg-carrying) -- enter-mode [\"export-prompt\"] and enter-mode [\"formula-edit\"]. A typo such as \"export_prompt\" would compile, pass the whole suite, and produce a dead Ctrl-e. The reviewer verified by hand that both resolve TODAY, so there is no live defect -- the check is what is missing.\n\nFIX: in that same walk, build a CmdContext and assert ks.registry().interactive(name, args, \u0026ctx).is_ok() rather than only checking membership in names(). Per doctrine(check_the_checker), prove it fires by injecting a typo'd arg before keeping it.","notes":"\n--- FIXED, AWAITING MERGE (2026-09-19) ---\nThe fix is on branch wave-1-review-fixes and is in PR #9\n(https://github.com/fiddlerwoaroof/improvise/pull/9), which is open pending\nCopilot review. Moved to in_progress rather than left open so nobody picks\nthis up again -- see the agent-issue-drift-pattern memory. Close it when #9\nmerges, not before.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:42:36Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:34Z","started_at":"2026-09-19T21:15:02Z","closed_at":"2026-09-19T21:50:34Z","close_reason":"Merged to main in PR #9 as e1acac691d70992b5dfcce9f11ce2c2b50fa1c29 (2026-09-19). Verified a real merge commit with both parents, not a squash, so all 18 branch commits are reachable on main per AGENTS.md's preference for merges. Copilot's second review on that PR: approval recommended, findings none. All eight non-skipped CI checks green, including claude-review. Synced to origin and gitea.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-hzoh","title":"simple_effect! reintroduces the EffectKind default that gotcha 3 exists to prevent","description":"From the wave-1 code review (2026-09-19), CONFIRMED by probe.\n\nsimple_effect! hard-codes EffectKind::ViewOnly and takes an unconstrained $place:expr. This probe compiles, is a textbook LayoutGeometry change (SetNumberFormat right next door declares exactly that), reports ViewOnly, and the full improvise-command suite passes 294/0 with it present:\n\n simple_effect!(AbusiveSetNumberFormat(String) =\u003e |app| app.model_state.workbook.active_view_mut().number_format);\n\ninvariant(effect_kind_no_default) exists because a mis-declared kind is SILENT -- ViewOnly never reaches other clients. kind() having no default forces a declaration; the macro reintroduces the default under one name, for a category defined by SYNTAX ('one field, one place') rather than SEMANTICS ('per-client'). The doc comment states the intended boundary honestly, but a comment is not a constraint. The only pin, view_only_effects_leave_shared_state_alone, is a hand-maintained Vec that nothing forces you to extend -- and SetYanked, one of the eight macro'd effects, is already missing from it.\n\nThe eight effects currently declared through the macro were each checked and ARE genuinely ViewOnly, so there is no live defect -- this is about the shape.\n\nFIX (cheapest that keeps the macro): give it a mandatory kind argument, e.g. simple_effect!(Name(T) =\u003e |app| place, ViewOnly), so the classification is written per call site even when the answer is always the same -- the same reasoning that gave kind() no default. Also add SetYanked to the pin list.","notes":"\n--- FIXED, AWAITING MERGE (2026-09-19) ---\nThe fix is on branch wave-1-review-fixes and is in PR #9\n(https://github.com/fiddlerwoaroof/improvise/pull/9), which is open pending\nCopilot review. Moved to in_progress rather than left open so nobody picks\nthis up again -- see the agent-issue-drift-pattern memory. Close it when #9\nmerges, not before.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:40:49Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:34Z","started_at":"2026-09-19T21:15:02Z","closed_at":"2026-09-19T21:50:34Z","close_reason":"Merged to main in PR #9 as e1acac691d70992b5dfcce9f11ce2c2b50fa1c29 (2026-09-19). Verified a real merge commit with both parents, not a squash, so all 18 branch commits are reachable on main per AGENTS.md's preference for merges. Copilot's second review on that PR: approval recommended, findings none. All eight non-skipped CI checks green, including claude-review. Synced to origin and gitea.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2v7t","title":"Protocol fold contract: headline tests are vacuous, ordering contract unenforced","description":"From the wave-1 code review (2026-09-19), CONFIRMED by fault injection.\n\n(A1) splitting_a_stream_anywhere_gives_the_same_cache and a_snapshot_seed_is_just_the_head_of_the_stream both assert fold(prefix)+fold(rest) == fold(whole). Since folded()'s only body IS default() + a loop of apply(), that identity is the DEFINITION of a left fold and holds for any apply() whatsoever. Proof: replacing GridCache::apply's cells.get_mut(row).get_mut(col) with get_mut(0).get_mut(0) -- every cell written to (0,0) -- left BOTH tests passing while the two real tests failed.\nThe property the architecture rests on is ORDER SENSITIVITY: a Cells update arriving before its framing RowLabels/ColLabels is silently dropped, so the fold is not commutative and nothing tests reordering. Also, the snapshot test uses the literal prefix stream[..2] as the seed; a real server sends a COMPACTED seed (current labels + cells), and that compaction-vs-replay equivalence -- what 'seeded then follows the stream' means in production -- is untested.\n\n(A2) A shape change without Invalidated tears the grid. fit_to_labels truncates/extends by POSITION, so removing a middle column leaves surviving cells under the wrong labels: ColLabels[A,B,C]+Cells, then ColLabels[A,C] =\u003e the column labelled C shows B's value. GridUpdate::Invalidated exists to prevent this and its doc states the ordering contract, but the contract is PROSE ONLY -- nothing in the types requires it, and shrinking_the_labels_drops_the_cells_that_no_longer_fit blesses a bare RowLabels shrink (the one case where preserve-by-position is right). There is no producer anywhere in the tree, so the first from_app author has only a doc comment to go on.\n\n(A3) col_widths is never re-fitted: ColLabels[A]+ColWidths[8] then ColLabels[A,B] leaves labels len 2, widths len 1. Cache stays total, but a renderer zipping widths with labels indexes out of range.\n\n(A5) populate_before_roundtrip regression: every_command_round_trips_through_json previously covered CellsUpdated with a populated CellDisplay, and now covers only Command::Project(Projection::Grid(GridUpdate::Invalidated)) -- the ONE variant in the new vocabulary carrying no payload. Also CellDisplay::numeric (the field deciding alignment) is never round-tripped at a non-default value, and Snapshot is never round-tripped at all and derives neither Clone nor PartialEq.\n\nFIX: make the tests prove order sensitivity and compaction-equivalence rather than the fold axiom; enforce or type-encode the Invalidated ordering contract; re-fit col_widths; restore populated round-trips.","notes":"\n--- FIXED, AWAITING MERGE (2026-09-19) ---\nThe fix is on branch wave-1-review-fixes and is in PR #9\n(https://github.com/fiddlerwoaroof/improvise/pull/9), which is open pending\nCopilot review. Moved to in_progress rather than left open so nobody picks\nthis up again -- see the agent-issue-drift-pattern memory. Close it when #9\nmerges, not before.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:40:48Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:32Z","started_at":"2026-09-19T21:15:00Z","closed_at":"2026-09-19T21:50:32Z","close_reason":"Merged to main in PR #9 as e1acac691d70992b5dfcce9f11ce2c2b50fa1c29 (2026-09-19). Verified a real merge commit with both parents, not a squash, so all 18 branch commits are reachable on main per AGENTS.md's preference for merges. Copilot's second review on that PR: approval recommended, findings none. All eight non-skipped CI checks green, including claude-review. Synced to origin and gitea.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-l3at","title":"AddCategory declares ModelChange::CellData but changes grid geometry","description":"Found while fixing improvise-djn (2026-09-19). The AddCategory effect declares EffectKind::Model(ModelChange::CellData), but adding a category changes grid GEOMETRY, not cell data.\n\nPer repo-map gotcha 3 this class of mistake is silent: EffectKind drives what reaches other clients, there is no default, and a wrong classification produces no compile error and no test failure today -- it only shows up as a connected client whose layout never updates. That makes it exactly the kind of defect the 'no default kind' rule exists to catch, and it slipped through anyway because the rule only forces you to WRITE a kind, not to write the right one.\n\nCheck the sibling effects at the same time (AddItem, RemoveItem, RemoveCategory, anything else touching category membership) -- if one is wrong the others likely are too.\n\nSuggested test: assert the declared kind for each membership-changing effect, so the classification is pinned rather than assumed.","notes":"\n--- CONFIRMED by the wave-1 review (2026-09-19), and it is TWO effects, not one ---\nAddCategory (effect.rs:96-98) AND RemoveCategory (effect.rs:314-316) both\ndeclare Model(ModelChange::CellData). Both go through Workbook, which mutates\nEVERY view's axis map:\n workbook.rs:59-61 add_category -\u003e for view in views { view.on_category_added(\u0026name) }\n workbook.rs:80-82 remove_category -\u003e view.on_category_removed(name)\nThat is view_config_fingerprint's territory and matches LayoutGeometry's own\ndoc ('Axis assignment ... the grid re-lays out'), not CellData's. Probe\nconfirmed each changes serde_json::to_string(\u0026workbook.views).\n\nSIBLINGS CHECKED AND CORRECT -- do not 'fix' these: AddItem, AddItemInGroup,\nRemoveItem go through model only and leave view config untouched; SetCell,\nClearCell, SortData, AddFormula, RemoveFormula likewise. SetAxis,\nTransposeAxes, CreateView, SetNumberFormat, TogglePruneEmpty already declare\nLayoutGeometry correctly. The defect is confined to the two CATEGORY effects.\n\nFailure scenario once a projector exists: a client told CellData emits\nGridUpdate::Cells but no Invalidated + fresh label pair, so a new category's\nrow never appears, or a removed category leaves a column whose label is gone\nfrom every view.\n\nLATENT TODAY: kind() has no production caller yet -- it is read only by\neffect.rs's classification tests. That is the argument for fixing now, while\nthere is no behaviour to regress.\n\n--- FIXED, AWAITING MERGE (2026-09-19) ---\nThe fix is on branch wave-1-review-fixes and is in PR #9\n(https://github.com/fiddlerwoaroof/improvise/pull/9), which is open pending\nCopilot review. Moved to in_progress rather than left open so nobody picks\nthis up again -- see the agent-issue-drift-pattern memory. Close it when #9\nmerges, not before.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:36:44Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:36Z","started_at":"2026-09-19T21:15:03Z","closed_at":"2026-09-19T21:50:36Z","close_reason":"Merged to main in PR #9 as e1acac691d70992b5dfcce9f11ce2c2b50fa1c29 (2026-09-19). Verified a real merge commit with both parents, not a squash, so all 18 branch commits are reachable on main per AGENTS.md's preference for merges. Copilot's second review on that PR: approval recommended, findings none. All eight non-skipped CI checks green, including claude-review. Synced to origin and gitea.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-6eyg","title":"beads git hooks strip memories from .beads/issues.jsonl","description":"The beads-installed git hooks (.beads/hooks/post-checkout etc.) run bd's plain auto-export, which omits 'bd remember' memories. Observed 2026-09-19: a 'git checkout prolog-sessions/kb.pl' silently rewrote and staged .beads/issues.jsonl with all 15 memory records removed (-180 lines). Interactive 'bd remember' correctly refuses via its shrink guard ('refusing to overwrite ... contains 14 record(s) outside auto-export scope'), so the guard exists but the hook path bypasses it.\n\nWorkaround in place: always regenerate with 'bd export --include-memories -o .beads/issues.jsonl' and check 'grep -c \"_type\\\":\\\"memory\\\"' before committing.\n\nFix options: (a) configure the hook/auto-export to include memories, (b) report upstream to gastownhall/beads, (c) add a pre-commit check in this repo that fails if issues.jsonl memory count drops. Recorded in kb.pl under beads-maintenance-2026-09-19.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:11:36Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:15:51Z","closed_at":"2026-09-19T21:15:51Z","close_reason":"RESOLVED by the tracker policy change earlier in this session, not by a code fix. The hooks strip memories from .beads/issues.jsonl only if memories are IN it. export.include-memories is now false (bd config set), which matches bd 1.3.0's auto-export scope, and the tracked file carries issues only -- so the plain export the git hooks run now produces exactly the shape the file is supposed to have, and there is nothing left to strip. Verified: several bd writes since the change have auto-exported and auto-staged the file with no shrink-guard warning.\n\nThe underlying bd oddity is unchanged and worth knowing if memories are ever tracked again: export.include-memories is honoured by an explicit 'bd export' but NOT by the auto-export path, whose guard calls memories 'outside auto-export scope' regardless of the config. That contradiction is recorded in the never-hand-edit-issues-jsonl and autoexport-owns-issues-jsonl memories. Filing it upstream to gastownhall/beads is still open if anyone wants memories in the tracked export.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-rkeq","title":"Decide RenderCache's shape: grid-only wire type vs per-widget sub-caches","description":"improvise-cqi landed RenderCache in improvise-protocol as a FLAT, grid-only type: row_labels, col_labels, col_widths, cells: Vec\u003cVec\u003cCellDisplay\u003e\u003e, documented as 'the client's copy of the visible grid', indexed by viewport position and rebuilt by folding projection commands. Six open tickets were written against a different object of the same name: a fat cache holding one sub-cache per widget, selected by a SubscriptionSet — improvise-35e (RenderCache::from_app, 'grid: Option\u003cGridCache\u003e, category_tree: Option\u003cCategoryTreeCache\u003e, formulas, views, tile_bar, wizard, help'), improvise-n10 ('takes \u0026GridCache'), improvise-pca ('its relevant sub-cache slice'), improvise-764 ('a WizardCache'), improvise-jb3 and improvise-edp. Neither is wrong; they are two objects with one name, and nothing in the tree will complain until someone writes from_app.","design":"Three shapes, none picked. (A) Grow the protocol type: RenderCache gains Option\u003cSubCache\u003e fields. Cost: the wire payload carries widget-shaped data a browser DOM renderer does not want, and Snapshot grows. (B) Keep the protocol RenderCache grid-only and give the native migration its own container — say WidgetCaches { grid: RenderCache, category_tree: ..., ... } — so the wire type is one field of it. The six migration tickets then say WidgetCaches where they say RenderCache. (C) Per-widget projection commands instead of a container: each widget subscribes and folds its own cache, no aggregate type at all. Whichever wins, improvise-cqq (projection emission) has to agree with it, because that is what produces the updates a cache folds.","acceptance_criteria":"(1) One of the three shapes is chosen and recorded here. (2) improvise-35e, n10, pca, 764, jb3 and edp are updated to name the chosen type. (3) If the protocol type changes shape, improvise-cqq's description is updated to emit for it.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T19:49:24Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:29:33Z","closed_at":"2026-09-19T20:29:33Z","close_reason":"DECIDED (user, 2026-09-19): shape (C) with per-widget caches. RenderCache is per-widget -- each widget owns its own cache type and folds its own projection-command stream; there is no flat grid-only wire type and no fat aggregate container. State synchronizes by events, not snapshots; the single exception is the initial state transfer, where a new client gets a Snapshot to seed from and then follows the event stream. All six migration tickets (35e, n10, pca, 764, jb3, edp) and the projection-emission ticket (cqq) were annotated with the decision.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2ou","title":"test","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T19:43:00Z","created_by":"Edward Langley","updated_at":"2026-09-19T19:43:06Z","closed_at":"2026-09-19T19:43:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-g0f","title":"Decide whether two clients may view different views","description":"improvise-cqi added ViewState::active_view alongside Workbook::active_view. Today the host keeps them identical (App::switch_to_view, App::install_workbook), so behaviour is unchanged for a single client.\n\nThe open question is whether they are ALLOWED to diverge. If yes, 'which view am I looking at' becomes per-client the same way the cursor did in improvise-8uz, and Workbook::active_view degrades to the document's saved starting view. If no, SwitchView must be a broadcast command every client applies.\n\nThis matters for improvise-cqq (projection emission): the server computes each client's viewport from that client's view, and if views can differ then the projections differ per client too — which is a materially bigger server.\n\nNot urgent: the single-client TUI is correct either way, and the guard test the_two_active_view_names_never_diverge pins the current answer (they may not).","acceptance_criteria":"A decision recorded on the ticket, and either the divergence is permitted with a test showing two ViewStates on different views, or SwitchView is documented as a broadcast command.","notes":"GROOMED 2026-09-19: raised P3 -\u003e P2. It blocks improvise-cqq (P2), and a lower-priority blocker makes bd ready unusable in priority order (the priority-non-decreasing rule from the 2026-08-30 groom).","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-01T09:02:42Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:00:50Z","closed_at":"2026-09-19T21:00:50Z","close_reason":"DECIDED (user, 2026-09-19): YES -- two clients may view different views. Workbook::active_view becomes the DOCUMENT DEFAULT (what a fresh client opens); ViewState::active_view becomes authoritative PER CLIENT. Decided at the point it acquired teeth: improvise-cqq's per-client ClientSession makes divergence expressible for the first time. ViewState::cursors is already keyed by view name, so the cursor half needs no change. Implementation work filed separately; gotcha 29's lockstep invariant and its guard test the_two_active_view_names_never_diverge must be replaced by a deliberate split rather than deleted.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-xf1","title":"Verify OpfsFs against a real browser","description":"improvise-i34 landed OpfsFs (crates/improvise-io/src/opfs.rs) compile-checked for wasm32 only — there is no browser in the dev environment.\n\nWhat to verify, in a dedicated Web Worker:\n- OpfsFs::open() gets the origin root\n- attach() acquires a sync access handle, creating the file\n- the storage_contract sequence in storage.rs passes: write/read/replace/list-sorted/delete/read-fails/delete-twice-errors/shorter-write-truncates\n- refresh() lists what attach created\n- commit_deletes() actually removes entries (reload the page and confirm)\n\nKnown API constraints already handled: handles are exclusive per file (attach closes what it replaces, detach releases), and sync access handles are worker-only. Safari 16.4+ has synchronous handle methods, so no IndexedDB fallback.\n\nMost likely failure points: the keys() async-iterator walk in refresh() is hand-rolled through js_sys::Reflect and is the least certain code in the file; and FileSystemReadWriteOptions 'at' handling on read/write.","acceptance_criteria":"The storage_contract sequence passes against OpfsFs in Chrome, Firefox and Safari, run from a dedicated Web Worker.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-01T06:10:42Z","created_by":"Edward Langley","updated_at":"2026-09-01T06:10:42Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-a2x","title":"Derive Serialize/Deserialize for the ViewState field types","description":"improvise-h0b evicted the two fields that could never be serialized (wizard: an improvise-io type; drill_state: holds an Rc). What remains is that ViewState's own field types still derive no Serialize, so it cannot actually be put on the wire.\n\nNeeded: AppMode, MinibufferConfig, ViewFrame (crates/improvise-command/src/ui/app.rs) and KeyCode, KeyModifiers (crates/improvise-command/src/command/key.rs). All five are plain data. CellValue already derives it.\n\nAlso requires adding serde with the derive feature to improvise-command's [dependencies] — today it depends only on serde_json.\n\nDeliberately NOT done in h0b: nothing serializes ViewState yet, so the derives would have no consumer. They land when improvise-cqi extracts the protocol crate and there is a Snapshot to build.","acceptance_criteria":"ViewState (or its successor in improvise-protocol) derives Serialize + Deserialize and a roundtrip test passes.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-30T22:56:09Z","created_by":"Edward Langley","updated_at":"2026-09-01T04:05:38Z","started_at":"2026-09-01T04:01:54Z","closed_at":"2026-09-01T04:05:38Z","close_reason":"ViewState + AppMode, MinibufferConfig, MinibufColor, ViewFrame, KeyCode, KeyModifiers all derive Serialize/Deserialize; serde added to improvise-command. Real blocker was unanticipated: MinibufferConfig.buffer_key was \u0026'static str, which Deserialize cannot produce, and rustc reported it on the five enclosing AppMode variants rather than the field. buffer_key and TextEntry::buffer are now Strings; rejected Cow (public-API plumbing) and a BufferKey enum (drags the buffers map key, script-parser args, and the search buffer). Guarded by view_state_survives_a_json_round_trip. Unblocks improvise-cqi.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-h0b","title":"Evict wizard and drill_state from ViewState (bootstrap-snapshot hygiene)","description":"Follow-up to improvise-jc8, which removed the hard cycle (Arc\u003cKeymap\u003e). Two fields remain in ViewState that should not be there:\n\n wizard: Option\u003cImportWizard\u003e -- improvise-io type; acyclic, but outside improvise-cqi AC 2's allowlist (core + formula + serde)\n drill_state: Option\u003cDrillState\u003e -- staged, uncommitted edits\n\nNeither is a dependency cycle, so this is not urgent the way jc8 was. The argument is bootstrap hygiene: per improvise-q08 the ONLY point ViewState is serialized is the Snapshot sent when a client connects. A fresh client should not inherit a half-open import wizard, and must not inherit another session's staged drill edits — those are uncommitted writes belonging to whoever staged them.\n\nShape (mirroring jc8): a host-side struct holds the session-local pieces alongside the portable ViewState.\n\n pub struct HostState {\n pub view: ViewState, // portable, serializable\n pub wizard: Option\u003cImportWizard\u003e,\n pub drill_state: Option\u003cDrillState\u003e,\n }\n\nAlso still needed before ViewState can be serialized at all: AppMode and DrillState derive no Serialize today (DrillState only matters if it stays).\n\nDo this before improvise-cqi extracts the protocol crate, or cqi inherits the problem.","notes":"PRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-cqi improvise-pro ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-30T03:48:29Z","created_by":"Edward Langley","updated_at":"2026-08-30T22:58:17Z","started_at":"2026-08-30T22:53:35Z","closed_at":"2026-08-30T22:58:17Z","close_reason":"wizard and drill_state moved from ViewState to App under a documented session-local residue tag. Deviated from the ticket's HostState sketch: App already is the host struct with a residue taxonomy, so this adds a fifth tag rather than a wrapper layer (jc8 set the same precedent). Demonstrated with a ViewState: Send assertion that failed to compile first. The ticket's note about Serialize derives is split out as improvise-a2x, now blocking cqi.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-jc8","title":"Evict host-only state from ViewState: flip the transient-keymap Arc","description":"Prerequisite for improvise-cqi. ViewState (crates/improvise-command/src/ui/app.rs:381) holds transient_keymap: Option\u003cArc\u003cKeymap\u003e\u003e. Keymap lives in improvise-command, which will depend on improvise-protocol once improvise-gxi puts reduce_full there taking \u0026Command — so a ViewState in the protocol crate carrying a Keymap is a hard dependency cycle, independent of serde.\n\nFLIP THE ARC. The transient keymap is never arbitrary: it is always the sub-keymap of a Binding::Prefix at a specific (mode, key, mods), set during dispatch and consumed on the very next keypress. There are exactly three, all flat, all on the normal keymap (g, y, Z at keymap.rs:634-642); RecordsNormal reaches them through parent fallback. So ViewState can hold the ADDRESS rather than the keymap:\n\n - transient_keymap: Option\u003cArc\u003cKeymap\u003e\u003e\n + transient_prefix: Option\u003c(KeyCode, KeyModifiers)\u003e\n\nand let KeymapSet — which already owns every keymap — resolve it. Both consumers can: App::handle_key has keymap_set and the mode; src/draw.rs:238 only needs binding_hints() for the which-key popup. Nothing between set and consume changes the mode, because a Prefix binding emits SetTransientKeymap and nothing else.\n\nGuard the assumption: resolution looks the prefix up in the CURRENT MODE's keymap, which is only correct while prefixes are flat. If a nested prefix is ever added (a Prefix binding inside a sub-keymap), this breaks. Encode that as a test rather than a comment.\n\nSCOPE: this ticket is the Arc flip only. wizard: Option\u003cImportWizard\u003e and drill_state: Option\u003cDrillState\u003e also do not belong in a ViewState that gets serialized — improvise-q08 sends one bootstrap Snapshot on connect, and a fresh client should not inherit a half-open import wizard or another session's staged drill edits — but neither is a dependency cycle, so they are follow-ups.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-30T03:44:37Z","created_by":"Edward Langley","updated_at":"2026-08-30T03:48:16Z","started_at":"2026-08-30T03:44:42Z","closed_at":"2026-08-30T03:48:16Z","close_reason":"ViewState no longer holds Arc\u003cKeymap\u003e. It stores transient_prefix: Option\u003c(KeyCode, KeyModifiers)\u003e — the key that opened the prefix — and KeymapSet::prefix_keymap resolves it back to the sub-keymap. The Arc lives in KeymapSet, which already owned every keymap.\n\nThe motivation is the dependency graph, not serde: Keymap is in improvise-command, which will depend on improvise-protocol once improvise-gxi puts reduce_full there taking \u0026Command. A ViewState in protocol carrying a Keymap is a hard cycle. That blocker is now gone.\n\nCharacterization tests written BEFORE the change, all passing on the old code first, so the refactor had something to preserve — prefix keys had almost no coverage (one Keymap-level unit test):\n- prefix_g_then_g_jumps_to_first_row\n- prefix_z_then_z_reaches_save_and_quit (ZZ with no file path, which also confirms improvise-8tk's abort still fires)\n- unmatched_key_after_prefix_is_swallowed (a prefix is consumed whether or not the next key matches, so a stray key does not act as a fresh normal binding)\n- prefix_keys_reach_through_the_records_parent_keymap (RecordsNormal defines no prefixes; it inherits them)\n\nTwo new guards for the assumption the flip introduces:\n- prefix_keymaps_are_flat — resolution looks the prefix up in the CURRENT MODE's keymap, which is only correct while prefixes are flat. A nested Prefix would be resolved where it does not exist and the second half of the sequence would silently do nothing. Verified it bites by adding a nested prefix under y: 'Normal: prefix Key(Char('y')) contains nested prefixes [Key(Char('n'))]'.\n- prefix_resolves_through_parent_keymaps — pins that resolution walks parents, and that a plain command binding is not a prefix.\n\nSTILL IN ViewState, both out of scope here and neither a cycle: wizard: Option\u003cImportWizard\u003e (improvise-io is acyclic but outside cqi's AC 2 allowlist) and drill_state: Option\u003cDrillState\u003e. Both should still leave before cqi, for a different reason recorded on that ticket — improvise-q08 sends one bootstrap Snapshot on connect, and a fresh client should inherit neither a half-open import wizard nor another session's staged drill edits.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-8uz","title":"Cursor and scroll are per-client state stored in the shared View","description":"Found while classifying effects for improvise-mae.\n\nSetSelected, SetRowOffset and SetColOffset write View::selected / row_offset / col_offset, which live inside model_state.workbook and are PERSISTED in .improv. But they are a per-client concern: in the multi-client server architecture, one client scrolling or moving its cursor must not move another client's viewport.\n\nI classified all three EffectKind::ViewOnly for improvise-mae, on the grounds that projection must not broadcast them. That is the right call for projection, but it leaves a genuine inconsistency: they are the only ViewOnly effects that mutate state stored in the shared workbook, and view_only_effects_leave_shared_state_alone has to special-case them for exactly that reason (crates/improvise-command/src/ui/effect.rs).\n\nOptions:\n(a) Move selected/row_offset/col_offset out of View into ViewState, and stop persisting them. Cleanest, but changes the .improv format — a saved file would no longer restore the cursor where you left it, which may be a feature worth keeping.\n(b) Keep them in View as the saved STARTING position, and track the live per-client cursor in ViewState. The server then never projects cursor moves, and only the owning session writes back on save.\n(c) Leave as is and rely on the projection layer to filter.\n\n(b) looks right but is the most work. Decide before improvise-cqq (server-side projection emission) builds on the classification.","notes":"PRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): recorded improvise-cqq as depending on this, per this ticket's own 'decide before improvise-cqq builds on the classification'. That made it a P3 gating a P2.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-29T20:02:08Z","created_by":"Edward Langley","updated_at":"2026-09-01T05:24:59Z","started_at":"2026-09-01T05:17:59Z","closed_at":"2026-09-01T05:24:59Z","close_reason":"Cursor moved from View to ViewState::cursors: HashMap\u003cview_name, ViewCursor\u003e, via App::cursor/cursor_mut/reset_cursor. None of the ticket's three options was right, because its premise was false: the cursor was never persisted in .improv (no grammar production, format_md emits none), verified by a characterization test before choosing. (a) drops the per-view cursor restore, (b) rests on the non-existent persistence, (c) isn't per-client. Keying by view name satisfies per-client + behaviour-preserving + no-dead-rationale together. Cursor resets moved from View::transpose_axes/cycle_axis into the TransposeAxes/CycleAxis effects; Workbook::normalize_view_state deleted as vacuous, replaced by cursors.clear() in LoadModel/AcceptWizardModel. The ticket's symptom is gone: view_only_effects_leave_shared_state_alone no longer exempts the three setters.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-907","title":"Import wizard step machine: validate early, allow going back, make the terminal step real","description":"Supersedes improvise-gfq, improvise-o1c and improvise-c6j. All three are defects in one mechanism — the WizardStep transition table in crates/improvise-io/src/import/wizard.rs plus the per-step key tables in crates/improvise-command/src/command/cmd/wizard.rs — and they interlock badly enough that fixing any one alone makes the wizard worse.\n\n(1) from improvise-gfq — build_model raises 'At least one category must be accepted' only on Enter at NameModel, the final step. An import with no category-classified field walks the user through ReviewProposals, DefineFormulas and NameModel before refusing. improvise-mzv already added an early pre-check for the opposite failure (too many categories) at the step where the user can still act; this wants the same treatment.\n\n(2) from improvise-o1c — every browsing step binds Esc to close-the-wizard and no step binds a back key. Reaching ConfigureDates or NameModel and wanting to change a field kind means Esc, losing every proposal toggle, formula and name, then restarting the import.\n\n(3) from improvise-c6j — advance() maps NameModel -\u003e Done, but the key layer never calls advance() at NameModel: Enter there runs BuildWizardModel, which closes the wizard outright. Done is reachable only from unit tests that force it. Either drop the variant and its close-on-any-key branch, or route a successful build through it as a real confirmation step.\n\nWHY TOGETHER, NOT SEPARATELY: (1) adds a gate that refuses to advance, and (2) is the observation that there is no way back once gated — shipping (1) alone strictly worsens the dead end that (2) describes. (3) decides whether the machine even has a terminal state, which determines what 'advance' means at the last step and therefore where (1)'s gate belongs. One coherent redesign of advance/previous/validate, one set of characterization tests.\n\nScope note: improvise-ych (JSON null becoming the literal item 'null') is deliberately NOT folded in — that is a data bug in build_model_with_warnings, not the step machine, and shares no code with these.\n\nSuggested shape: WizardStep::previous(), a validate(step) gate consulted by advance(), a back key (Backspace or Left) bound on every browsing step, and a decision recorded on Done. Characterization tests exist from improvise-2wm — extend them rather than starting fresh.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-14T01:08:54Z","created_by":"Edward Langley","updated_at":"2026-08-29T17:48:49Z","started_at":"2026-08-29T17:37:14Z","closed_at":"2026-08-29T17:48:49Z","close_reason":"Three parts, one redesign.\n\n(1) validation_error() gate consulted by advance(), checked on the step where the user can act. Adds the no-category message at ReviewProposals alongside improvise-mzv's category-limit check.\n\n(2) previous() via a history stack: advance() pushes the step it leaves, previous() pops. Retraces the path actually taken rather than re-deriving the reverse of each conditional transition, so it can't drift from the forward table. Bound to Left on every step (Backspace is taken by the name and formula buffers; h is printable and NameModel needs it).\n\n(3) Done made a real confirm step, per user decision over dropping the variant. ImportWizard holds built: Option\u003c(Workbook, Vec\u003cString\u003e)\u003e; NameModel Enter -\u003e build() -\u003e Done showing the model summary and warnings in the panel; Enter accepts, Left/Esc go back and discard the build. BuildWizardModel renamed AcceptWizardModel since it now installs an already-built workbook. move_cursor's _ =\u003e 0 wildcard made exhaustive while there.\n\nTests: 8 new in improvise-io (previous reverses each advance, retraces a visited optional step, no-op at first step, clears message/cursor; build parks on Done; take_built hands over once; build failure stays on NameModel; previous from Done discards built), 5 in improvise-command (no-category gate end-to-end with recovery via c, Left walks back, Done Esc returns and drops the build, Done ignores unbound keys, two-step confirm installs the model), plus key-table tests that every step binds Left and that Done confirms on Enter. Three characterization tests from improvise-2wm updated to the new behavior.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-5j9","title":"ImportPipeline silently skips records missing category values","description":"crates/improvise-io/src/import/wizard.rs:189-197: build_model() skips any record lacking a value for an accepted Category field, with no count or warning surfaced to the user. A 1000-row import with 5 incomplete rows silently loses 5 rows (behavior is documented only in a test comment ~line 1043). Fix: count skipped records and surface in the wizard summary/status line. Related to improvise-k8i (CSV column drops) but at the pipeline level. Found in systematic review 2026-06-09.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:22Z","created_by":"Edward Langley","updated_at":"2026-07-31T06:04:20Z","closed_at":"2026-07-31T06:04:20Z","close_reason":"build_model_with_warnings tallies skipped records and surfaces them in the wizard status line, the import effect, and the headless CLI.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ehz","title":"EnterEditAtCursor effect calls rebuild_layout and builds a CmdContext inside apply","description":"src/ui/effect.rs:137-149: the effect calls app.rebuild_layout() and then constructs a fresh CmdContext just to read display_value. Effects should be pure mutations with inputs decided by the command; rebuilding layout mid-batch couples effect semantics to apply order (rebuild_layout is also called by apply_effects afterwards). Fix: command computes/passes the display value, or a preceding RebuildLayout effect is emitted explicitly. Found in systematic review 2026-06-09.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:21Z","created_by":"Edward Langley","updated_at":"2026-07-31T06:44:04Z","closed_at":"2026-07-31T06:44:04Z","close_reason":"RebuildLayout effect emitted explicitly by EnterEditAtCursorCmd and CommitAndAdvance; display value read via App::display_value_at_cursor() instead of constructing a CmdContext inside apply.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2wm","title":"WizardKey effect embeds the import-wizard state machine inside Effect::apply","description":"src/ui/effect.rs:587-712: the WizardKey effect (~126 lines) pattern-matches on wizard.step, mutates wizard state, and conditionally changes view_state.mode — all inside apply(\u0026mut App). Per design-principles, decision logic belongs in the command layer; effects should be discrete mutations. Refactor: a command inspects the wizard step from CmdContext and emits fine-grained effects. Found in systematic review 2026-06-09.","notes":"Groomed 2026-06-09: coordinate with improvise-764 (migrate import wizard widget/state to RenderCache under the ltq epic) — both restructure the same wizard code; doing 2wm first gives 764 a cleaner command-shaped wizard to migrate.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:19Z","created_by":"Edward Langley","updated_at":"2026-07-31T07:22:13Z","closed_at":"2026-07-31T07:22:13Z","close_reason":"Step/key decisions moved to command/cmd/wizard.rs (per-step key tables + shared cursor fallback); effects reduced to WizardApply(WizardOp), CloseWizard, BuildWizardModel. CmdContext gained a read-only wizard ref. 10 handle_key characterization tests pin behavior unchanged.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-97o","title":"vb4 step 6: write field-by-field audit report into vb4 notes","description":"Closes vb4 acceptance criterion (5). After steps 1–4 land, write a complete audit of every original App field into improvise-vb4's notes via bd update improvise-vb4 --notes. Each entry: field name, slice (ModelState / ViewState / App-derived / App-runtime / App-config), one-line rationale. Then close improvise-vb4.","acceptance_criteria":"(1) vb4 notes contain a field-by-field classification table. (2) improvise-vb4 is closed.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-26T18:17:09Z","created_by":"jeeves","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-05-03T04:16:32Z","close_reason":"Audit report written into vb4 notes per criterion (5). All 29 original App fields classified into ModelState (3) / ViewState (20) / App-derived (1) / App-runtime (3) / App-transient (1) / App-config (1). vb4 acceptance criteria all met: (1) typed model_state + view_state subfields ✓ (2) no overlap ✓ (3) tests pass ✓ (4) Effect::apply still \u0026mut App per criterion (4)'s allowance for cross-cutting; impl-level narrowing deferred to improvise-drg (step 5) which is P3/optional. Refactor commits: f11d79f, 917b928, 9ad8abd, 7249fac.","dependencies":[{"issue_id":"improvise-97o","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:17Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-99k","title":"vb4 step 4: document App-resident runtime/derived fields","description":"After steps 1–3, App owns model_state + view_state plus a residue of fields that don't belong to either slice: term_width, term_height, layout (derived cache from model+view), last_autosave (runtime instant), keymap_set (config), abort_effects (transient apply-loop flag). Add a doc-comment block on App that classifies each remaining field — 'derived cache', 'runtime metadata', 'config', 'transient' — with a one-line rationale. Satisfies vb4 acceptance criterion (2).","acceptance_criteria":"(1) App struct has a doc comment classifying every remaining direct field. (2) No behavior change. (3) Tests pass.","status":"closed","priority":2,"issue_type":"task","assignee":"jeeves","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:50Z","created_by":"jeeves","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-30T00:35:59Z","close_reason":"Step 4 complete in commit 7249fac. App struct has a top-level doc explaining the slice structure; each of the 5 residue fields (last_autosave, term_width, term_height, layout, abort_effects, keymap_set) is tagged derived cache / runtime metadata / transient / config with a one-line rationale. No behavior change; tests still pass.","dependencies":[{"issue_id":"improvise-99k","depends_on_id":"improvise-ew0","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ew0","title":"vb4 step 3: move UI session fields into ViewState","description":"Third slice of improvise-vb4. Move into App.view_state: ViewState the following UI session fields: mode, status_msg, wizard, search_query, search_mode, formula_panel_open, category_panel_open, view_panel_open, cat_panel_cursor, view_panel_cursor, formula_cursor, yanked, tile_cat_idx, view_back_stack, view_forward_stack, drill_state, help_page, expanded_cats, buffers, transient_keymap. Update every read/write site. Effect::apply signature stays \u0026mut App for now (narrowing happens in step 5).","acceptance_criteria":"(1) App has a view_state: ViewState field that owns all listed fields. (2) None of the listed fields are direct fields of App. (3) All tests pass. (4) cargo clippy --workspace --tests clean.","status":"closed","priority":2,"issue_type":"task","assignee":"jeeves","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:49Z","created_by":"jeeves","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-30T00:23:54Z","close_reason":"Step 3 complete in commit 9ad8abd. ViewState owns 20 UI session fields; App accesses them via app.view_state.X. 227 tests in main crate pass (+1 structural test). Clippy clean.","dependencies":[{"issue_id":"improvise-ew0","depends_on_id":"improvise-x2c","type":"blocks","created_at":"2026-04-26T11:17:15Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-x2c","title":"vb4 step 2: move workbook/file_path/dirty into ModelState","description":"Second slice of improvise-vb4. Move App.workbook, App.file_path, App.dirty into App.model_state: ModelState. Update every read/write site (commands, effects, draw, persistence, tests). ModelState wraps Workbook + IO bookkeeping; Workbook stays in improvise-core and remains IO-agnostic. Effect::apply signature stays \u0026mut App for now.","design":"ModelState { workbook: Workbook, file_path: Option\u003cPathBuf\u003e, dirty: bool } is intentionally not the same as Workbook — Workbook is pure document semantics in improvise-core, ModelState is the UI-layer wrapper that adds IO bookkeeping (file_path, dirty). Keeps the crate boundary clean.","acceptance_criteria":"(1) App has a model_state: ModelState field. (2) workbook, file_path, dirty are NOT direct fields of App. (3) All tests pass. (4) cargo clippy --workspace --tests clean.","status":"closed","priority":2,"issue_type":"task","assignee":"jeeves","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:47Z","created_by":"jeeves","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-29T05:12:12Z","close_reason":"Step 2 complete in commit 917b928. ModelState owns workbook/file_path/dirty; App accesses them via app.model_state.X. 226 tests in main crate pass (+1 structural test). Clippy clean.","dependencies":[{"issue_id":"improvise-x2c","depends_on_id":"improvise-3vr","type":"blocks","created_at":"2026-04-26T11:17:14Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3vr","title":"vb4 step 1: scaffold ModelState and ViewState types","description":"First slice of improvise-vb4. Define pub struct ModelState and pub struct ViewState in src/ui/app.rs as empty (or stub-field) types, exported. No App field is moved yet — this isolates the type-introduction diff so subsequent steps are pure field migrations. Establishes the named boundary the rest of the refactor fills in.","acceptance_criteria":"(1) ModelState and ViewState defined and re-exported from ui::app. (2) cargo build --workspace succeeds. (3) All existing tests pass unchanged.","status":"closed","priority":2,"issue_type":"task","assignee":"jeeves","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:14Z","created_by":"jeeves","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-26T18:31:58Z","close_reason":"Step 1 complete in commit f11d79f. ModelState and ViewState defined as empty pub structs in src/ui/app.rs with doc-comments pointing at follow-up issues. Structural test model_state_and_view_state_are_constructible passes (225 tests in main crate, +1).","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-rml","title":"Consolidate Axis display into a method on Axis","description":"Axis display logic is duplicated:\\n- src/ui/tile_bar.rs:30-36 — axis_display() match (4 arms)\\n- src/ui/category_panel.rs:14-19 — exact copy of the same match\\n- src/ui/view_panel.rs:40-44 — inline match\\n- crates/improvise-command/src/command/cmd/tile.rs:112, 131, 143 — axis cycling and conversion matches\\n\\nAdd methods on Axis (crates/improvise-core/crates/improvise-core/src/view/axis.rs):\\n- display_short() -\u003e (\u0026'static str, Color)\\n- display_long() -\u003e \u0026'static str\\n- next() -\u003e Self (cycle Row -\u003e Col -\u003e Page -\u003e None -\u003e Row)\\n- is_data_axis() -\u003e bool\\n\\nCorrectness win: eliminates the tile_bar/category_panel copy-paste — currently a change to axis colors or short labels must be made in both places.\\n\\n~25 LOC.","notes":"PRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-dz8 ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:21Z","created_by":"Ed L","updated_at":"2026-09-01T05:30:17Z","started_at":"2026-09-01T05:26:41Z","closed_at":"2026-09-01T05:30:17Z","close_reason":"Axis gained next/color/plain_label/glyph/short_label/initial; Display is plain_label+glyph. Four duplicate sites replaced (tile_bar, category_panel, view_panel, cmd/tile.rs) and View::cycle_axis now calls Axis::next. Two corrections to the ticket: (1) display_short() -\u003e (\u0026'static str, Color) is a layering violation — Color is ratatui, Axis is improvise-core — so it split into color() -\u003e AxisColor (semantic palette) with draw.rs::axis_fg resolving it, mirroring minibuf_fg/MinibufColor; (2) category_panel was NOT an exact copy of tile_bar — same colors, different labels — so only the color table was real duplication, and the four label vocabularies are preserved as four methods with a characterization test pinning them verbatim. is_data_axis() omitted: no caller. Unblocks improvise-dz8.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-tvt","title":"Route mutations through Workbook facade instead of app.workbook.model chains","description":"40+ sites follow the pattern 'if let Some(cat) = app.workbook.model.category_mut(\u0026name) { cat.add_item(\u0026item); }' (src/ui/effect.rs:38, 54, 99 and many test setups in src/command/cmd/mod.rs, src/ui/grid.rs).\\n\\nSimilarly .views.get().unwrap().axis_of() appears in 11 sites (workbook.rs:167-177, model/types.rs:1868-1890, persistence:943-946,1165) even though Workbook::active_view() / active_view_mut() already exist and are used correctly in some places.\\n\\nFix: add facade methods on Workbook: add_item(cat, item), remove_item(cat, item), view_axis(view_name, cat), sort_cells(), cell_count(). Migrate the existing chains to use them.\\n\\nCorrectness win: (a) Workbook becomes a real facade with the option to enforce invariants (e.g., notify views when items change); (b) Tests and effects stop reaching through three layers; (c) future refactors to Model/DataStore internals don't cascade into 40+ call sites.\\n\\n~60 LOC in production + simplification of many tests.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:18Z","created_by":"Ed L","updated_at":"2026-08-01T17:58:24Z","started_at":"2026-08-01T17:37:40Z","closed_at":"2026-08-01T17:58:24Z","close_reason":"Workbook gains add_item / add_item_in_group / remove_item; 165 model.category_mut chains migrated across production (effect.rs, import/wizard.rs) and tests (layout, persistence, app, cmd, grid). Net -125 lines, 706 tests green.\n\nDeliberately NOT done here:\n- .views.get(..).unwrap().axis_of(..) migration → covered by improvise-ont.\n- sort_cells() / cell_count() facade methods → zero call sites in the tree; speculative API, skipped per simplicity-first.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2hi","title":"Extract AppMode dispatch methods (mode_label, mode_style, mode_key)","description":"AppMode is matched in ~52 sites across 8 files to derive UI labels, styles, and mode keys. Each match arm is a small piece of per-variant logic that should live on the variant itself.\\n\\nHot spots:\\n- src/draw.rs:119-149 — mode_name() and mode_style() match blocks (16+ arms)\\n- src/command/keymap.rs:69-89 — ModeKey::from_app_mode match (16 arms)\\n- src/ui/{formula,category,view}_panel.rs — matches!(mode, AppMode::X) checks\\n- src/ui/tile_bar.rs, src/ui/effect.rs:898-900, src/command/cmd/registry.rs:376\\n- src/ui/app.rs: 13 scattered matches!() checks\\n\\nFix: add methods on AppMode — mode_label() -\u003e \u0026'static str, mode_style() -\u003e Style, mode_key() -\u003e Option\u003cModeKey\u003e, panel_mode() -\u003e Option\u003cPanel\u003e, is_text_entry_mode() -\u003e bool (partially present via minibuffer()). Also extend the existing minibuffer() with buffer_key() convenience.\\n\\nCorrectness win: adding a new AppMode variant requires updating the enum's methods (one file), not chasing 8 files with match blocks. Exhaustive match inside the method body still forces the compiler to flag every new variant.\\n\\n~80 LOC collapsed and 7 files depend on fewer concerns of AppMode.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:09Z","created_by":"Ed L","updated_at":"2026-08-01T19:31:52Z","started_at":"2026-08-01T18:17:47Z","closed_at":"2026-08-01T19:31:52Z","close_reason":"Added AppMode::label(), hint_text(), panel() — all exhaustive matches. Moved Panel next to AppMode (re-exported from effect.rs, zero call-site churn) since Panel::mode and AppMode::panel are inverses. Collapsed the 'is this mode focused in panel X' duplication from 4 sites (registry.rs + 3 PanelContent impls) into one method; PanelContent::is_active(mode) became PanelContent::panel().\n\nTwo wildcards that hid gaps are now exhaustive: ModeKey::from_app_mode (_ =\u003e None would have silently swallowed all keys for a new variant) and hint_text (_ =\u003e \"\" hid ExportPrompt having no hint → filed improvise-1c9). 4 new tests, 710 green.\n\nNOT done: mode_style() stays in src/draw.rs. Style/Color are ratatui types and improvise-command carries no rendering-framework dependency; its wildcard arm is the intended default chrome, not a hidden gap. buffer_key() convenience skipped — only 2 call sites, both already holding the MinibufferConfig.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2lh","title":"Migrate name-string comparisons to CategoryKind enum checks","description":"8 sites compare category names as strings against virtual-category prefixes ('_Measure', '_Index', '_Dim') instead of checking the typed CategoryKind enum. Adding a new CategoryKind variant won't produce a compile error at any of these sites.\\n\\nSites:\\n- crates/improvise-core/src/model/types.rs:215, 226, 429, 636 (target_category == '_Measure', cat_name == '_Measure')\\n- crates/improvise-io/src/persistence/mod.rs:213, 226 (formula target comparison)\\n- crates/improvise-core/src/view/layout.rs:108 (c == '_Index' \u0026\u0026 c == '_Dim')\\n- src/command/cmd/grid.rs:306 (target_category == '_Measure')\\n\\nFix: add methods on CategoryKind: is_virtual(), is_measure(), is_persisted(), is_index_or_dim(). Look up the Category by name once, dispatch on its .kind.\\n\\nCorrectness win: the compiler flags every site when a new variant is added. Also removes the hardcoded-name dependency — if the underscore prefix convention ever changes, one place to edit.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T19:01:53Z","created_by":"Ed L","updated_at":"2026-08-01T17:00:11Z","started_at":"2026-08-01T00:47:52Z","closed_at":"2026-08-01T17:00:11Z","close_reason":"CategoryKind::category_name() is now the single exhaustive name\u003c-\u003ekind mapping; of_name() plus is_measure/is_index/is_dim/is_index_or_dim/is_virtual replace all 10 string comparisons across core, io and command.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-6sq","title":"Merge EditOrDrill and EnterEditAtCursorCmd into EnterEdit{mode}","description":"command/cmd/mode.rs:254-307 has two commands (EditOrDrill, EnterEditAtCursorCmd) that each thread an edit_mode parameter. The drill-vs-edit decision is mode-independent; merging eliminates the fork.\\n\\nAbstraction: EnterEdit { mode: AppMode } — single command that checks aggregation state, pre-fills the buffer if editing, then enters the supplied mode.\\n\\nMatches the parameterization precedent from commit 30383f2 ('parameterize mode-related commands and effects'). ~50 LOC collapsed.","notes":"ANALYSIS: the proposed merge is not safe as specified. Do not merge these two commands without addressing the three points below.\n\nThe ticket says the two commands 'each thread an edit_mode parameter' and that the fork is mode-related. They actually differ in WHEN the cell value is read:\n\n- EditOrDrill (bound to i/a) runs standalone with a fresh CmdContext, so it pre-fills from ctx.display_value.\n- EnterEditAtCursorCmd runs ONLY as the tail of the records 'o' Binding::Sequence, after add-record-row has invalidated the layout. It emits RebuildLayout + the EnterEditAtCursor effect, which re-reads App::display_value_at_cursor() AFTER the rebuild. That is improvise-ehz / repo-map gotcha 13, not incidental duplication.\n\nThree concrete blockers for a merged EnterEdit { mode }:\n\n1. Empty records grid. ToggleRecordsMode puts every regular category on Axis::None, so EditOrDrill's regular_none is always true in records mode and the edit branch depends entirely on is_synthetic. With no records yet, ctx.cell_key() is None -\u003e is_synthetic false -\u003e is_aggregated true -\u003e 'cannot drill - no cell at cursor' instead of entering edit. Today 'o' works there because it does not run the drill check.\n\n2. The drill check would run against the stale pre-rebuild ctx on the 'o' path, since the check is a Cmd-time decision and RebuildLayout is an apply-time effect.\n\n3. The two paths want opposite no-cell behavior: i on no cell should report 'cannot drill', o after adding a row should edit.\n\nA merged command needs a rebuild_first / skip_drill flag, which is the same fork under a new name.\n\nDELIVERED instead: the investigation turned up a real bug. EnterEditAtCursor set view_state.mode without overriding changes_mode(), so ':enter-edit-at-cursor \u003cmode\u003e' was immediately snapped back to Normal by ExecuteCommand. Fixed with a regression test in b685857. Filed improvise-toc for ViewBack/ViewForward/StartImportWizard, which have the same symptom but change the mode conditionally.\n\nRECOMMEND: close as wont-do, or rescope to 'give EditOrDrill and EnterEditAtCursorCmd a shared helper for the edit branch' (blocked on improvise-toc, since delegating would also have hit the changes_mode bug).\n\n--- Groomed 2026-08-13: labelled 'human' — this needs a decision, not more analysis. ---\nInvestigated in full on 2026-08-01 (see the three blockers above) and the conclusion has not changed: the merge as specified would regress the empty-records-grid case. The ticket has now been skipped twice at the top of 'bd ready' because it cannot be actioned as written.\n\nThe decision is binary:\n (a) close as wont-do — the fork EditOrDrill/EnterEditAtCursorCmd is load-bearing, not incidental; or\n (b) rescope to 'give EditOrDrill and EnterEditAtCursorCmd a shared helper for the edit branch', which is a real but much smaller cleanup, and add a dependency on improvise-toc (delegating today would re-hit the changes_mode bug fixed in improvise-6sq's sibling work).\n\nUntil one is chosen this sits at the head of the ready queue blocking nothing and being picked up by nobody.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:39Z","created_by":"Ed L","updated_at":"2026-08-29T17:53:06Z","started_at":"2026-08-01T20:08:01Z","closed_at":"2026-08-29T17:53:06Z","close_reason":"WON'T DO — verified the NOTES analysis against the code; all three blockers hold.\n\nThe two commands do not 'each thread an edit_mode parameter' over a shared decision. They differ in WHEN the cell value is read. EditOrDrill (i/a, keymap.rs:507/575) runs standalone and pre-fills from ctx.display_value at Cmd time. EnterEditAtCursorCmd runs only as the tail of the records 'o' Binding::Sequence (keymap.rs:589) and emits RebuildLayout + EnterEditAtCursor, which re-reads App::display_value_at_cursor() AFTER the rebuild — repo-map gotcha 16, not incidental duplication.\n\nConfirmed in code:\n1. ToggleRecordsMode (command/cmd/grid.rs:432) puts every regular category on Axis::None, so EditOrDrill's regular_none is always true in records mode and the edit branch rests entirely on is_synthetic.\n2. RebuildLayout is apply-time while the drill check is Cmd-time, so a merged command would run the check against the stale pre-rebuild ctx on the 'o' path.\n3. The paths want opposite no-cell behavior.\n\nMerging needs a rebuild_first/skip_drill flag, which is the same fork under a new name, and would ADD lines rather than collapse ~50.\n\nDeliverable instead of the merge: ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid turns the analysis into executable evidence — on an empty records grid i yields RecordsNormal + 'cannot drill' while o creates a row and opens an edit (already guarded by add_record_row_in_empty_records_view_creates_first_row). No single command produces both, so a future attempt at this merge fails a test rather than shipping.\n\nNoticed while proving it: 'cannot drill' leaks a pivot-mode concept into records mode. Filed as improvise-ct5 (P4).","labels":["human"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-6t3","title":"Unify Commit{Formula,CategoryAdd,ItemAdd} into CommitBuffer(kind)","description":"command/cmd/commit.rs:349-439 has three commands with identical shape: read buffer named X, if empty abort, else emit AddY effect, mark dirty, optionally exit mode. Only the buffer name and the target effect type differ.\\n\\nAbstraction: CommitBuffer { kind: CommitKind } where CommitKind is Formula | Category | Item.\\n\\nCorrectness win: minibuffer commit semantics live in one place. New minibuffer modes (rename-view, rename-item) get correct semantics for free. Pairs with improvise-{text-entry keymap template} — once the commit is generic, the keymap template composes Sequence [commit-buffer X, clear-buffer X, enter-mode Normal] uniformly across all text-entry modes.\\n\\n~40 LOC collapsed.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:33Z","created_by":"Ed L","updated_at":"2026-08-01T23:23:37Z","started_at":"2026-08-01T20:45:46Z","closed_at":"2026-08-01T23:23:37Z","close_reason":"Replaced CommitFormula/CommitCategoryAdd/CommitItemAdd with a single CommitBuffer { kind: CommitKind }, registered as 'commit-buffer \u003cformula|category|item\u003e'; the 5 keymap bindings now pass the kind as an arg, so improvise-w3q's text-entry template can compose Sequence [commit-buffer X, clear-buffer X, ...] uniformly.\n\nCORRECTION TO THE TICKET: this does NOT collapse ~40 LOC — it costs ~+24. The three commands were not 'identical shape'; they differed in five dimensions (buffer name, empty-buffer landing mode, post-commit landing mode, status text, add effect), and two of those were genuinely semantic, not incidental:\n - formula returns to FormulaPanel on success; category/item stay in the minibuffer for the next entry (batch entry). Collapsing that would have been a UX regression either way, so CommitKind::on_commit() -\u003e Option\u003cAppMode\u003e keeps it.\n - CommitFormula did not trim or check for empty at all — it emitted AddFormula(\"\") and let the parser error. Unified, an empty formula buffer now just returns to FormulaPanel.\nThe win is real but it is 'one home for minibuffer commit semantics', not line count.\n\nBUG FOUND AND FIXED: the commit pushed set_status(\"Formula added\") AFTER AddFormula, clobbering the parse error AddFormula::apply writes to the status bar — an unparseable formula was dropped while the UI reported success. Regression test invalid_formula_reports_the_parse_error_not_success confirmed FAILING before the fix ('Formula added'). Fix: the add effect is emitted last, so the only party that knows whether it worked gets the last word.\n\nDESIGN NOTE: CommitKind::Item carries its target category rather than reading ctx.mode inside execute. parse_commit_kind(arg, mode) resolves it at registry time, which (a) keeps execute a pure function of kind+buffer, (b) removes the Option\u003cBox\u003cdyn Effect\u003e\u003e the old shared helper threaded, and (c) turns the old silent 'return vec![] if not in ItemAdd mode' no-op into a real error message on the ':' line.\n\nAlso added keymap test minibuffer_enter_dispatches_to_a_registered_commit: Keymap::dispatch swallows an unresolvable command name with .ok()?, so the existing lookup-only smoke tests would have passed even if the rename had missed the registry. Verified it fails when the bound name is corrupted.\n\nFiled improvise-djn: AddCategory still swallows its Result, so the same status-masking class of bug survives there.\n\n713 tests green, clean workspace build.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-k8h","title":"Tie MinibufferConfig.buffer_key to the keymap's buffer name","description":"Groomed 2026-08-13 — the original ask is already done; this is what is left.\n\nALREADY DONE: all seven constructors the ticket proposed exist on AppMode (crates/improvise-command/src/ui/app.rs:222-296): editing(), records_editing(), formula_edit(), command_mode(), category_add(), item_add(category), export_prompt(). No caller hand-builds a MinibufferConfig any more.\n\nSTILL BROKEN — the correctness win the ticket was actually after: the buffer name is written twice inside improvise-command, on opposite sides of the crate, with nothing connecting them.\n\n 1. crates/improvise-command/src/ui/app.rs:225-292 — buffer_key: \"edit\" / \"formula\" / \"command\" / \"category\" / \"item\" / \"export\"\n 2. crates/improvise-command/src/command/keymap.rs — TextEntry::new(\"edit\", ...), TextEntry::new(\"formula\", ...), etc., which is what append-char / pop-char / clear-buffer actually address\n\nIf those two disagree the mode renders a prompt over one buffer while every keystroke edits another — silent, and no test would catch it today.\n\nThis got much more tractable with improvise-w3q: TextEntry is now the single place the keymap names a buffer, so there is exactly one join to make. Options:\n (a) a Buffer enum (or \u0026'static str consts) that both AppMode::minibuffer() and TextEntry take, so the two cannot drift;\n (b) have TextEntry read the name off the AppMode it is building the keymap for.\n\nCheap interim guard regardless of which: a test asserting that for every text-entry AppMode, mode.minibuffer().buffer_key equals the buffer that mode's keymap binds append-char to. That is a direct extension of the existing text_entry_modes_clear_the_buffer_they_type_into test in keymap.rs.\n\nSuperseded scope note: the original '~40 LOC collapsed' no longer applies — the collapsing already happened. This is now a pure correctness ticket, not a size one.","notes":"Groomed 2026-06-09: the constructor consolidation has LANDED (AppMode::editing/records_editing/formula_edit/command_mode/category_add/item_add/export_prompt, crates/improvise-command/src/ui/app.rs:110-185; no hand-built minibuffer variants remain). Remaining scope is only the enforcement half: buffer_key literals are still duplicated between the constructors and command-side lookups (e.g. text_buffer.rs:187,230 hardcode \"command\"; commit.rs hardcodes \"formula\"/\"category\"/\"item\"/\"export\"). Narrow this issue to: shared buffer-key constants (or accessor via ctx.mode.minibuffer()) so a typo cannot silently desync. Related: improvise-gdl.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:25Z","created_by":"Ed L","updated_at":"2026-08-29T18:00:31Z","started_at":"2026-08-29T17:56:25Z","closed_at":"2026-08-29T18:00:31Z","close_reason":"Closed the join with option (b): TextEntry::new/with_parent now take the AppMode and read the buffer off its MinibufferConfig via buffer_of(). The keymap no longer spells a buffer name anywhere — call sites read TextEntry::new(\u0026AppMode::formula_edit(), \"formula-panel\"). The name is written exactly once, in the AppMode constructor.\n\nChose (b) over (a): a Buffer enum would still have each side naming a variant, so the two could still disagree — it closes the set of names but not the drift. Reading the name off the mode removes the second write entirely.\n\nGuards added in keymap.rs:\n- every_text_entry_mode_types_into_the_buffer_its_prompt_names: for all seven text-entry AppModes, mode.minibuffer().buffer_key equals the buffer that mode's keymap binds append-char to. Verified it bites by temporarily misspelling FormulaEdit's buffer_key — fails with 'FORMULA prompts over \"formyula\" but types elsewhere'.\n- every_typing_keymap_belongs_to_a_text_entry_mode: any keymap binding append-char must belong to a mode in the list, so an eighth text-entry mode cannot be added without coverage. SearchMode is exempt by name — it is Normal + a flag and has no AppMode variant.\n\nAlso reworked text_entry_modes_clear_the_buffer_they_type_into, which was hardcoding the buffer names a third time; it now derives them and shares one (mode, exit-keys) table with the new tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-4pg","title":"Extract panel mode keymap template","description":"command/keymap.rs:554-713 has three nearly-identical panel keymaps (FormulaPanel, CategoryPanel, ViewPanel) sharing Esc/Tab/j/k/F/C/V/: bindings. Only panel name varies.\\n\\nAbstraction: fn panel_keymap(panel: Panel) -\u003e Keymap\\n\\nCorrectness win: adding a 4th panel becomes a one-line call; prevents per-panel binding drift. Also helps keep CyclePanelFocus keybinding consistent once improvise-dqn is fixed.\\n\\n~80 LOC collapsed.","notes":"Groomed 2026-08-13 — still valid, and there is now a precedent to copy.\n\nLine range in the description is stale: the three panel keymaps are now at crates/improvise-command/src/command/keymap.rs ~600-780 (161 lines), not 554-713. Re-verify before starting.\n\nPRECEDENT: improvise-w3q landed a TextEntry builder in the same file that does exactly this job for the seven minibuffer modes — a struct that binds the shared spine in its constructor and takes the per-mode variation through chained methods. panel_keymap(panel: Panel) should follow that shape rather than inventing a second style in one file.\n\nTwo cautions learned from w3q:\n 1. Check what actually varies before committing to a signature. w3q's ticket claimed only three things varied; Tab and Backspace varied too, and the fixed-arity fn the ticket proposed would not have fitted. Diff the three panel blocks key by key first.\n 2. Verify behavior preservation by dumping every (mode, key, modifier) lookup for all 16 modes before and after and diffing the tables — most of these bindings have no direct test, so a green suite is not evidence. That is how w3q was checked.\n\nExpect the real saving to be well under the '~80 LOC collapsed' estimate once the builder's own cost is counted; w3q netted -39 against a claimed -150.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:24Z","created_by":"Ed L","updated_at":"2026-08-29T18:58:20Z","started_at":"2026-08-29T18:53:55Z","closed_at":"2026-08-29T18:58:20Z","close_reason":"Extracted PanelKeymap following the TextEntry precedent, as the notes asked.\n\nCaution 1 (diff the blocks first) paid off: a fixed-arity panel_keymap(panel: Panel) would NOT have fitted. The spine is genuinely shared — Esc/Tab to Normal, arrows and j/k to move-panel-cursor for that panel, every panel's toggle key, : for command mode — but the content keys vary in SHAPE, not just command name: Category's n takes an argument (enter-mode category-add) while Formula's and View's do not, View has no 'a', Formula has no Enter, Category alone binds Space. So the variation comes through chained .bind/.bind_args/.delete, same as TextEntry's .commit.\n\nCaution 2 (dump and diff, a green suite is not evidence): dumped all 4065 (mode, key, modifier) lookups across all 16 modes before and after — byte-identical. That was the whole verification; the pre-existing suite would not have caught a dropped panel binding.\n\nAlso added Panel::ALL/name()/key() in ui/app.rs and routed Normal mode's F/C/V through the same bind_panel_toggles helper — a small deliberate step past the three panel keymaps, because otherwise 'adding a 4th panel is a one-line call' would still have needed a manual Normal-mode binding. Now one Panel::ALL entry reaches all four keymaps.\n\nThree permanent guards replace the throwaway dump: every_panel_keymap_shares_the_same_spine (verified it bites by rebinding Tab), every_mode_with_panel_toggles_binds_all_of_them, and panel_toggle_keys_dispatch_to_real_effects (name() must spell what parse_panel accepts; a lookup-only assertion would miss it since dispatch swallows unresolvable commands with .ok()?).\n\nLOC note: the description claimed ~80 collapsed. Production keymap.rs went 953 -\u003e 899 lines, so 54 net once the builder is counted — the win is drift-prevention, not line count.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-9cn","title":"Introduce register_cmd! macro to prevent name drift","description":"crates/improvise-command/src/command/cmd/registry.rs has 20+ register_pure(\u0026SomeCmd(vec![]), SomeCmd::parse) sites where the registered name string must match SomeCmd::name(). Nothing enforces this match — a typo produces a silent dispatch failure.\\n\\nAbstraction: register_cmd!(r, SomeCmd) macro that derives the name from the Cmd impl at compile time.\\n\\nCorrectness win: compile-time guarantee that the registered name matches Cmd::name(). Subsumes improvise-61f (the runtime invariant test); keeping 61f as a belt-and-braces check is fine but may be unnecessary once this lands.\\n\\n~40 LOC collapsed.","notes":"Groomed 2026-06-09: see improvise-61f (invariant test for registry name mapping) — same drift problem from the test side. Sequence together; the macro makes the test trivial.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:22Z","created_by":"Ed L","updated_at":"2026-08-29T19:10:26Z","started_at":"2026-08-29T19:03:25Z","closed_at":"2026-08-29T19:10:26Z","close_reason":"Modelled the registry in Prolog (85 registration sites x 80 Cmd::name impls, facts extracted mechanically from source) and let it classify the drift risk rather than eyeballing. Verdict:\n\n- 70 of 85 sites already safe: register/register_pure/register_nullary all read the name off a prototype INSTANCE, not a string. The description's '20+ sites where the registered name string must match Cmd::name()' was wrong — there were 8.\n- 8 sites used NamedCmd(\"literal\") as a stand-in prototype. Prolog confirmed all 8 strings currently match what their closures build (literal_bad/3 had no solutions), so no live bug — but nothing tied them.\n- 7 sites carry a risk the ticket never mentions: ViewNavigate, TileAxisOp, ToggleGroupAtCursor and CommitAndAdvance name themselves off a FIELD, so prototype and constructed value share a type and can differ in variant.\n\nSo the macro is unnecessary. All 8 NamedCmd sites take a real prototype instead (EditOrDrill { edit_mode: AppMode::Normal }, TileAxisOp { axis: Some(Axis::Row) }, etc.), which removes the literal entirely — simpler than a macro and gives the same compile-time guarantee. NamedCmd itself is deleted; registry.rs now contains no command-name literal.\n\nThe variant class cannot be fixed by types at all, which is what improvise-61f is for (closed alongside). Note for anyone revisiting: 61f as specified — 'assert registry_name == command.name()' — is INSUFFICIENT. I injected the exact bug (ViewNavigate prototype flipped to forward: false while the closure still builds true) and that test passed: the wrong-variant entry collides with its sibling under 'view-back', so every surviving entry still agrees with its own name and 'view-forward' simply disappears. Uniqueness is what reaches it.\n\nFour guards, each verified by injecting the fault it targets:\n- every_entry_constructs_a_command_with_its_registered_name (both parse and interactive paths; asserts no entry is unreachable by both, so nothing is silently skipped)\n- registered_names_are_unique (catches the wrong-variant prototype)\n- every_bound_command_name_is_registered in keymap.rs (walks every binding in all 16 modes; dispatch resolves names with .ok()?, so an unregistered name is a silent dead key — verified by renaming toggle-records-mode)\n- every_alias_resolves_to_a_registered_command\n\nThe reasoning model is kept in prolog-sessions/kb.pl as rules (variant_named_cmd/2, catches/2, insufficient_for/2), not prose, so the 'roundtrip test does not reach wrong_variant_prototype' conclusion is re-derivable.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-w3q","title":"Extract text-entry keymap template for minibuffer modes","description":"command/keymap.rs:770-966 hand-binds Enter/Esc/Tab/Backspace/AnyChar for 8 modes (Editing, RecordsEditing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, CommandMode, SearchMode). Only buffer name, commit command, and exit mode vary between them.\\n\\nAbstraction: fn text_entry_keymap(buffer: \u0026str, commit: \u0026str, exit: AppMode, parent: Option\u003cArc\u003cKeymap\u003e\u003e) -\u003e Keymap\\n\\nCorrectness win: impossible to omit clear-buffer in one mode's Esc sequence (the exact class of bug fixed in a recent refactor). New text modes inherit semantics for free. Pairs naturally with generic CommitBuffer(kind) — once the commit primitive is unified, the template composes Sequence [commit-buffer X, clear-buffer X, enter-mode Normal] uniformly.\\n\\n~150 LOC collapsed.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:14Z","created_by":"Ed L","updated_at":"2026-08-14T00:00:26Z","started_at":"2026-08-02T18:15:40Z","closed_at":"2026-08-14T00:00:26Z","close_reason":"Added a TextEntry builder in keymap.rs; the seven text-entry modes (Editing, RecordsEditing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, CommandMode) are now built from it. It binds the shared spine (AnyChar -\u003e append-char \u003cbuf\u003e, Backspace -\u003e pop-char \u003cbuf\u003e, Esc -\u003e [clear-buffer \u003cbuf\u003e, enter-mode \u003cexit\u003e]) and .commit(key, cmd, args) welds each commit key to its clear-buffer, so the two halves cannot come apart.\n\nCORRECTION TO THE TICKET: ~39 production lines collapsed, not ~150. The ticket said 'only buffer name, commit command, and exit mode vary'. Tab and Backspace vary too: Tab is absent in FormulaEdit/ExportPrompt/CommandMode, duplicates Enter in CategoryAdd/ItemAdd, and runs commit-and-advance-right in Editing/RecordsEditing; CommandMode's Backspace is command-mode-backspace (exits on an empty line) rather than pop-char. The builder itself costs ~62 lines. Net production 978 -\u003e 939.\n\nSearchMode deliberately NOT converted: no commit key, and its query is owned by exit-search-mode rather than clear-buffer. Forcing it into the template would have meant adding opt-outs for both Esc and Enter, i.e. the template describing a mode it does not fit.\n\nVERIFICATION: dumped every (mode, key, modifier) lookup across all 16 modes from HEAD (in a throwaway git worktree) and from the refactor, 416 rows each — the tables are byte-identical. That is a stronger guarantee than the test suite alone, since most of these bindings have no direct test.\n\nAdded test text_entry_modes_clear_the_buffer_they_type_into: for each text-entry mode, asserts AnyChar appends into buffer B and every commit/exit key's Sequence contains clear-buffer B. NOTE: it PASSES against the pre-refactor code — there was no live missing-clear-buffer bug, contrary to the ticket's implied 'exact class of bug fixed in a recent refactor'. It is a regression guard, not a bug fix. Confirmed it bites by deleting the clear-buffer from ExportPrompt's Esc (fails with 'ExportPrompt: Esc must clear \"export\"').\n\n714 tests green; cargo fmt and cargo clippy --workspace --all-targets both clean.","dependencies":[{"issue_id":"improvise-w3q","depends_on_id":"improvise-6t3","type":"blocks","created_at":"2026-04-16T11:54:32Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0bf","title":"Inconsistent float-equality tolerance in formula eval","description":"crates/improvise-core/src/model/types.rs uses different float-equality strategies: division checks 'rv == 0.0' exactly (:674), while = and != comparisons use '1e-10' epsilon (:555, :749). A formula like IF(x = 0, ..., y/x) with x = 1e-11 treats x as zero in the condition but divides without error. Either unify on a single FLOAT_EQ_EPSILON constant or document the design choice explicitly in comments.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:35Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:25Z","closed_at":"2026-06-10T04:46:25Z","close_reason":"Fixed in 45bfe2c (test-first): shared FLOAT_EQ_EPSILON constant governs Eq/Ne and div-by-zero guards in both eval paths (regression tests for each); stem dedup now IndexSet (order-preserving), removing the O(n^2) scan.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-kdp","title":"Duplicated aggregation dispatch block","description":"crates/improvise-core/src/model/types.rs:305-312 and :598-604 contain identical Sum/Avg/Min/Max/Count match blocks against AggFunc. If business logic changes (NaN handling, default agg), updates must be made in both sites. Fix: extract fn apply_agg(values: \u0026[f64], func: \u0026AggFunc) -\u003e f64 as a pure helper.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:29Z","created_by":"Ed L","updated_at":"2026-08-01T00:45:47Z","started_at":"2026-07-31T17:48:34Z","closed_at":"2026-08-01T00:45:47Z","close_reason":"Extracted apply_agg, eval_agg and Model::find_item_category; all four duplicated blocks across the two eval paths now route through one definition. Direct unit tests added.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-nrs","title":"Duplicated find_item_category helper in eval paths","description":"crates/improvise-core/src/model/types.rs has identical nested find_item_category helpers at lines 420 and 627 (inside eval_formula_with_cache and eval_formula_depth). Both search all categories for an item, then fall back to formula targets. Fix: extract as Model::find_item_category(\u0026self, item: \u0026str) -\u003e Option\u003c\u0026str\u003e so bug fixes apply in one place.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:26Z","created_by":"Ed L","updated_at":"2026-08-01T00:45:47Z","started_at":"2026-07-31T17:48:35Z","closed_at":"2026-08-01T00:45:47Z","close_reason":"Extracted apply_agg, eval_agg and Model::find_item_category; all four duplicated blocks across the two eval paths now route through one definition. Direct unit tests added.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-6os","title":"O(n^2) stem deduplication in recompute_formulas","description":"crates/improvise-core/src/model/types.rs:346-359 accumulates unique formula stems using 'if !stems.contains(\u0026stripped) { stems.push(stripped); }'. This is O(n^2) over every cell for every formula category. recompute_formulas runs at startup and after every data mutation. Large workbooks will show visible slowdown. Fix: collect into HashSet\u003cCellKey\u003e, convert to Vec once for iteration order.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:24Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:26Z","closed_at":"2026-06-10T04:46:26Z","close_reason":"Fixed in 45bfe2c (test-first): shared FLOAT_EQ_EPSILON constant governs Eq/Ne and div-by-zero guards in both eval paths (regression tests for each); stem dedup now IndexSet (order-preserving), removing the O(n^2) scan.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-dqn","title":"CyclePanelFocus does not actually cycle between panels","description":"The CyclePanelFocus command at src/command/cmd/panel.rs:299-313 is documented as 'Tab through open panels' but the if/else if chain always resolves to the first open panel (formula \u003e category \u003e view). Pressing the bound key repeatedly never rotates. Test at panel.rs:84 only asserts a panel mode is entered, not rotation. Fix: either implement real rotation using ctx.mode to determine 'next' open panel, or rename to FocusFirstOpenPanel. Also replace the three bool fields with an enum of open panel states.","acceptance_criteria":"With two or more panels open, repeated cycle-panel-focus visits each open panel in order and wraps around; with one panel open it is a no-op (stays focused). Unit test builds CmdContext with formula+category panels open in CategoryPanel mode and asserts the returned effect targets FormulaPanel (not first-open-wins).","notes":"Groomed 2026-06-09: downgraded P1→P2 — broken command but cosmetic (focus lands on first open panel instead of cycling); the remaining P1s are all data-integrity bugs. Verified still present at src/command/cmd/panel.rs:294-313 (first-open-wins if/else chain, ignores current mode).","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:07Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:48Z","closed_at":"2026-06-10T04:46:48Z","close_reason":"Fixed (test-first where applicable): records_display returns None for OOB columns (f04fe51); try_axis_of added + axis_of invariant documented + cycle_axis no longer panics on unregistered categories (f04fe51); scrolled header suppression fixed via pure show_sublabel helper for both row and column headers (df9a02b); CyclePanelFocus cycles from current panel with wrap (9e02245); AGENTS.md merge target corrected (9634c95).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-dwe","title":"Split App into AppState + App wrapper (in-place, no crate change)","description":"Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from \u0026mut App to \u0026mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes.","design":"AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(\u0026mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert(\"edit\", self.initial_value.clone()); state.mode = self.target_mode.clone();","acceptance_criteria":"(1) AppState struct exists; App wraps it. (2) Effect::apply takes \u0026mut AppState, not \u0026mut App. (3) CmdContext.workbook still resolves to \u0026AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change.","notes":"Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T06:28:47Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-26T18:08:24Z","dependencies":[{"issue_id":"improvise-dwe","depends_on_id":"improvise-vb4","type":"supersedes","created_at":"2026-04-16T16:02:31Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3zq","title":"Records-mode Enter/Tab at bottom-right: persist empty record, re-enter editing","description":"Two related bugs at the bottom-right cell of records view:\n\n1. Enter on bottom-right cell re-enters editing at the same cell (because EnterAdvance stays put at bottom-right and EnterEditAtCursor then re-edits). Expected: exit edit mode.\n\n2. Tab on bottom-right cell in records mode calls AddRecordRow, which SetCells with an empty CellKey when no page filters are active (the default records view has _Measure/foo on None axis, not Page). This writes a nonsense ' = 0' cell to the model that gets persisted (see sample.improv line 26: ' = 0'). Re-toggling records mode (RR) shows the empty record at the top of the table.\n\nRoot causes:\n- CommitAndAdvance (Down) always appends EnterEditAtCursor, even when no forward motion is possible.\n- AddRecordRow::execute unconditionally produces SetCell(empty_key, 0) when coords vec is empty, rather than bailing out.\n\nTests should be added in crates/improvise-persistence (or wherever) that demonstrate ' = 0' is not persisted, plus command-level tests that:\n- CommitAndAdvance(Down) at bottom-right produces a non-editing mode change.\n- AddRecordRow with no page filters produces no SetCell effect.","acceptance_criteria":"1. AddRecordRow with empty coords emits only a status message, no SetCell.\n2. commit-cell-edit (Enter) at bottom-right exits editing (lands in Normal/RecordsNormal).\n3. Tab at bottom-right of records view does not create an empty cell in the model.\n4. All new tests fail on main before the fix; pass after.","notes":"Bug #1 (Enter at bottom-right re-enters editing) — fixed via effect-chain abort mechanism:\n- Added App::abort_effects flag; apply_effects short-circuits and resets per batch\n- New AbortChain effect sets the flag\n- EnterAdvance at bottom-right now emits only AbortChain\n- CommitAndAdvance pushes change_mode(exit_mode_for(edit_mode)) BEFORE advance; trailing EnterEditAtCursor lifts back to editing only if advance succeeded\n\nBug #2 (Tab persists empty record across RR) — fixed by cleanup-on-leave:\n- New CleanEmptyRecords effect removes cells with empty CellKey\n- ToggleRecordsMode on leave emits [CleanEmptyRecords, ViewBack, status] — inverse of the SortData on entry\n\n5 new tests (2 integration demonstrating the bugs, 3 unit for the new primitives). 366 tests pass; clippy clean.","status":"closed","priority":2,"issue_type":"bug","assignee":"fido","owner":"el-github@elangley.org","created_at":"2026-04-16T06:01:20Z","created_by":"fido","updated_at":"2026-04-16T06:26:44Z","closed_at":"2026-04-16T06:26:44Z","close_reason":"Both bugs fixed; see notes for implementation summary. All 5 new tests pass; 366 workspace tests green; clippy clean.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-4ju","title":"Make EditOrDrill mode-agnostic; delete EnterEditMode","description":"EnterEditMode::execute currently checks ctx.mode.is_records() to decide between editing() and records_editing(). Per the keymap design principle (commands/effects must be mode-agnostic; modes switch keymaps), this is wrong. EditOrDrill (the only caller) should take an edit_mode parameter, and the records-mode keymap should pass records-editing while the normal keymap passes editing. Delete EnterEditMode; replace its registration with a parameterized binding for edit-or-drill.","notes":"Refactored both:\n- EditOrDrill (i/a) — now takes edit_mode: AppMode; keymap supplies it\n- EnterEditAtCursor effect + Cmd — now take target_mode: AppMode\n- CommitAndAdvance (Enter/Tab in editing modes) — now takes edit_mode: AppMode\n- Records-normal keymap overrides i/a to pass records-editing\n- Records-editing keymap overrides Enter/Tab to pass records-editing\n- Records-normal o sequence now passes records-editing\n- Shared parse_mode_name helper in registry.rs\n\nNo is_records() checks remain in any of these commands/effects.\nTests added for each parameterization (5 new tests).\n551 tests pass; clippy clean.","status":"closed","priority":2,"issue_type":"task","assignee":"fido","owner":"el-github@elangley.org","created_at":"2026-04-16T05:04:39Z","created_by":"fido","updated_at":"2026-04-16T05:35:11Z","closed_at":"2026-04-16T05:35:11Z","close_reason":"EditOrDrill, EnterEditAtCursor (effect + cmd), and CommitAndAdvance all parameterized with mode args from keymap; no runtime is_records() checks remain in those paths; 5 new tests cover the contract.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-kos","title":"_Dim and _Index should work as pivot axes outside Records/Drill view; must default to None axis on non-records views including empty models","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T11:15:40Z","created_by":"Edward Langley","updated_at":"2026-04-15T11:37:10Z","closed_at":"2026-04-15T11:37:10Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-60z","title":"Virtual views _Records and _Drill should not be persisted to .improv files","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T11:13:29Z","created_by":"Edward Langley","updated_at":"2026-07-31T16:04:38Z","started_at":"2026-07-31T07:26:39Z","closed_at":"2026-07-31T16:04:38Z","close_reason":"format_md skips views where View::is_virtual(); Initial View falls back to the first real view; parse_md drops stale virtual views so older files self-heal. Three regression tests in persistence.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-c5v","title":"Improve records view data entry by making each measure a normal value column (wide format instead of synthetic Value column)","description":"Current records view uses long format with synthetic 'Value' column. Editing values requires being on the 'Value' column; editing measure column renames the coordinate. This is awkward for typical data entry. Switch to wide format: one column per measure with direct value editing. Update build_records_mode, records_display, cell_key, resolve_display, DrillState pending edits, tests, and related effects. Red-green-refactor cycle. Extends/ reopens improvise-rbv.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T10:34:46Z","created_by":"Edward Langley","updated_at":"2026-08-29T19:29:18Z","started_at":"2026-08-29T19:13:28Z","closed_at":"2026-08-29T19:29:18Z","close_reason":"Records mode switched from long format (synthetic 'Value' column + a _Measure column) to wide format: a row is one coordinate tuple, each raw measure is its own editable column, and a trailing '+' column creates new measures.\n\nDerived the case matrix in Prolog rather than guessing it, which surfaced the load-bearing case the description does not mention: in wide format a row GROUPS one cell per measure, so editing a category column must move every one of those cells. The long-format code moved only the cell under the cursor, which would have torn rows in half — half the measures under the old coordinate, half under the new. Both edit paths (plain records and staged drill edits) implement it; both are tested.\n\nDesign decisions:\n- _Dim in the synthetic CellKey now carries the column INDEX, not a label. A category and a measure item can share a name, so a label cannot say which column an edit landed on. Resolved through GridLayout::record_col -\u003e RecordCol {Category, Measure, NewMeasure}. This rippled through synthetic_record_info, CmdContext::synthetic_record_at_cursor, DrillState.pending_edits (now keyed by (row, RecordCol), which also removes ApplyAndClearDrill's dependency on the layout still being the drill layout), and the grid renderer.\n- Measure columns are the raw _Measure items only. Formula targets are computed, so there is nothing to type into them.\n- The '+' column was the user's call over two alternatives, because a blank model has no measures and wide format would otherwise leave nowhere to enter a number — that is the onboarding path pinned by the old edit_record_row_in_blank_model_persists_value test. Adding a measure is a schema change, so it applies immediately even inside a drill rather than being staged.\n\nVerified end-to-end, not just by unit tests: tests/records_wide_smoke.rs imports a real CSV, loads the .improv, and asserts the records grid is two rows with both measures side by side. My first two expectations there were wrong (measure order and row order come from the file/store, not the source CSV) — the code was right both times.\n\nSix existing tests encoded long-format behavior and were rewritten, not deleted: the two-row helper now uses two Region items rather than two measures on one, and the blank-model test drives the new '+' flow.\n\nAlso removed prune_empty's records branch, which was unreachable behind the function's own early return; filed improvise-2yk in case the early return is itself the bug.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-edp","title":"Establish ViewModel layer: derived data between RenderCache and widgets","description":"Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly.","design":"For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: \u0026SurfaceCache, view: \u0026ViewState, render_env: \u0026RenderEnv) -\u003e SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read \u0026SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation.","acceptance_criteria":"(1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed.","notes":"Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.\n\n--- UNBLOCKED: improvise-vjms decided (user, 2026-09-19) ---\nThe category panel question is answered: the TUI KEEPS the virtual categories\nand the BROWSER GETS THEM TOO. CategoryTreeCache grows to carry _Index, _Dim\nand _Measure so both panels render the same thing from one source.\n\nTHE TRAP TO AVOID when implementing: _Measure items are DERIVED, not stored --\ngotcha 7 says they are the explicit items UNION the formula targets, and\nadd_formula does not add items to _Measure. So category_tree_of must compute\nmembership through measure_item_names()/effective_item_names(), NEVER through\ncategory(\"_Measure\").items, or the panel silently omits every formula target.\nimprovise-ete was closed for proposing exactly that raw-list getter.\n\nAlso: CategoryTreeCache doc comment says \"the client does not know the\n_-prefixed virtual-category convention\" -- that is now false and must be\nremoved rather than left to mislead.\n\nThen build_cat_tree can migrate off \u0026Model (it takes \u0026Model today), which is\nwhat actually unblocks the category panel and this ticket. improvise-35e\nmigrated the other three panels already; this is the fourth.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:04Z","created_by":"spot","updated_at":"2026-09-20T00:25:56Z","closed_at":"2026-09-20T00:25:56Z","close_reason":"PREMISE LARGELY FALSE; the one real piece was built and the rest should not be.\n\nThe ticket asked for a SurfaceViewModel per SurfaceCache plus compute_*(cache, view, render_env). Evidence it does not apply: (a) gotcha 32 already removed the aggregate RenderCache the ticket names, and (b) after improvise-35e the widget seam is ALREADY one cache plus \u0026AppMode -- FormulaContent::new(\u0026FormulaCache, \u0026AppMode), ViewContent::new(\u0026ViewListCache, \u0026str). A per-widget cache built by project.rs is already the render-ready shape: CellDisplay carries formatted strings, GridCache carries col_widths. There is no cache-shape to decouple a widget from. render_env is likewise handled -- the terminal extent enters through ClientSession::set_extent, and inventing a Command for it is forbidden by decision(improvise-cqi, command_set_derived_not_invented).\n\nWHAT WAS REAL AND GOT BUILT: the category panel flattening, which genuinely sits between a cache and a renderer because it mixes CategoryTreeCache with per-client ViewState::expanded_cats. It lives in improvise-protocol::viewmodel (CatTreeEntry + flatten_cat_tree), NOT improvise-command, because improvise-bpq2 cannot reach improvise-command -- the wasm client Cargo.toml excludes it deliberately -- and move-panel-cursor walks this flattening. Both expanded_cats and cat_panel_cursor already live in protocol ViewState, so the derivation inputs and its consumer index are all in that crate. Putting it in improvise-command would have guaranteed bpq2 re-derives it client-side and the two ends drift.\n\nALSO DELIVERED: improvise-vjms. CategoryTreeCache now carries every category; the stale \"the client does not know the _-prefixed convention\" doc is gone; CatTreeEntry carries its Axis so CategoryContent takes a cache and an expanded set and no View -- the FOURTH AND LAST panel off \u0026Model. It also removes a latent panic: the widget called view.axis_of(name), which panics for a category the view has never seen.\n\nA LIVE SHIPPED BUG WAS FIXED, not a hypothetical one: build_cat_tree took \u0026Model and read cat.items.len()/ordered_item_names() -- the RAW list -- so the TUI category panel has been showing _Measure with the WRONG ITEM COUNT and OMITTING EVERY FORMULA TARGET, on main, today. That is the improvise-ete failure mode already shipped. Injecting it back fails exactly ONE test against 1023 green.\n\nNOT BUILT AND NOT WORTH A TICKET AS SPECCED: viewmodels for grid/formula/view/tile-bar. 1031 tests pass.","dependencies":[{"issue_id":"improvise-edp","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-e0u","title":"Split native binary into in-process client + server, delete \u0026App adapter","description":"The architectural payoff: once every widget consumes RenderCache + ViewState, restructure the native binary so the ratatui UI is a proper client of an in-process server. Main loop has two halves: server holds the full App + projection layer + VFS persistence; client holds ViewState + RenderCache + ratatui widgets. Commands flow through a direct-call channel (zero-serialization in-process transport). Delete the temporary RenderCache::from_app adapter.","design":"Main loop restructure: fn main() spawns (a) a server loop that owns an App + projection layer + VFS storage (via improvise-6mq Storage trait backed by PhysicalFS for native), and (b) a client loop that owns a ViewState + RenderCache and renders ratatui. Communication via a tokio mpsc channel (or plain std::sync::mpsc since native is single-threaded) — no serialization because both sides hold the same Command enum in memory. Client captures keys → resolves via keymap → sends Command upstream → server calls reduce_full → projection commands come back down → client applies them via reduce_view → widgets redraw. Importantly, this is exactly the same message flow as the browser modes; the transport is just a function call. Delete crates/improvise-command/src/ui/app.rs's RenderCache::from_app adapter (issue 2). Native TUI's App now lives on the server side and is never read directly by widgets.","acceptance_criteria":"(1) Native binary has distinct client and server halves with a Command channel between them. (2) Ratatui widgets never read \u0026App. (3) RenderCache::from_app adapter is deleted. (4) All existing tests + integration tests pass. (5) Performance: frame latency unchanged from current native TUI (the projection work is now on the server side, the rendering work on the client side, but it's all the same process). (6) A structural lint: nothing in src/ui/ can import from src/model/ or src/command/ (only from improvise-protocol's cache types).","notes":"Terminal node of the required work in this epic. Depends on issues 1 (reduce_full), 2 (adapter), 3-6 (all widget migrations). After this lands, the native TUI and the browser modes share an identical architecture — only the transport and persistence backend differ.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:54Z","created_by":"spot","updated_at":"2026-08-14T00:44:33Z","dependencies":[{"issue_id":"improvise-e0u","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-764","title":"Migrate import wizard widget and state to consume RenderCache","description":"Rewrite ui/import_wizard_ui.rs (347 lines) to consume a WizardCache instead of the ImportWizard struct directly. Also decide where the import wizard state itself lives in the new crate layout — probably needs to stay in improvise-command (not improvise-io) so it can be referenced from App without creating a dep cycle.","design":"WizardCache mirrors the ImportWizard state: current step, per-field decisions, preview rows, current validation errors. The widget reads cache + ViewState and draws. The ImportWizard state machine stays wherever it ends up living after the crate-split epic step 3 resolves its placement — either improvise-command (portable state machine) or improvise-io (tangled with CSV parsing). The separation between wizard state (session/view-ish) and import pipeline (pure data processing) is worth getting right because it affects the worker-server's ability to run imports.","acceptance_criteria":"(1) import_wizard_ui.rs no longer reads the wizard state directly — takes WizardCache. (2) ImportWizard is split into state + pipeline; state lives in an appropriate crate that doesn't cycle. (3) Native TUI wizard rendering unchanged. (4) Unit tests with fixture WizardCaches for each step.","notes":"Depends on improvise-edp (ViewModel layer). Wizard widget consumes WizardViewModel derived from WizardCache + ViewState. The wizard state machine itself (step tracking, field decisions) lives in the ImportWizard struct on the server side; the cache is a snapshot of its renderable state; the viewmodel adds any styling/layout derivation the widget needs.\nPRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-ltq improvise-e0u ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:47Z","created_by":"spot","updated_at":"2026-09-19T20:29:31Z","dependencies":[{"issue_id":"improvise-764","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-jb3","title":"Migrate help and which_key widgets to consume RenderCache + ViewState","description":"Rewrite the small static-content widgets: ui/help.rs (5-page help overlay, 617 lines but mostly static text) and ui/which_key.rs (prefix-key hint popup). Lowest-risk widgets in the migration because their content is almost entirely derived from static data plus a small amount of ViewState (help_page index, active transient_keymap).","design":"HelpCache: largely static; just the page-index state and the content pages (constant). Could even be stateless if the renderer reads help_page directly from ViewState. WhichKeyCache: derived from the active transient_keymap — a flat list of (key, binding description) entries. Each widget becomes a pure render function taking its cache + ViewState for minimal dynamic bits.","acceptance_criteria":"(1) help.rs and which_key.rs no longer import App. (2) Unit-testable with fixture caches (or no cache for help). (3) Native TUI rendering unchanged.","notes":"Depends on improvise-edp (ViewModel layer). Help widget consumes HelpViewModel (derived from the static help content + current help_page); which_key consumes WhichKeyViewModel (derived from the active transient_keymap). Low-complexity widgets, but go through the viewmodel layer for consistency with the rest of the migration.\nPRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-e0u improvise-ltq ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.\nSTALE ASSUMPTION (groomed 2026-08-30): this design refers three times to 'the active transient_keymap' as a ViewState field. That field is gone (improvise-jc8). ViewState now holds transient_prefix: Option\u003c(KeyCode, KeyModifiers)\u003e — the key that OPENED the prefix — and the sub-keymap is resolved on demand via KeymapSet::prefix_keymap(mode, key, mods), which walks parent keymaps.\n\nConsequence for the WhichKeyCache described here: it cannot be derived from ViewState alone any more. It needs ViewState (for the armed prefix) plus the keymap set (to resolve it and call binding_hints()). App::armed_prefix_keymap() does exactly that today and is the natural source. This matters because the whole point of jb3 is that widgets stop reading \u0026App — so decide whether the cache is populated server-side from the resolved keymap, or whether the hint list itself is what crosses to the client. The latter is probably right: hints are (label, command-name) pairs, which are portable, whereas a Keymap is not.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:23Z","created_by":"spot","updated_at":"2026-09-19T20:29:32Z","dependencies":[{"issue_id":"improvise-jb3","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:37Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-pca","title":"Migrate panel widgets (category/formula/view/tile_bar) to consume RenderCache","description":"Rewrite the smaller ratatui panel widgets so each one takes its relevant sub-cache slice instead of \u0026App. Targets: ui/category_panel.rs + cat_tree.rs, ui/formula_panel.rs, ui/view_panel.rs, ui/tile_bar.rs, ui/panel.rs (generic frame). Each becomes a pure renderer of its own cache structure.","design":"Four sub-cache types: CategoryTreeCache (flattened cat/item/group tree with expand state, current cursor index), FormulaListCache (raw strings + target info), ViewListCache (names + active flag), TileBarCache (per-category axis assignment). Each widget's render takes \u0026SubCache and \u0026ViewState (for mode / cursor). Widget signatures: fn draw_category_panel(frame, area, cache: \u0026CategoryTreeCache, view: \u0026ViewState); same shape for the others. The flattening logic that's currently in ui/cat_tree.rs (build_cat_tree) moves into cache construction rather than happening at render time. Handcrafted fixture caches for unit tests. Panels become entirely render-only; no App dependency. Shared with the browser/standalone clients later if they ever add panel rendering (post-MVP for those).","acceptance_criteria":"(1) Each of the four panel widgets no longer imports App. (2) Each is unit-testable with a fixture sub-cache. (3) Native TUI renders identically to before. (4) Sub-cache types live in improvise-protocol so they can be serialized for remote clients later.","notes":"Depends on improvise-edp (ViewModel layer). Each panel widget consumes its own sub-viewmodel (CategoryTreeViewModel, FormulaListViewModel, ViewListViewModel, TileBarViewModel), computed from the corresponding sub-cache + ViewState. Panels become pure renderers of viewmodels; all flattening, ordering, and highlight logic lives in the compute_*_viewmodel functions.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:49:06Z","created_by":"spot","updated_at":"2026-09-19T20:29:31Z","dependencies":[{"issue_id":"improvise-pca","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-n10","title":"Migrate GridWidget to consume RenderCache + ViewState (not \u0026App)","description":"Rewrite ui/grid.rs so that GridWidget::render takes \u0026GridCache + \u0026ViewState instead of reaching into \u0026App. The grid is the largest and most important widget — 1036 lines, mixes layout calc + ratatui drawing + direct App reads. The migration separates 'what to draw' (data in GridCache) from 'how to draw it' (ratatui primitives). Also aligns with improvise-cr3 (browser DOM renderer), which renders the same GridCache shape to DOM — share the GridCache type between them.","design":"pub struct GridCache { pub col_widths: Vec\u003cu16\u003e, pub row_labels: Vec\u003cString\u003e, pub col_labels: Vec\u003cString\u003e, pub cells: Vec\u003cVec\u003cCellDisplay\u003e\u003e, pub cursor: (usize, usize), pub highlights: Vec\u003cHighlight\u003e, pub mode_indicator: ModeIndicator, ... }. GridWidget becomes: fn draw_grid(frame: \u0026mut Frame, area: Rect, cache: \u0026GridCache, view: \u0026ViewState). All current dynamic calculations (col width from content, layout metrics, records mode detection) move to GridCache construction in the projection layer, not at render time. Widget is pure drawing. The temporary RenderCache::from_app adapter (issue 2) populates GridCache from App+View+GridLayout; later the projection layer computes it server-side for remote clients. During this migration, the widget is tested against a handcrafted GridCache fixture — no App needed. Share GridCache type with improvise-cr3 by placing it in improvise-protocol.","acceptance_criteria":"(1) GridWidget no longer imports App or reads anything outside GridCache + ViewState. (2) Widget is unit-testable with a fixture GridCache. (3) Native TUI renders identically to before for every .improv file in tests/. (4) GridCache type is shared with improvise-cr3 (both rasterize the same data structure). (5) Performance: per-frame render time unchanged (the projection layer work happens outside the widget, not inside).","notes":"Depends on issue improvise-edp (ViewModel layer) — the grid widget consumes GridViewModel (client-computed from GridCache + ViewState + render env), not GridCache directly. Share design work with improvise-cr3 (browser DOM renderer) — both target the same GridViewModel type, differing only in the rendering backend.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:49:03Z","created_by":"spot","updated_at":"2026-09-19T20:29:30Z","dependencies":[{"issue_id":"improvise-n10","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-35e","title":"Temporary adapter: RenderCache::from_app bridge for incremental widget migration","description":"Build a function that constructs a complete RenderCache from an \u0026App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '\u0026App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading \u0026App. When every widget has been migrated and the binary split is complete, this adapter is deleted.","design":"pub fn RenderCache::from_app(app: \u0026App, subs: SubscriptionSet) -\u003e RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option\u003cGridCache\u003e, category_tree: Option\u003cCategoryTreeCache\u003e, formulas: Option\u003cFormulaListCache\u003e, views: Option\u003cViewListCache\u003e, tile_bar: Option\u003cTileBarCache\u003e, wizard: Option\u003cWizardCache\u003e, help: Option\u003cHelpCache\u003e, ... }. Each field is Some if subscribed and populated from App data.","acceptance_criteria":"(1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly.","notes":"Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read.\nGROOMED 2026-09-19: the RenderCache that actually landed in improvise-cqi is grid-only (row_labels/col_labels/col_widths/cells) — not the 'grid + category_tree + formulas + views + tile_bar + wizard + help' shape this ticket's design describes. Blocked on improvise-rkeq until the shape is decided; writing from_app against the wrong one is the expensive mistake.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:38Z","created_by":"spot","updated_at":"2026-09-19T23:40:22Z","closed_at":"2026-09-19T23:40:22Z","close_reason":"PREMISE CHECK FIRST, and the ticket as written is DEAD: RenderCache::from_app(app, subs) -\u003e RenderCache cannot be written because that aggregate does not exist and will not (improvise-rkeq, gotcha 32). It was not written. The builders it asked for already existed and were already pub -- grid_of, category_rows, formula_texts, view_summaries, tile_entries -- and Dirty already answers the SubscriptionSet, so no parallel type was invented.\n\nTWO THINGS WERE GENUINELY MISSING, both real:\n1. THE ROOT CRATE COULD NOT NAME A CACHE TYPE. improvise had no improvise-protocol dependency and improvise-command re-exported core/formula/io but NOT protocol, so no ratatui widget in src/ could write fn new(cache: \u0026FormulaCache). That was the hard blocker and nothing complained because nobody had tried. Fixed by re-exporting the protocol through improvise-command (and the root crate), so a host names the caches through the crate that builds them and the root takes no new dependency.\n2. THE BUILDERS RETURNED PAYLOADS, NOT CACHES. Added category_tree_of/formulas_of/view_list_of/tile_bar_of, each one line over the existing payload fn so there is no second derivation. document_projections and the tests server_truth now both derive FROM the caches, so what a widget holds and what a client folds cannot disagree.\n\nThree widgets migrated off \u0026Model/\u0026View: formula panel, view panel, tile bar. No aggregate type and no adapter type -- a caller wanting several caches calls several builders, and no widget names the seam, so deleting the temporary half is deleting call sites.\n\n6 tests, each an EQUIVALENCE between the cache and what the widget used to read off the model (plus pinned render output where drawing has layout logic). Three faults injected into the builders, all fired. 970 workspace tests pass.","dependencies":[{"issue_id":"improvise-35e","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:34Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-35e","depends_on_id":"improvise-rkeq","type":"blocks","created_at":"2026-09-19T12:49:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gxi","title":"Define unified reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e","description":"Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function.","design":"Signature: pub fn reduce_full(app: \u0026mut App, cmd: \u0026Command) -\u003e Vec\u003cCommand\u003e. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports.","acceptance_criteria":"(1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box\u003cdyn Cmd\u003e trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: \u0026Command, ctx: \u0026CmdContext) -\u003e Vec\u003cEffect\u003e' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path.","notes":"Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist.\nGROOMED 2026-09-19 — PREREQUISITES DISSOLVED. The notes said this depends on \"App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes)\". improvise-3mm is closed and App already lives at crates/improvise-command/src/ui/app.rs, so that prerequisite is satisfied, not pending. improvise-cqi is closed too: Command, ViewState and reduce_view are in improvise-protocol, and improvise-command depends on it. Nothing blocks this now — it is in bd ready.\n\n--- PARTIALLY LANDED 2026-09-19; ACs 4-5 BLOCKED, deliberately not forced ---\nDONE: reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e (AC 1), apply_command\n(the inner half a server uses to drive Sessions::project_all), reduce_key,\nand App::handle_key is now a wrapper over App::dispatch_key(key) -\u003e Dirty\n(AC 2 in spirit). All existing behaviour preserved (ACs 3, 6).\n\nNOT DONE, and not doable as written: ACs 4 and 5 ask that keymap lookup\nreturn Command values and that CmdRegistry gain dispatch_command(\u0026Command).\nThe Command enum's client-\u003eserver half is exactly the ViewOnly portable-slice\nmutations -- cqi derived it from those effects and nothing else -- so it has\nno SetCell, AddCategory, SetAxis or Save. A keymap cannot return a Command for\nmost bindings, and reduce_full over a Command cannot drive a model mutation.\nForcing it would mean inventing variants ahead of the mutations, which\ndecision(improvise-cqi, command_set_derived_not_invented) and\ndoctrine(derive_the_enum_from_the_effects) both forbid.\n\nBLOCKED ON: the new ticket to derive the model half of Command from the 29\nEffectKind::Model effects. Until then reduce_full and reduce_key are two\ndoors and ui/reduce.rs's module doc explains why.\n\n--- AC 4 IS UNREACHABLE BY DESIGN, not for want of vocabulary (2026-09-19) ---\nimprovise-kmgs landed ModelCommand, so the \"the vocabulary is missing\" reason\nis gone. AC 4 still cannot be met, and the sharper reason is worth recording:\n\na keymap binding names an INTENT, resolved against CmdContext -- \"move-cursor\ndown\". A Command is the RESOLVED MUTATION -- SetSelected { row, col } -- which\nonly exists once the cursor and layout have been read. So a keymap LOOKUP can\nnever return a Command; only dispatch, after CmdContext, can. That is a\nproperty of the Cmd/Command split itself, not a gap.\n\nRECOMMEND: rewrite AC 4 to \"dispatch (not lookup) yields a Command where the\nbinding resolves to a model mutation\", or drop it. Do not force the original\nwording.\n\nAC 5 is satisfied in substance: model_effects(\u0026ModelCommand) -\u003e Vec\u003cBox\u003cdyn\nEffect\u003e\u003e in ui/reduce.rs is the \"or equivalent\". It was deliberately NOT hung\noff CmdRegistry as dispatch_command, because the mapping needs neither the\nregistry nor a CmdContext and a method ignoring \u0026self would misdescribe it.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:36Z","created_by":"spot","updated_at":"2026-09-19T23:29:48Z","started_at":"2026-09-19T21:15:06Z","closed_at":"2026-09-19T23:29:48Z","close_reason":"CLOSED, re-scoped: ACs 1-3 and 6 delivered; ACs 4-5 resolved as written-against-a-false-premise.\n\nDELIVERED (wave 2, merged): reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e, apply_command (the inner half a server uses to drive Sessions::project_all), reduce_key, and App::handle_key as a wrapper over App::dispatch_key(key) -\u003e Dirty. All existing behaviour preserved.\n\nAC 5 satisfied in substance: model_effects(\u0026ModelCommand) -\u003e Vec\u003cBox\u003cdyn Effect\u003e\u003e in ui/reduce.rs is the \"or equivalent\". Deliberately NOT hung off CmdRegistry as dispatch_command, because the mapping needs neither the registry nor a CmdContext and a method ignoring \u0026self would misdescribe it.\n\nAC 4 IS UNREACHABLE BY DESIGN, and the reason is sharper than \"the vocabulary is missing\" (improvise-kmgs supplied the vocabulary and it is still unreachable): a keymap binding names an INTENT resolved against CmdContext -- \"move-cursor down\" -- while a Command is the RESOLVED MUTATION -- SetSelected { row, col } -- which only exists once the cursor and layout have been read. So a keymap LOOKUP can never return a Command; only dispatch, after CmdContext, can. That is a property of the Cmd/Command split itself. Forcing it would mean either inventing intent-shaped Command variants (which decision(improvise-cqi, command_set_derived_not_invented) forbids) or dissolving the split.\n\nClosing rather than leaving open: it blocks improvise-cqq and the 35e -\u003e edp -\u003e cr3 chain to improvise-1ey, which is now the priority, and there is no remaining work here that a future ticket would not state better. If dispatch-yields-a-Command is wanted, file it as its own ticket against the real question.","dependencies":[{"issue_id":"improvise-gxi","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-08-29T12:37:17Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ltq","title":"Epic: Native TUI as in-process client of the unified server architecture","description":"Restructure the native binary so the ratatui UI is a client of an in-process server, exactly the way the DOM renderer is a client of the service worker in the standalone deployment and of the ws-server in the network deployment. One binary process, two halves: a 'server' holding the full App + projection layer, and a 'client' holding ViewState + render cache + ratatui widgets. Commands flow through a direct in-process channel (same shape as websocket/postMessage protocols, zero serialization overhead). The server emits projection commands back to the client's cache. Result: the ratatui path is architecturally identical to the browser paths — same reduce_full, same Command vocabulary, same cache-based rendering. Widgets stop reading \u0026App directly.","design":"Four deployment modes all share one architecture: (1) native TUI = in-process client + server with direct-call transport, (2) network thin-client = browser main thread + ws-server with websocket transport, (3) standalone web = browser main thread + worker with postMessage transport, (4) hybrid = in-process native client + server PLUS remote ws-server subscriber = native TUI with remote browser observer. Under this epic, mode (1) gets restructured to match (2) and (3). The shared pieces: reduce_full, projection layer with per-client subscriptions, Command vocabulary, render cache shape. The difference per mode is only the transport + which clients subscribe to which projection kinds. Native TUI subscribes to every projection kind because it renders everything (grid, panels, help, wizard); browser thin-client MVP subscribes to grid only. Staging: introduce a temporary RenderCache::from_app adapter, migrate widgets one at a time with the adapter bridging, split the binary and delete the adapter at the end. Every widget migration is individually landable.","notes":"Implications for other epics: (A) improvise-cqq (browser epic projection layer) gains a prerequisite on issue 1 of this epic (unified reduce_full). (B) improvise-3mm (crate-epic step 5) needs its design updated to put App in improvise-command, not improvise-tui, so reduce_full can live alongside App without pulling ratatui into ws-server or worker-server. (C) improvise-cr3 (browser DOM renderer) and issue 3 of this epic (ratatui grid cache consumer) share the RenderCache grid shape and can share the GridViewModel type — coordinate the two to avoid duplication. Stretch issues cover hybrid mode (native TUI + remote browser subscriber) and native undo/replay via command logging.\nGROOMED 2026-09-19: implication (B) in the description is done — improvise-3mm closed with App in improvise-command, which is where this epic wanted it. Implication (A) is recorded as a real edge (cqq depends on gxi), so it no longer needs to live in prose.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:34Z","created_by":"spot","updated_at":"2026-09-19T19:49:04Z","dependencies":[{"issue_id":"improvise-ltq","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:48Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:51Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-9x6","title":"Worker-hosted server bundle (wasm, MessageChannel transport)","description":"New wasm bundle that hosts the full App + command pipeline + projection layer + VFS persistence inside a browser worker. Communicates with the main-thread side via a MessageChannel carrying Command messages in the same shape as the websocket protocol. The worker-server is the 'server' half of the standalone deployment — structurally it mirrors improvise-ws-server but swaps tokio + tungstenite + std::fs for wasm + postMessage + OPFS. Both ws-server and worker-server reuse the same projection layer (improvise-cqq); only the transport and persistence backend differ.","design":"crates/improvise-worker-server/. Depends on improvise-core, improvise-formula, improvise-command, improvise-io (persistence half), improvise-protocol, and the OPFS Storage implementation from improvise-i34. wasm-bindgen entry points: init(opts) initializes App and VFS-backed Storage, loads an initial file from OPFS if present; on_message(serialized_command) deserializes the Command, feeds it through the App + projection layer, posts any outbound projection commands back via self.postMessage(). OPFS is async; handle by returning JsPromise from on_message or by queuing outbound messages and letting the event loop deliver them. Projection layer (improvise-cqq) is reused wholesale — transport-agnostic, already tracks per-session viewport state. The only new code specific to this issue is (a) wasm-bindgen bootstrap, (b) MessageChannel binding via self.onmessage / self.postMessage, (c) wiring VFS/OPFS storage into the persistence layer. Worker choice: Dedicated Worker first. Service Worker later if PWA install is desired.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Worker bundle runs the full App end-to-end: loads file from OPFS, processes commands, persists changes, emits projections. (3) Size budget: under 3 MB compressed. (4) Driven by a test harness that simulates main-thread postMessage traffic.","notes":"Companion to improvise-djm — two halves of the standalone deployment. Depends on improvise-cqq (projection layer), improvise-cqi (protocol), improvise-ywd (wasm compat), improvise-6mq (VFS abstraction), improvise-i34 (OPFS impl). Effectively a wasm-flavored analogue of improvise-q08 (ws-server); both wrap the same projection layer with a different transport + persistence.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:29Z","created_by":"spot","updated_at":"2026-07-30T23:06:32Z","dependencies":[{"issue_id":"improvise-9x6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:40:11Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-xf1","type":"blocks","created_at":"2026-08-31T23:11:01Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-bck","title":"Standalone web MVP: end-to-end offline demo","description":"Final milestone of the standalone epic. Open the deployed static site (or run it locally from file://), load a bundled demo .improv file, edit cells, add a formula, save to OPFS, reload the page, verify persistence. No server process running anywhere. This is the architectural payoff — improvise running entirely in the browser.","design":"Architecture under test: main-thread wasm-client (thin client), dedicated worker hosting worker-server bundle, MessageChannel transport between them, OPFS persistence via the VFS abstraction. Test plan: (1) Visit the deployed GH Pages URL or run the shell locally. (2) Bundled demo file loads automatically via the worker on init. (3) Navigate with arrow keys — view effects dispatched locally in the thin-client wasm, no postMessage round-trip. (4) Enter edit mode, type a number, commit — command flows to worker via MessageChannel, worker updates model + persists to OPFS, projection command flows back. (5) Add a formula — same path, plus formula recompute in worker. (6) Trigger save (explicit save to user-picked path via File System Access API, or implicit autosave to OPFS). (7) Close the tab, reopen the URL, verify edits persisted. (8) Upload a different .improv file via file picker, verify it opens. Performance: cursor moves under 16ms (local, no worker hop); edit commits under 100ms (worker hop + OPFS write).","acceptance_criteria":"(1) All test plan steps pass in Chrome and Firefox. (2) Page load to interactive under 3 seconds on a typical connection. (3) Wasm bundle under 3 MB compressed. (4) Screencast or screenshot series documenting the demo attached to the issue. (5) README updated with link to live demo.","notes":"Terminal node of standalone epic (improvise-tm6). Depends on improvise-djm (main-thread), the worker-server issue, improvise-d31 (static shell + deploy), and all upstream prerequisites transitively.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:38Z","created_by":"spot","updated_at":"2026-07-30T23:06:32Z","dependencies":[{"issue_id":"improvise-bck","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:17Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-d31","title":"Static HTML shell + GitHub Pages deploy workflow","description":"HTML/JS shell that hosts the standalone wasm deployment. Registers the worker-server (dedicated worker for MVP, service worker as a later PWA enhancement), instantiates the MessageChannel transport, loads the main-thread wasm-client, wires them together, and presents the DOM renderer. Also publishes the whole bundle to GitHub Pages via a build + deploy workflow so anyone can open improvise in a browser with no install.","design":"docs/ or web/ directory with index.html (#app div), main.js (main-thread wasm loader + worker registration + MessageChannel setup + keyboard event wiring), worker.js (loads the worker-server wasm bundle and handles postMessage), style.css. No websocket code — transport is local MessageChannel only. GitHub Actions: on push to main, build both wasm bundles (main-thread and worker) with cargo + wasm-bindgen, run wasm-opt for size, assemble into a static site, push to gh-pages branch. Path-filtered to skip if neither bundle changed. Worker registration: start with Dedicated Worker (simpler lifecycle, same-tab scope) until PWA install requirements justify Service Worker complexity.","acceptance_criteria":"(1) Static site loads improvise in a modern browser with no backend. (2) GitHub Actions workflow builds and deploys on every main push. (3) Deployed URL is listed in the README. (4) Demo .improv file is bundled so first-time visitors see something, not an empty grid.","notes":"Depends on improvise-djm (main-thread entry point) and the worker-server issue. Mostly configuration and glue — heavy lifting lives in the upstream wasm crates.\nPRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-tm6 improvise-bck ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:36Z","created_by":"spot","updated_at":"2026-08-30T17:07:40Z","dependencies":[{"issue_id":"improvise-d31","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-djm","title":"Standalone main-thread bundle: wasm-client + worker transport","description":"Main-thread wasm entry point for the static-web standalone deployment. Hosts the existing thin-client wasm (improvise-wasm-client) inside the browser tab, registers a worker (dedicated or service) that hosts the worker-server bundle, and wires a MessageChannel between them carrying Command messages in exactly the same shape the websocket carries in the thin-client-over-network deployment. The main-thread code does not know whether its peer is a remote server or a local worker — same protocol, different transport. Result: standalone deployment is just the thin-client architecture with a local transport.","design":"Two wasm bundles loaded by different parts of the browser runtime. (1) Main-thread bundle = improvise-wasm-client, unchanged from the network deployment. ViewState, reduce_view, keymap, DOM renderer. Small (~300 KB). (2) Worker bundle = worker-server, a separate issue. Full App + command pipeline + projection layer + VFS persistence. Heavy (~1-3 MB). Transport: MessageChannel with serde-json (or bincode) over postMessage. Main-thread Rust code is already transport-agnostic because on_message accepts serialized commands regardless of source and outgoing commands are returned to JS — the transport abstraction lives at the JS boundary. This issue is the main-thread entry point: JS bootstrap that instantiates wasm-client, spawns the worker, constructs the MessageChannel, routes messages in both directions. Worker type: start with Dedicated Worker (new Worker('worker.js')) for cleaner lifecycles and confirmed OPFS access; explore Service Worker later for PWA/offline-install scenarios.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 3 MB compressed (stretch: under 2 MB). (3) Initializes with a bundled demo .improv file on first load. (4) All core interactions work: cursor move, cell edit, formula entry, save, reload. (5) Can be driven end-to-end from a small JS harness without any server process running.","notes":"Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:23Z","created_by":"spot","updated_at":"2026-07-30T23:06:32Z","dependencies":[{"issue_id":"improvise-djm","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:14Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-i34","title":"OPFS-backed Storage implementation for wasm target","description":"Implement the Storage trait (or vfs backend) against the browser's Origin Private File System. Provides read/write/list operations from wasm that work across Chrome, Firefox, and Safari. Falls back to an in-memory or IndexedDB-backed implementation if OPFS is unavailable in the target environment.","design":"Use web-sys to access navigator.storage.getDirectory() and the File System Access API. OPFS is async-only, so either (a) make the Storage trait async-compatible (preferred if we switch to an async trait in the abstraction issue) or (b) wrap OPFS calls in a queued executor so the sync trait API can block on completion via a yield loop. Option (a) is cleaner; the abstraction issue (improvise-6mq) should probably commit to async up front if OPFS is the target. Directory layout: one root directory under OPFS for improvise, with subdirectories per purpose (autosave, user files, session state). File picker integration: when the user says 'open a file' in the browser, use the native file picker and either copy the file into OPFS or open it directly via the File System Access API's handle-based API. Fallback: if OPFS is unavailable (older Safari, some privacy modes), provide an IndexedDB-backed implementation using the idb crate — exposes the same trait.","acceptance_criteria":"(1) Storage implementation for wasm target exists, keyed off a compile-time target check. (2) Round-trips: write bytes, read bytes, list files, delete file. (3) Manual test: load a .improv file via file picker, edit in the DOM renderer, save to OPFS, reload page, file is still there. (4) Unit tests using a memory-backed stub where OPFS is unavailable (CI).","notes":"Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this.\nUnblocked by improvise-6mq (Storage trait landed 2026-08-29: crates/improvise-io/src/storage.rs, two methods read/write on opaque document paths, PhysicalFs + MemoryFs). NOTE: still not startable in the current dev environment — no wasm32-unknown-unknown std in the Nix toolchain and no rustup. See improvise-ywd notes.\nToolchain unblocked 2026-08-30: wasm32-unknown-unknown added to the flake (improvise-ywd), and all four bundle crates check clean. See improvise-fuj first — the two effects that still call std::fs directly need a decision on how a wasm build injects its Storage backend, which is the same decision this ticket needs.\nSYNC-VS-ASYNC RESOLVED (2026-08-30) — the abstraction does NOT need to become async, so improvise-6mq stands as built.\n\nThe DESIGN here says 'OPFS is async-only' and the NOTES say this 'may reveal a need to change the abstraction from sync to async, in which case update 6mq before starting'. That premise is incomplete. OPFS exposes two APIs:\n\n - the promise-based one (getDirectory, getFileHandle, createWritable) — async\n - FileSystemFileHandle.createSyncAccessHandle() -\u003e FileSystemSyncAccessHandle,\n whose read/write/truncate/flush/close are SYNCHRONOUS, and which is available\n ONLY inside a dedicated Web Worker. It exists precisely so wasm can do\n blocking file I/O; it is the mechanism SQLite's WASM build uses for its OPFS\n VFS.\n\nimprovise-9x6 puts 'the full App + command pipeline + projection layer + VFS persistence inside a browser worker'. So the persistence layer never runs on the main thread, and the sync path is available to it.\n\nShape that falls out:\n - ASYNC, once, at worker init(): navigator.storage.getDirectory(), getFileHandle(), createSyncAccessHandle().\n - SYNC, thereafter: the Storage impl holds the access handles; read/write are ordinary blocking calls, satisfying the existing two-method trait unchanged.\n\nThis avoids an invasive async migration through persistence::save/load and every caller, which is the outcome 6mq's Option-B choice was already hedging toward.\n\nCAVEAT: asserted from API knowledge, not tested — there is no browser here. Confirm createSyncAccessHandle availability against the target browsers (it is the weakest on Safari) before relying on it. The IndexedDB fallback this ticket already calls for is the hedge, and IndexedDB IS async-only — so the fallback, not OPFS, is what would force the async question. Decide whether the fallback is in scope before starting.\n\nPREREQUISITE: improvise-fuj — how a wasm build injects its Storage instance. App holds no Storage today; persistence::save/load hardwire PhysicalFs, and two effects bypass the trait entirely. That plumbing is host-verifiable and should land first.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:08Z","created_by":"spot","updated_at":"2026-09-01T06:11:09Z","started_at":"2026-09-01T06:06:19Z","closed_at":"2026-09-01T06:11:09Z","close_reason":"Storage gained list/delete (PhysicalFs + MemoryFs, shared storage_contract test). OpfsFs added for wasm32 using sync access handles, keeping the trait synchronous — createSyncAccessHandle's read/write/truncate/flush/close are blocking, worker-only, which suits improvise-9x6. Async work is four explicit methods: open/attach/refresh/commit_deletes. delete is deferred (index updated immediately, removeEntry on commit). No IndexedDB fallback per user decision — verified Safari 16.4+ has sync handle methods, so it isn't needed. AC(1)(2)(4) met; AC(3) is a browser test that cannot run here, tracked as improvise-xf1 and blocking improvise-9x6.","dependencies":[{"issue_id":"improvise-i34","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-i34","depends_on_id":"improvise-fuj","type":"blocks","created_at":"2026-08-30T09:57:13Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ywd","title":"Wasm compatibility audit and fixes across core/formula/command/io","description":"Verify every crate that belongs in the wasm bundle builds for wasm32-unknown-unknown and runs correctly. Fix anything that doesn't: feature-gate wall-clock time, swap Instant for web_time, enable chrono wasmbind, isolate dirs crate usage, verify pest/serde/indexmap/flate2/enum_dispatch all compile. Ship a cargo-check invocation per crate as a CI gate.","design":"Systematic audit: for each crate (improvise-core, improvise-formula, improvise-command, improvise-io-persistence), run cargo check --target wasm32-unknown-unknown and fix what breaks. Known hazards: (1) dirs crate — not wasm-compatible, isolate behind a trait function. (2) chrono::Local::now() — needs wasmbind feature, used in import analyzer for date parsing. (3) std::time::Instant — swap for web_time crate which falls back to js performance.now() in wasm. (4) std::fs in persistence — handled by the VFS abstraction issue. (5) std::env and std::process — probably none in core, verify. (6) flate2 — verify rust_backend feature works in wasm (should). Strategy: don't feature-gate everything individually, instead do the minimal isolation needed so each crate's public API is portable, and let the downstream bundle crate (improvise-web-standalone) opt in to what it actually needs.","acceptance_criteria":"(1) cargo check --target wasm32-unknown-unknown -p improvise-core passes. (2) Same for improvise-formula, improvise-command, and improvise-io (or improvise-persistence if split). (3) No new feature flags required to get these to build — the core path is wasm-compatible by default. (4) CI job added that runs the wasm checks on every push.","notes":"Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel.\nTOOLCHAIN-BLOCKED in this dev environment (checked 2026-08-29). The deliverable is 'cargo check --target wasm32-unknown-unknown' per crate plus a CI gate, but the Nix toolchain here ships no wasm32 std and there is no rustup to add it:\n\n $ cargo check -p improvise-core --target wasm32-unknown-unknown\n error[E0463]: can't find crate for 'std'\n note: the wasm32-unknown-unknown target may not be installed\n $ which rustup -\u003e not found\n\nSource changes could be written speculatively (Instant -\u003e web_time, chrono wasmbind, isolating dirs) but none could be VERIFIED, which is the whole point of an audit. Adding wasm32-unknown-unknown to the flake's rust toolchain is the actual first step of this ticket.\n\nSame constraint hits improvise-cqi acceptance criterion 5 ('builds for both host target and wasm32-unknown-unknown').","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:49Z","created_by":"spot","updated_at":"2026-08-30T16:51:24Z","started_at":"2026-08-30T16:47:28Z","closed_at":"2026-08-30T16:51:24Z","close_reason":"AC 1-3 turned out to need NO source changes; the blocker was the toolchain, exactly as the notes I left on 2026-08-29 predicted.\n\nAdded targets = [\"wasm32-unknown-unknown\"] to the flake's rustToolchain (flake.nix:26) and verified wasm32 std lands in the sysroot. Then all four bundle crates check clean, first try, with no feature flags:\n\n improvise-core ok\n improvise-formula ok\n improvise-io ok\n improvise-command ok\n\nThat satisfies AC 1, AC 2 and AC 3 (AC 3 explicitly: 'no new feature flags required — the core path is wasm-compatible by default'). The root improvise crate does NOT build for wasm because crossterm does not; that is correct — it is the terminal binary, not part of the bundle — so the CI job checks the four bundle crates by name rather than the workspace.\n\nAC 4: .github/workflows/ci.yml adds a wasm job plus a host job (fmt, clippy -D warnings, test). There was no test/lint CI before this — only cargo-dist's release.yml and the Claude bots. Verified clippy -D warnings and fmt pass locally so the job is not red on arrival, and validated the workflow YAML parses.\n\nTHE AUDIT'S REAL VALUE WAS THE HAZARD LIST, because a cargo check gate cannot catch anything that merely panics at runtime. Going through the DESIGN section's named hazards one by one:\n\n (1) dirs NON-ISSUE — declared at root Cargo.toml:36, used nowhere (already noted on improvise-v9w)\n (2) chrono Local::now NON-ISSUE — never called\n (3) std::time::Instant REAL — App::last_autosave; Instant::now() panics on wasm32-unknown-unknown yet compiles fine. FIXED: improvise-command now uses web-time, which re-exports std::time off-wasm and performance.now() on it. Verified both targets build and all 14 native suites pass.\n (4) std::fs REAL, NOT FIXED — StartImportWizard (effect.rs:931) and LoadModel (effect.rs:1052) still call std::fs::read_to_string directly, bypassing the Storage trait from improvise-6mq. Compiles on wasm, fails in a browser. Filed as improvise-fuj, because the fix needs a decision on how a wasm build injects its Storage backend — which also determines how improvise-i34 plugs in, so it should not be guessed at here.\n (5) std::env/process NON-ISSUE — std::env appears only in a test\n (6) flate2 NON-ISSUE — compiles for wasm as-is\n\nCaveat worth stating plainly: 'compiles for wasm32' is all that has been verified. Nothing here has been RUN on a wasm host — there is no browser or wasm runtime in this environment — so the web-time fix is verified by dual-target compilation and unchanged native behaviour, not by execution. Actual runtime verification arrives with improvise-i34 / improvise-tm6.","dependencies":[{"issue_id":"improvise-ywd","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-14T00:40:09Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-6mq","title":"Introduce Storage/VFS abstraction in persistence layer","description":"Refactor persistence/mod.rs to go through a Storage trait instead of std::fs directly. Native build wires PhysicalFS; later issues wire OPFS for wasm. Behavior-preserving refactor — .improv files still save and load identically on native. Foundation for the standalone web deployment and for alternative formats (sqlite, parquet) that can target any backend.","design":"Option A: adopt the vfs crate directly (vfs::VfsPath, vfs::PhysicalFS). Pros: batteries included, filesystem-path-based API, open-source implementations. Cons: sync-only; wasm backends may need async. Option B: define our own narrow Storage trait with read_bytes/write_bytes/list/stat methods, sync for native and async-wrapped for wasm. Pros: tailored to our needs. Cons: more code to maintain. Recommend starting with Option A (vfs crate) and falling back to Option B if OPFS's async-only nature forces the issue. Refactor persistence::{save_md, load_md, save_json, load_json} to take \u0026dyn Storage (or VfsPath) as the target parameter. Autosave path resolution (currently uses the dirs crate) goes behind the abstraction too — native resolves to a dirs path, wasm resolves to a fixed OPFS path.","acceptance_criteria":"(1) Persistence functions take a storage parameter rather than reading/writing files directly. (2) Native build wires PhysicalFS (or equivalent) and all existing persistence tests pass unchanged. (3) dirs crate usage is isolated behind a function that can be stubbed for wasm. (4) Round-trip tests still pass. (5) A memory-backed test fixture exists for unit testing persistence without touching disk.","notes":"Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:47Z","created_by":"spot","updated_at":"2026-08-29T19:40:58Z","started_at":"2026-08-29T19:37:37Z","closed_at":"2026-08-29T19:40:58Z","close_reason":"persistence::save/load now go through a Storage trait; save/load remain thin PhysicalFs wrappers so every caller and all 83 persistence tests are untouched (AC 2).\n\nCHOSE OPTION B OVER THE RECOMMENDED OPTION A. The design section recommends starting with the vfs crate. I measured the actual surface first: persistence touches the filesystem in exactly two places — write the whole document in save, read the whole document in load. autosave_path is pure path manipulation with no I/O. That does not justify a dependency plus a VfsPath abstraction. The ticket's own con for Option A is decisive: it is sync-only while OPFS, the actual wasm target, is async — adopting A would mean doing B later anyway. The trait is two methods:\n\n fn read(\u0026self, path: \u0026Path) -\u003e Result\u003cVec\u003cu8\u003e\u003e\n fn write(\u0026self, path: \u0026Path, bytes: \u0026[u8]) -\u003e Result\u003c()\u003e\n\nPaths are opaque document names, not a directory tree, so an OPFS or map-backed backend implements it without pretending to have directories, metadata or seeking.\n\nAC 3 IS VACUOUS: 'dirs crate usage is isolated behind a function that can be stubbed for wasm' — there is no dirs usage. dirs = \"6\" is declared at root Cargo.toml:36 and referenced nowhere under src/ or crates/*/src/. It is simply an unused dependency; noted on improvise-v9w, which already covers removing unused root deps.\n\nGzip moved from streaming File+GzEncoder to encode-then-write. The .gz suffix still selects it, which makes gzip a property of the document name rather than of the backend. physical_and_memory_backends_write_identical_bytes pins that swapping backends does not change the bytes.\n\nAC 5 (memory-backed fixture): MemoryFs, plus three persistence tests that round-trip a workbook with categories, items and data through it — no tempfile, no disk. That is the shape a browser backend plugs into.\n\nVerified beyond unit tests: import to .improv, headless add-cat against it, and the .gz path all still work through the CLI.","dependencies":[{"issue_id":"improvise-6mq","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-14T00:40:08Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-tm6","title":"Epic: Standalone static-web deployment via wasm + VFS storage","description":"Enable improvise to run entirely in the browser with no backend, suitable for GitHub Pages or any static host. Full App compiles to wasm; ratatui is stripped (automatically, by not depending on improvise-tui); persistence goes through a VFS/Storage abstraction backed by OPFS (or IndexedDB fallback) in the browser and by PhysicalFS on native. Shares the DOM renderer and Command protocol with the thin-client epic (improvise-6jk) — same code, different transport (in-process channel instead of websocket).","design":"Three deployment modes share code: (1) native TUI = today, (2) thin client over websocket = epic 6jk, (3) static web standalone = this epic. Mode 3 bundles the full App (improvise-core + improvise-formula + improvise-command + improvise-io's persistence half) into wasm. Same Command protocol and DOM renderer as mode 2; transport is an in-process command bus. Persistence abstracted behind a Storage trait (or the vfs crate) with PhysicalFS backing for native and OPFS backing for wasm. This makes sqlite/parquet formats a later orthogonal win — VFS handles bytes→medium, new format modules handle data→bytes. DOM renderer still reads ViewState + render cache even in standalone mode; an in-process projection layer updates the cache as App state changes. Ratatui falls out for free once crate-split epic completes: wasm build simply doesn't depend on improvise-tui.","notes":"Depends on crate-split epic's steps 3 (improvise-io) and 5 (improvise-command) being complete — both become critical-path under this epic. Shares protocol crate (improvise-cqi) and DOM renderer (improvise-cr3) with thin-client epic. Bundle size estimate: 1-3 MB compressed; biggest contributors are pest (formula parser) and chrono. Optimization (wee_alloc, wasm-opt, pest error trimming) is follow-on.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:45Z","created_by":"spot","updated_at":"2026-07-30T23:06:32Z","dependencies":[{"issue_id":"improvise-tm6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:28Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:31Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-bck","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:32Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:30Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:29Z","created_by":"spot","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-rbv","title":"Include _Measure in _Dim columns for records mode","description":"In records mode, _Dim columns are generated from regular (non-virtual) categories plus a synthetic 'Value' column. _Measure is excluded because it starts with '_'. This means records show a generic 'Value' column instead of showing which measure (Revenue, Cost, etc.) each record belongs to. _Measure items should appear as _Dim columns in records mode so the measure name is visible per-record.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:30:40Z","created_by":"spot","updated_at":"2026-04-15T11:16:12Z","closed_at":"2026-04-15T11:16:12Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-dz8","title":"Add Axis::Filter for drill-fixed coordinates","description":"Drill mode sets fixed coordinates (e.g. Product=Widgets, Region=North) as Axis::Page, but these should be immutable constraints, not user-navigable page dimensions. Add Axis::Filter variant that: (1) excludes categories from tile bar in tile select mode, (2) excludes from page cycling via [ and ], (3) visually distinguished from Page in the UI. Drill uses Filter instead of Page for its fixed coordinates.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:30:04Z","created_by":"spot","updated_at":"2026-09-01T05:45:01Z","started_at":"2026-09-01T05:38:46Z","closed_at":"2026-09-01T05:45:01Z","close_reason":"Axis::Filter added. DrillIntoCell fixes coordinates on Filter instead of Page. All three ticket requirements met: excluded from tile select (View::tileable_cats, used by tile_bar + MoveTileCursor + TileAxisOp), excluded from [/] page cycling (page_cat_data stays on categories_on(Axis::Page)), and visually distinct (Fix ⚲, yellow, via Axis::color -\u003e AxisColor::Filter). Data filtering preserved via View::filtering_cats (Page ∪ Filter), used by GridLayout and add-record-row. Axis::next maps Filter to itself so nothing can rotate into it. .improv grammar gains a 'filter' keyword with round-trip test — defensive, since Filter only occurs in the never-persisted _Drill view. Note: adding the variant caused exactly ONE compile error outside axis.rs, because improvise-rml had already consolidated the display matches.","dependencies":[{"issue_id":"improvise-dz8","depends_on_id":"improvise-rml","type":"blocks","created_at":"2026-04-16T16:02:39Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-1ey","title":"Browser frontend MVP: end-to-end working demo","description":"Wire everything together into a working browser demo: start the ws-server, open the HTML shell in a browser, it loads the wasm client, connects to the websocket, receives the initial snapshot, renders the grid, accepts keystrokes, and round-trips commands to the server. This is the milestone where the architecture proves itself end-to-end. Scope: open an existing .improv file server-side, view it in the browser, type a number into a cell, see the server-side value update and the browser's projection arrive with the new value.","design":"HTML shell: minimal index.html with a \u003cdiv\u003e for the grid and a \u003cscript\u003e tag bootstrapping the wasm module. JS bootstrap: loads wasm, opens websocket connection, wires on_message from the socket into wasm.on_message, wires wasm.on_key_event output into the socket send. Reconnection: on socket close, show a status indicator; on reconnect, request a fresh snapshot. No auth, no TLS, localhost only. Test by pointing at bank-info.improv or any existing test file.","acceptance_criteria":"(1) ws-server serves a file from CLI arg, accepts a websocket connection. (2) Browser loads page, wasm initializes, grid renders. (3) Arrow keys move the cursor locally (view effect) with no round-trip. (4) Typing a number + Enter in a cell round-trips to the server and the new value appears. (5) Scrolling fetches cells for the new viewport. (6) Reload recovers cleanly from current server state. (7) Video or screenshot of the demo attached to the issue.","notes":"Final milestone of the browser epic. Depends on projection layer (improvise-cqq), ws-server (improvise-q08), wasm client (wasm-client id), DOM renderer (dom-renderer id). No new logic — just glue and testing.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:44Z","created_by":"spot","updated_at":"2026-07-30T23:06:32Z","dependencies":[{"issue_id":"improvise-1ey","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-08-30T10:08:33Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-cr3","title":"DOM renderer for browser grid (reads ViewState + render cache)","description":"JS/TS (or Rust via web-sys) layer that subscribes to the wasm client's state and renders the grid to the DOM. MVP: rebuild a \u003ctable\u003e element on each state change, no virtual-DOM diffing. Reads ViewState for mode indicator, cursor highlight, minibuffer text; reads render cache for cell contents, labels, column widths. Captures browser keyboard events and routes them into the wasm client's on_key_event.","design":"Option A: pure JS/TS module that reads wasm-exposed state via JsValue and updates DOM imperatively. Simpler for MVP. Option B: Rust + web-sys in the wasm crate, rendering from inside wasm. More code sharing but bigger bundle. Start with Option A. Renderer: single \u003ctable\u003e for the grid body, \u003cthead\u003e for column labels, \u003ctbody\u003e with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected \u003ctd\u003e. Mode indicator is a \u003cdiv\u003e above the table. Minibuffer is a \u003cdiv\u003e shown conditionally when mode is Editing/FormulaEdit/etc.","acceptance_criteria":"(1) Grid renders from a ViewState + RenderCache snapshot. (2) Cursor highlight updates on cursor move without full re-render (nice to have, not required for MVP). (3) Mode indicator reflects current AppMode. (4) Keyboard events on document are captured and routed to wasm on_key_event. (5) Works in Chrome and Firefox. Help overlay, panels, import wizard, tile bar — all deferred post-MVP.","notes":"Consumes viewmodels (not the render cache directly) per improvise-edp. Specifically GridViewModel for grid rendering. Shares GridViewModel type and compute_grid_viewmodel function with ratatui's grid widget (improvise-n10) — one derivation, two rendering backends. DOM-specific concerns (device pixel ratio, CSS class names) live in the RenderEnv the viewmodel is computed against, not in the viewmodel itself.\nPRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-1ey improvise-6jk improvise-djm ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:41Z","created_by":"spot","updated_at":"2026-08-30T17:07:39Z","dependencies":[{"issue_id":"improvise-cr3","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cr3","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:42Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gsw","title":"improvise-wasm-client crate (keymap + reduce_view in wasm)","description":"Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream.","design":"crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -\u003e Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -\u003e Option\u003cString\u003e (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -\u003e JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5).","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket.","notes":"Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:37Z","created_by":"spot","updated_at":"2026-09-19T23:40:23Z","closed_at":"2026-09-19T23:40:23Z","close_reason":"crates/improvise-wasm-client landed complete, not a skeleton. 28 tests; cargo check -p improvise-wasm-client --target wasm32-unknown-unknown clean.\n\nTHE TICKET AC 3 IS A FALSE PREMISE and the design turns on it: a browser client resolves almost NO keys locally. A binding names an INTENT resolved against a CmdContext (Model, GridLayout, cursor, yanked cell) while a Command is the RESOLVED MUTATION -- the same reason improvise-gxi AC 4 was closed. So keys travel upstream UNRESOLVED as ClientMessage::Key { code, modifiers }.\n\nTHE ONE LOCAL PATH is minibuffer text entry, allowed for a STRUCTURAL reason rather than convenience: the target buffer is AppMode::minibuffer().buffer_key, the same field TextEntry::new reads (gotcha 11), so the two rules are DERIVED FROM ONE FACT rather than duplicated. Four things were considered and rejected with reasons in the module docs: cursor movement (GridCache is viewport-indexed, so a client cannot tell last-visible-row from last-row and cannot clamp), panel cursors (the flattening depends on expanded_cats), any modified character (KeyPattern::AnyChar matches under ANY modifier on the host, so mirroring the fallback would be a SUPERSET -- local resolution may only ever be a strict subset), and CommandMode Backspace (the single TextEntry::rebind, which leaves the mode on an empty buffer).\n\nimprovise-command is a DEV-dependency only, so the browser graph is formula -\u003e core -\u003e protocol -\u003e client: no App, Workbook, persistence or wizard. tests/lockstep.rs drives the real host and the client over the same keypress across all seven text-entry modes.\n\nAC 2 (under 500KB compressed) UNVERIFIED -- no wasm-pack/wasm-opt available; the dependency graph is the strongest evidence here and someone should measure a real release build. AC 5 (JS harness) not written, and belongs with improvise-cr3.","dependencies":[{"issue_id":"improvise-gsw","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-14T00:25:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-gsw","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:41Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-q08","title":"improvise-ws-server binary (tokio + tungstenite session wrapper)","description":"New binary crate that wraps today's App behind a websocket. Accepts connections, creates a ClientSession per connection, receives Command messages from clients, dispatches them into the App, drains the outbound projection queue, sends projection commands back down. One App instance per session for the MVP (no shared authoritative state across sessions yet).","design":"crates/improvise-ws-server/. Tokio runtime, tokio-tungstenite for websocket. On connect: create App + ClientSession, send initial Snapshot (full ViewState + render cache filled from current viewport). Main loop: recv Command from client → feed into reduce_full (today's App::handle_key path, but command-keyed instead of key-keyed) → drain projection queue → send Commands down. On disconnect: drop session. Single session per connection for MVP. No auth, no collab, localhost only. Wire format: serde_json to start (easy to debug), bincode/postcard later if size matters.","acceptance_criteria":"(1) Binary crate builds and accepts websocket connections on a configurable port. (2) Initial snapshot is sent on connect. (3) Command round-trip works: client Command in → model updated → projection Command out. (4) Disconnection cleanly drops session state. (5) Integration test: spawn server, connect a test client, send a SetCell, verify a CellsUpdated comes back.","notes":"Depends on improvise-protocol (wire types) and improvise-mae/vb4/projection-emission for the server-side logic. The server itself is a thin transport wrapper — all the real logic lives in the App and projection layer.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:14Z","created_by":"spot","updated_at":"2026-09-19T23:46:08Z","closed_at":"2026-09-19T23:46:08Z","close_reason":"crates/improvise-ws-server landed whole; all five ACs met and tested. 14 tests (13 in host.rs + 1 real-socket integration). Host::handle is where every decision is made and every unit test points; transport.rs is framing, JSON and one handshake rule, with a doc comment saying that if the socket test ever becomes the place a behaviour is pinned, that behaviour belongs in host.rs.\n\nONE APP PER DOCUMENT, as ruled. Not per connection (the ticket premise, now false -- Sessions exists to hold many ClientSessions against one App, and per-connection Apps would make broadcast, apply_switch and apply_model dead code and give two tabs on one document two documents) and not per server. No singleton: Host is a value the transport is handed. The multi-document registry slots into transport.rs where host_loop is -- a HashMap\u003cDocumentId, Host\u003e plus a document id on Connect -- and nothing above it changes, because Sessions never owned the App.\n\nTHE ONLY ADDITION TO THE SHARED VOCABULARY IS ClientMsg::Viewport { rows, cols }, and it is derived rather than invented: the extent is the one session input no Command carries by construction. It is also the HANDSHAKE -- the bootstrap Snapshot seed carries the VISIBLE cells, so a snapshot built at extent zero is an empty grid needing an immediate second round trip. That rule lives in transport::connection alone, which keeps Host::connect total.\n\nNO KEYPRESS DOOR: the retraction arrived before one was built, so nothing had to be removed. ClientMsg carries Viewport and Command only.\n\nEvery non-obvious behaviour proven by injection, including both arms of the broadcast (relay to nobody FAILED; relay to everyone FAILED). The document-swap fixture keeps the incoming active view named Default per gotcha 39 fixture warning, so it cannot pass by accident.","dependencies":[{"issue_id":"improvise-q08","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-q08","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-cqq","title":"Server-side projection emission layer + per-client viewport tracking","description":"After each command is applied on the server, walk the resulting effect list, identify model-touching effects, compute which connected clients have the affected cells in their viewport, and enqueue projection commands (CellsUpdated, ColumnLabelsChanged, etc.) for dispatch to those clients. Server must track per-client viewport state (visible row/col range) and mirror each client's ViewState for this to work.","design":"Server holds HashMap\u003cSessionId, ClientSession\u003e where ClientSession { view: ViewState, visible_region: (row_range, col_range) }. When a client dispatches a command, update its session's view state via the shared reduce_view. When the command affects Model, walk the effect list: for each ModelTouching effect, compute which cells changed, intersect with each client session's visible_region, emit CellsUpdated commands into that session's outbound queue. ScrollTo commands update visible_region and trigger a fresh CellsUpdated for the newly-visible region. Viewport intersection logic: a cell is visible if its row/col position (computed from layout) lies within the client's visible range.","acceptance_criteria":"(1) Server struct ClientSession tracks view state and visible region per connected client. (2) After effect application, a projection step computes Vec\u003c(SessionId, Command)\u003e to dispatch. (3) ScrollTo updates visible region and triggers a fresh render-cache fill. (4) Unit tests: dispatching a SetCell produces CellsUpdated only for clients whose viewport includes the cell. (5) Unit tests: ScrollTo produces a CellsUpdated covering the new visible region.","notes":"Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.\n\n--- IMPLEMENTATION LANDED 2026-09-19; left OPEN because it is blocked by improvise-g0f ---\nAll five ACs are met in crates/improvise-command/src/ui/project.rs (861 lines,\n9 tests). Not closed: bd reports this blocked by g0f (may two clients view\ndifferent views?) and gxi (ACs 4-5, itself blocked). g0f is a user decision and\nnow has teeth -- the per-client ClientSession makes divergence expressible.\n\nDesign, for whoever picks this up:\n* The producer decides what changed by DIFF, not by asking effects. It rebuilds\n each widget's desired state from the App and diffs against a per-client\n mirror. Making every effect report its touched cells would be a second\n description of the same mutation, free to drift from the first.\n* EffectKind is the GATE, not the answer: Dirty::of(kind) is an exhaustive\n match, so a new ModelChange variant fails the build. Over-reporting costs\n work; under-reporting is the bug.\n* Only the grid keeps a mirror -- every other cache replaces wholesale, so\n Dirty is that widget's whole decision. That also splits the two APIs:\n document-scoped projections come from reduce_full, the viewport-dependent\n grid from Sessions::project_all.\n* grid_updates emits RowLabels/ColLabels/ColWidths BEFORE Cells and diffs the\n cells against fold(mirror, label_updates) rather than against mirror, so\n emission is exact through reshapes and never needs Invalidated.\n* Viewport extent is host-reported (ClientSession::set_extent), not inferred:\n offsets already ride the command stream, but how many rows fit is a terminal\n fact no derived Command carries, and inventing a wire variant for it would\n break command_set_derived_not_invented.\n* Bootstrap shares the steady-state path: connect resets the mirror and\n projects Dirty::EVERYTHING, so a Snapshot seed is the diff-from-nothing.\nAC wording deviation: there is no ScrollTo in the derived Command set;\nSetRowOffset/SetColOffset do that job.\n15 tests, each proven to fire by injection; two injections that did NOT fire\nwere treated as test weaknesses and the tests rewritten rather than accepted.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:12Z","created_by":"spot","updated_at":"2026-09-19T23:29:48Z","closed_at":"2026-09-19T23:29:48Z","close_reason":"Implementation landed in wave 2 (crates/improvise-command/src/ui/project.rs), reviewed, three HIGH bugs fixed, merged to main. All five ACs met. Design recorded in this ticket and in gotchas 33/35/36/39 plus doctrine(lockstep_binds_every_session_not_the_asker). Blockers now all resolved: g0f decided by the user, mae and 8uz closed, gxi re-scoped and closed. Closing unblocks improvise-1ey.","dependencies":[{"issue_id":"improvise-cqq","depends_on_id":"improvise-8uz","type":"blocks","created_at":"2026-08-30T10:08:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqq","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqq","depends_on_id":"improvise-g0f","type":"blocks","created_at":"2026-09-01T02:02:48Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqq","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:45Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqq","depends_on_id":"improvise-mae","type":"blocks","created_at":"2026-04-14T00:25:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":5,"dependent_count":4,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-cqi","title":"Extract improvise-protocol crate (Command, ViewState, reduce_view)","description":"Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic.","design":"pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec\u003c(CellKey, CellDisplay)\u003e }, ColumnLabelsChanged { labels: Vec\u003cString\u003e }, ColumnWidthsChanged { widths: Vec\u003cu16\u003e }, RowLabelsChanged { labels: Vec\u003cString\u003e }, ViewportInvalidated, ... }. pub fn reduce_view(vs: \u0026mut ViewState, cache: \u0026mut RenderCache, cmd: \u0026Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation.","acceptance_criteria":"(1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown.","notes":"Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui).\nNote (2026-08-29): acceptance criterion 5 (builds for wasm32-unknown-unknown) is not verifiable in the current dev environment — no wasm32 std in the Nix toolchain and no rustup. See improvise-ywd notes. The rest of this ticket is host-verifiable.\nCORRECTION (2026-08-29): the DESIGN section's justification for ViewState serialization is wrong, and misleading in a way that would invert the architecture.\n\nIt says: 'Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state.' The server does NOT serialize ViewState to maintain the mirror. Per improvise-cqq, it updates each session's view state 'via the shared reduce_view' — i.e. by applying the same commands the client applies. ViewState is a PROJECTION of the command stream, not a wire message. improvise-ltq (commands both directions) and improvise-m91 ('a command log is a deterministic replay') say the same.\n\nViewState: Serialize is still needed, but for exactly one reason: improvise-q08's bootstrap — 'On connect: create App + ClientSession, send initial Snapshot (full ViewState + render cache filled from current viewport)'. A client that never saw the earlier stream needs a value to fold onto. Snapshot-then-stream, not state-on-every-change.\n\nConsequences for whoever picks this up:\n- AC 3 stands, but do not build a server that ships ViewState on change. Steady-state traffic is Commands only.\n- RenderCache is local, rebuilt by applying projection commands; it needs Serialize only to ride in that same bootstrap Snapshot.\n- Because the only serialization point is bootstrap, host-only fields must NOT be in ViewState: a fresh client should not inherit a half-open import wizard, a pending prefix key, or staged drill edits.\n\nBLOCKER FOUND (2026-08-29): ViewState as it stands cannot live in this crate at all. crates/improvise-command/src/ui/app.rs:381 holds transient_keymap: Option\u003cArc\u003cKeymap\u003e\u003e. Keymap is in improvise-command, which will depend on improvise-protocol (improvise-gxi puts reduce_full there, taking \u0026Command) — a hard dependency cycle, independent of serde. wizard: Option\u003cImportWizard\u003e is a softer version (improvise-io is acyclic but outside AC 2's allowlist); drill_state and AppMode simply lack derives.\n\nProposed fix for the hard one, per the maintainer: flip the Arc. The transient keymap is always the sub-keymap of a Binding::Prefix at a specific (mode, key, mods), set during dispatch and consumed on the next keypress. ViewState can hold that address — Option\u003c(KeyCode, KeyModifiers)\u003e — and let KeymapSet, which already owns every keymap, resolve it. Both consumers can: App::handle_key has keymap_set and the mode; draw.rs only needs binding_hints() for the which-key popup. Nothing between set and consume changes the mode, since a Prefix binding emits SetTransientKeymap and nothing else.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:10Z","created_by":"spot","updated_at":"2026-09-01T09:03:56Z","started_at":"2026-09-01T08:55:39Z","closed_at":"2026-09-01T09:03:56Z","close_reason":"crates/improvise-protocol created. AC(1) crate exists; AC(2) depends only on improvise-core + serde, verified by cargo tree (no ratatui/crossterm/tokio) and by the CI wasm job; AC(3) Command/ViewState/RenderCache/CellDisplay all Serialize+Deserialize with a per-variant JSON round-trip test; AC(4) reduce_view has 12 tests covering view application, cache updates, out-of-range projections, and determinism over a repeated stream; AC(5) builds for host and wasm32, added to the CI wasm job.\n\nCommand's client-\u003eserver half was derived, not invented: it is the set of ViewOnly effects targeting the portable slice, which improvise-h0b had already separated from host-local state.\n\nOne addition beyond a pure move: ViewState::active_view, because reduce_view runs on a client with no Workbook and cursors is keyed by view name. Two writers only (App::switch_to_view, App::install_workbook), guarded by the_two_active_view_names_never_diverge. Whether the two copies may diverge is improvise-g0f, which now blocks improvise-cqq.\n\nFollowed the 2026-08-29 correction on the ticket: ViewState is a projection of the command stream, serialized only for the bootstrap Snapshot. Nothing here ships ViewState on change.","dependencies":[{"issue_id":"improvise-cqi","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-14T00:25:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-a2x","type":"blocks","created_at":"2026-08-30T15:56:13Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-h0b","type":"blocks","created_at":"2026-08-29T20:48:44Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-jc8","type":"blocks","created_at":"2026-08-29T20:44:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:37Z","created_by":"spot","metadata":"{}"}],"dependency_count":5,"dependent_count":6,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-mae","title":"Tag existing effects as model / view / projection-emitting","description":"Add a classification to every Effect so the server can decide which effects trigger projection-command emission to connected clients. Pure view effects (cursor move, mode change) don't project. Model effects (SetCell, AddCategory, AddFormula) do project — they emit CellsUpdated or similar commands to clients whose viewport includes the change. Prerequisite for the server-side projection emission layer.","design":"Add a method to Effect like fn kind(\u0026self) -\u003e EffectKind where EffectKind is { ModelTouching, ViewOnly }. If Step 4 of crate epic (Effect→enum) has landed, this is a const match on variant. If not, each struct impls a kind() method. ModelTouching effects are further classified by what they change, so projection computation knows what kind of command to emit: { CellData, CategoryShape, ColumnLabels, RowLabels, FormulaResult, LayoutGeometry }.","acceptance_criteria":"(1) Every existing Effect has a kind classification. (2) A const/fn on Effect returns the kind without running apply. (3) Unit tests verify classification for each effect. (4) Audit notes list every effect and its kind.","notes":"Does not depend on Effect-as-enum refactor (crate epic step 4) but is much cleaner if that's landed. Depends on the AppState split so effects that cross slices are clearly cross-cutting.\nAUDIT (AC 4) — all 58 effects and their kinds, as of the closing commit:\n\nCellData (12): AddCategory, AddFormula, AddItem, AddItemInGroup, ApplyAndClearDrill, CleanEmptyRecords, ClearCell, RemoveCategory, RemoveFormula, RemoveItem, SetCell, SortData\n\nLayoutGeometry (14): CreateView, CycleAxis, DeleteView, HideItem, SetAxis, SetNumberFormat, SetPageSelection, ShowItem, SwitchView, ToggleGroup, TogglePruneEmpty, TransposeAxes, ViewBack, ViewForward\n\nWholeWorkbook (3): AcceptWizardModel, ImportJsonHeadless, LoadModel\n\nViewOnly (29): AbortChain, ChangeMode, CloseWizard, EnterEditAtCursor, ExportCsv, HelpPageNext, HelpPagePrev, HelpPageSet, MarkDirty, RebuildLayout, Save, SaveAs, SetBuffer, SetColOffset, SetDrillPendingEdit, SetPanelCursor, SetPanelOpen, SetRowOffset, SetSearchMode, SetSearchQuery, SetSelected, SetStatus, SetTileCatIdx, SetTransientKeymap, SetYanked, StartDrill, StartImportWizard, ToggleCatExpand, WizardApply\n\nNotes on the non-obvious ones:\n- SetSelected/SetRowOffset/SetColOffset are ViewOnly despite writing the shared, persisted View: cursor and scroll are per-client. Residual inconsistency filed as improvise-8uz.\n- Save/SaveAs/MarkDirty touch model_state (dirty, file_path) but change nothing renderable -\u003e ViewOnly.\n- ExportCsv reads the model and writes a file; no state change -\u003e ViewOnly.\n- RebuildLayout rebuilds a derived cache -\u003e ViewOnly.\n- SortData reorders the data store, which changes records-mode row identity -\u003e CellData.\n- ApplyAndClearDrill writes cells through free-function helpers -\u003e CellData.\n- SetTransientKeymap lives in command/keymap.rs, not effect.rs.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:22:02Z","created_by":"spot","updated_at":"2026-08-29T20:03:28Z","started_at":"2026-08-29T19:57:52Z","closed_at":"2026-08-29T20:03:28Z","close_reason":"All 58 effects classified. EffectKind::{ViewOnly, Model(ModelChange)} with ModelChange::{CellData, LayoutGeometry, WholeWorkbook}. Full audit table in the notes (AC 4).\n\nkind() HAS NO DEFAULT IMPL. The design section offered a per-struct method; making it required instead means a new effect cannot forget to classify itself. That matters because the failure mode is silent — a mis-declared ViewOnly edit would simply never reach connected clients. It paid off immediately: the compiler found a 58th Effect impl, SetTransientKeymap, living in command/keymap.rs rather than effect.rs, which I would have missed classifying by eye.\n\nDERIVED THE CLASSIFICATION, THEN VERIFIED IT. I extracted what each apply() body touches and reasoned about it in Prolog rather than eyeballing 58 effects. Two things that came out of it:\n\n1. The naive signal is WRONG for 21 effects. The View is stored inside model_state.workbook, so 'touches model_state' does not mean 'is a model change'. SetAxis, SetPageSelection, ToggleGroup, HideItem, ShowItem, TransposeAxes, CycleAxis, SetNumberFormat, SwitchView, ViewBack, ViewForward, CreateView, DeleteView and TogglePruneEmpty are LayoutGeometry, not CellData; Save/SaveAs/MarkDirty touch model_state for bookkeeping only and are ViewOnly. A classification done by grepping for model_state would have had the projection layer broadcasting cell updates for cursor moves.\n\n2. My own extraction was wrong twice, and I caught it by disbelieving the output rather than by luck: multi-line method chains hid workbook.model.set_cell(), and free-function helpers hid ApplyAndClearDrill's writes, so SetCell, SortData, RemoveItem and others first came out ViewOnly — obviously false. Fixed the extraction (flatten chains) and re-derived, then read the remaining suspicious ones directly.\n\nTESTS VERIFY DECLARATIONS EMPIRICALLY, not by restating them (AC 3). Rather than a table asserting kind() == what kind() returns, the tests apply each effect to a probe App and fingerprint the shared state it claims not to touch: view_only_effects_leave_shared_state_alone (16 effects, model data and view config both unchanged), cell_data_effects_actually_change_model_data, layout_geometry_effects_actually_change_view_config. Verified they bite by mislabelling SetCell as ViewOnly — the exact silent-drop bug — which fails cell_data_effects_actually_change_model_data.\n\nJUDGEMENT CALL, recorded rather than buried: SetSelected/SetRowOffset/SetColOffset write the shared, persisted View but are per-client concerns — one client scrolling must not move another's viewport — so they are ViewOnly. They are the only ViewOnly effects that mutate shared workbook state, which the test has to special-case. Filed improvise-8uz to resolve that properly before improvise-cqq builds the projection layer on this.","dependencies":[{"issue_id":"improvise-mae","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-vb4","title":"Split AppState into ModelState + ViewState (standalone refactor)","description":"Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on.","design":"pub struct ModelState { model: Model, file_path: Option\u003cPathBuf\u003e, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model.","acceptance_criteria":"(1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take \u0026mut the correct slice where obvious, or \u0026mut App where cross-cutting. (5) Audit report in issue notes: every field classified.","notes":"Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply.\n\n---\n\n## Field-by-field audit (improvise-97o)\n\nRefactor landed across 4 commits: f11d79f (3vr scaffold), 917b928 (x2c\nModelState fields), 9ad8abd (ew0 ViewState fields), 7249fac (99k residue\ndocs). All 29 original App fields classified below.\n\n| Field | Slice | Why |\n|---|---|---|\n| workbook | ModelState | Document semantics (Workbook = Model + named views + active view). |\n| file_path | ModelState | Path on disk for save/load — IO bookkeeping. |\n| dirty | ModelState | Divergence from on-disk state — IO bookkeeping. |\n| mode | ViewState | Current AppMode (Normal / Editing / etc.). |\n| status_msg | ViewState | Transient status-line text. |\n| wizard | ViewState | Optional import wizard, present only in import flow. |\n| search_query | ViewState | Current search string. |\n| search_mode | ViewState | True while typing into the search prompt. |\n| formula_panel_open | ViewState | Formula side panel toggle. |\n| category_panel_open | ViewState | Category side panel toggle. |\n| view_panel_open | ViewState | View side panel toggle. |\n| cat_panel_cursor | ViewState | Selected row in category panel. |\n| view_panel_cursor | ViewState | Selected row in view panel. |\n| formula_cursor | ViewState | Selected formula in formula panel. |\n| yanked | ViewState | Last `y`-yanked CellValue for paste. |\n| tile_cat_idx | ViewState | Highlighted tile in tile-select mode. |\n| view_back_stack | ViewState | View navigation history (back). |\n| view_forward_stack | ViewState | View navigation history (forward). |\n| drill_state | ViewState | Frozen records snapshot + staging edits for drill view. |\n| help_page | ViewState | Current page in help overlay. |\n| expanded_cats | ViewState | Categories expanded in the panel tree. |\n| buffers | ViewState | Named text-entry buffers (edit, formula, command, ...). |\n| transient_keymap | ViewState | Emacs-style prefix key chord state. |\n| layout | App-derived | Pure function of model_state.workbook + view_state.drill_state + active view; rebuilt via rebuild_layout(). |\n| last_autosave | App-runtime | Wall-clock instant for autosave debounce. |\n| term_width | App-runtime | Terminal width, updated on resize. |\n| term_height | App-runtime | Terminal height, updated on resize. |\n| abort_effects | App-transient | Flag scoped to a single apply_effects call; reset on entry. |\n| keymap_set | App-config | Keymap configuration loaded once at startup. |\n\nCounts: ModelState 3, ViewState 20, App-derived 1, App-runtime 3, App-transient 1, App-config 1 = 29 total.\n\nimprovise-drg (step 5: narrow Effect::apply signatures) was deferred —\nthe issue's framing has a hidden trait-design decision (you can't change\none impl's signature without changing the trait). Three options floated\n(split into ModelEffect/ViewEffect/AppEffect, body-level rebind, or\ndefault-method dispatch); none picked yet. vb4 does not depend on drg.","status":"closed","priority":2,"issue_type":"feature","assignee":"jeeves","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:59Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-05-03T04:16:32Z","close_reason":"Audit report written into vb4 notes per criterion (5). All 29 original App fields classified into ModelState (3) / ViewState (20) / App-derived (1) / App-runtime (3) / App-transient (1) / App-config (1). vb4 acceptance criteria all met: (1) typed model_state + view_state subfields ✓ (2) no overlap ✓ (3) tests pass ✓ (4) Effect::apply still \u0026mut App per criterion (4)'s allowance for cross-cutting; impl-level narrowing deferred to improvise-drg (step 5) which is P3/optional. Refactor commits: f11d79f, 917b928, 9ad8abd, 7249fac.","dependencies":[{"issue_id":"improvise-vb4","depends_on_id":"improvise-97o","type":"blocks","created_at":"2026-04-26T11:17:18Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-6jk","title":"Epic: Browser frontend via synchronized Redux-style stores","description":"Build a browser frontend for improvise by treating the client as a thin Redux-style peer of the existing server. Commands are the wire format; each side runs a reducer over its own state slice. Server is structurally unchanged (still runs today's full App with ratatui session, effect pipeline, formula eval). Client is a new thin peer: ViewState + render cache + reduce_view + keymap + DOM renderer. Commands flow both directions — upstream (user intent resolved client-side) and downstream (projection commands emitted by the server after model-touching effects). See child issues for sequenced steps.","design":"Client holds ViewState (mode, cursor, scroll, minibuffer, search, yanked, expanded cats, drill buffer, panel cursors) plus a render cache (visible cells, labels, col widths). Server holds full App as today. Wire type: Command enum, serde over websocket. Upstream: user-initiated commands resolved by client-side keymap. Downstream: projection commands (CellsUpdated, ColumnLabelsChanged, etc.) emitted by server after effect application. Client reduce_view pattern-matches commands and applies view-slice effects; client never runs formula eval or touches Model. Server emits projections by walking the effect list after each command and computing per-viewport deltas per connected client. Effect split (model vs view) is a server-internal tag driving projection emission — never crosses the wire. Key→command resolution is client-side (keymap in wasm bundle) for local responsiveness.","notes":"Prereqs from crate-split epic (improvise-xgl): step 2 (improvise-core, improvise-36h) for shared types; step 5 (improvise-command split, improvise-3mm) so wasm client can import keymap + Command + reduce_view without ratatui. Step 4 (Effect enum, improvise-45v) useful but not strictly required. Target bundle: 200-500 KB compressed — formula layer stays server-side.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:21Z","created_by":"spot","updated_at":"2026-07-30T23:06:32Z","dependencies":[{"issue_id":"improvise-6jk","depends_on_id":"improvise-1ey","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:59Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-mae","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:56Z","created_by":"spot","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3mm","title":"Step 5: Extract improvise-command crate below improvise-tui","description":"Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.","design":"Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes \u0026mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(\u0026mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState.","acceptance_criteria":"(1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean.","notes":"With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T06:53:59Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-06-13T20:57:07Z","close_reason":"Done in d60ee8d (prep: command layer de-coupled from ratatui/crossterm via own command::key types + semantic MinibufColor) and a85e6ca (extraction). crates/improvise-command now holds command/ (cmd, keymap, parse, key), ui/{app,effect,cat_tree}.rs, and the pure viewport helpers; depends on core+formula+io and provably NOT ratatui/crossterm (cargo tree -e normal = 0 hits; grep = 0 hits). Tests: 665 workspace green (improvise-command 227 in isolation), clippy --workspace --all-targets clean. DEVIATION from the recorded design: App moved WHOLE into improvise-command rather than splitting an AppState struct out, and the root 'improvise' crate stays the binary (re-exporting improvise_command under crate::command / crate::ui::{app,effect,cat_tree} so widgets/draw.rs/main.rs are untouched) instead of a renamed improvise-tui crate. This matches the improvise-ltq design correction and keeps the diff a pure file-move + re-export.","dependencies":[{"issue_id":"improvise-3mm","depends_on_id":"improvise-dwe","type":"blocks","created_at":"2026-04-15T23:29:27Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-45v","title":"Step 4: Convert Effect trait to an enum (in-place, no crate change)","description":"Refactor Effect from a trait (Box\u003cdyn Effect\u003e with apply(\u0026self, \u0026mut App)) into a data enum, with a single apply(app: \u0026mut App, effect: \u0026Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.","design":"Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: \u0026mut App, e: \u0026Effect) function that matches exhaustively. Commands still return Vec\u003cEffect\u003e (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.","acceptance_criteria":"(1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec\u003cBox\u003cdyn Effect\u003e\u003e is replaced with Vec\u003cEffect\u003e in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.","notes":"Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T06:36:11Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-16T06:26:50Z","close_reason":"Superseded. Effect stays as a trait — the original blocker (command transitively depending on App via Effect::apply(\u0026mut App)) is resolved by splitting App into AppState (semantic state, in improvise-command) + App (rendering state wrapper, in improvise-tui). Effect::apply takes \u0026mut AppState; the few effects that currently read App-level state (layout, display_value) pre-compute those values in their Cmd::execute and pass them via the effect struct's own fields (self). No enum conversion, no dual trait, no noise. See improvise-3mm for the revised design.","dependencies":[{"issue_id":"improvise-45v","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-13T23:54:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-8zh","title":"Step 3: Extract improvise-io crate (persistence + import)","description":"Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.","acceptance_criteria":"(1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.","notes":"Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:35:23Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-16T06:09:19Z","close_reason":"Done on worktree-improvise-ewi-formula-crate. Created crates/improvise-io/ containing persistence/ + import/; depends only on improvise-core and improvise-formula (plus external: anyhow, chrono, csv, flate2, indexmap, pest, serde, serde_json). No crate::ui::* or crate::command::* imports in moved code. All 616 tests pass (219 main + 190 core + 65 formula + 142 io); clippy clean; 'cargo build -p improvise-io' succeeds standalone. Kept as one crate per acceptance criterion #5.","dependencies":[{"issue_id":"improvise-8zh","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-13T23:54:39Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-36h","title":"Step 2: Break Model↔View cycle and extract improvise-core crate","description":"Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap\u003cString,View\u003e, and view/layout.rs reads \u0026Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.","design":"Introduce pub struct Workbook { pub model: Model, pub views: IndexMap\u003cString, View\u003e, pub active_view: String, pub measure_agg: HashMap\u003cString, AggFunc\u003e }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a \u0026View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.","acceptance_criteria":"(1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).","notes":"This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:35:05Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-16T05:50:39Z","close_reason":"Phase A + Phase B complete on worktree-improvise-ewi-formula-crate. Model↔View cycle broken via Workbook wrapper (pure-data Model with view state lifted to Workbook); improvise-core sub-crate extracted containing model/, view/, workbook.rs, format.rs; depends only on improvise-formula; builds standalone via 'cargo build -p improvise-core'. All 616 tests pass, clippy clean, persistence round-trips intact.","dependencies":[{"issue_id":"improvise-36h","depends_on_id":"improvise-ewi","type":"blocks","created_at":"2026-04-13T23:54:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ewi","title":"Step 1: Set up Cargo workspace and extract improvise-formula crate","description":"Convert the repo to a Cargo workspace and pull formula/ (parser, AST, Expr/BinOp/AggFunc/Formula) out into a standalone improvise-formula crate. This is the warm-up step — formula/ has zero local deps so extraction is mechanical. Proves the workspace plumbing before tackling the structural work.","acceptance_criteria":"(1) Root Cargo.toml becomes a workspace. (2) crates/improvise-formula/ contains everything from src/formula/ and compiles standalone. (3) improvise (the main crate) depends on improvise-formula and re-exports or uses it at crate::formula paths. (4) All existing tests pass. (5) cargo build and cargo test work from the workspace root.","notes":"Touchpoints: src/formula/{ast,parser,mod}.rs move; src/model/types.rs (uses AggFunc, Formula) and src/persistence/mod.rs and src/import/wizard.rs update imports. No logic changes.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:34:50Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-15T10:09:14Z","close_reason":"Workspace set up, improvise-formula extracted as standalone sub-crate under crates/. Root crate re-exports as crate::formula via 'pub use improvise_formula as formula;' so existing paths unchanged. 35 formula tests run standalone in the sub-crate, 537 tests run in the root crate (572 total, matching pre-refactor). cargo build, cargo test --workspace, and cargo clippy --workspace all clean.","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-xgl","title":"Epic: Split improvise into enforced-boundary workspace crates","description":"Convert the single improvise crate into a Cargo workspace of 5 crates so that module boundaries become compile-enforced rather than convention. Today nothing stops model/types.rs from reaching into ui::app; the goal is to make that a compile error. Target shape: improvise-formula (leaf) → improvise-core (model+view+format) → improvise-io (persistence+import) → improvise-command → improvise-tui (bin). See child issues for sequenced steps. The expensive work is breaking the Model↔View cycle and decoupling Effect from \u0026mut App — those are features, not costs.","design":"Target crate graph: improvise-formula (no deps) ← improvise-core (model+view+format) ← improvise-io (persistence+import) ← improvise-command ← improvise-tui. Two structural obstacles: (1) Model owns views: IndexMap\u003cString,View\u003e, creating a model↔view cycle — fix by moving views out into a Workbook wrapper. (2) Effect::apply takes \u0026mut App, so command transitively depends on App — fix by converting Effect to an enum with apply(app, effect) in the tui crate. Prefer the enum over a trait-based EffectTarget: effects become loggable/replayable (already gestured at in design-principles §1) and it matches the existing enum-heavy style (Binding, Axis, CategoryKind, BinOp).","notes":"Order matters: do the formula extraction first as a warm-up, then core, then io, then the Effect enum conversion in-place (no crate change), then finally the command/tui split. Steps 1-3 are mostly mechanical; step 4 is the real semantic work; step 5 should mostly just work after 4 lands.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T06:33:09Z","created_by":"spot","updated_at":"2026-07-31T05:57:04Z","closed_at":"2026-07-31T05:57:04Z","close_reason":"All 5 sequenced steps landed: formula/core/io/command crates extracted, Effect converted to enum, Model↔View cycle broken via Workbook. Boundaries now compile-enforced.","dependencies":[{"issue_id":"improvise-xgl","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-13T23:55:04Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-13T23:55:06Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-45v","type":"blocks","created_at":"2026-04-13T23:55:05Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-13T23:55:04Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-ewi","type":"blocks","created_at":"2026-04-13T23:55:03Z","created_by":"spot","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-s0h","title":"'o' (add-record-row) broken in fresh data models","description":"Pressing 'o' in records mode to add a new record row doesn't work correctly with fresh data models. The keybinding exists (add-record-row + enter-edit-at-cursor sequence) but the behavior is broken. Needs investigation to reproduce and fix.","status":"closed","priority":2,"issue_type":"bug","assignee":"edwlan","owner":"el-github@elangley.org","created_at":"2026-04-09T22:18:58Z","created_by":"spot","updated_at":"2026-04-26T17:03:28Z","closed_at":"2026-04-26T17:03:28Z","close_reason":"Fixed in 1cea06e fix(records): allow adding rows in empty records view (2026-04-14), with followups 9710fb5 (synthetic-records direct commit) and 38f83b2 (_Measure column in records mode). Regression tests in src/ui/app.rs all pass: add_record_row_in_empty_records_view_creates_first_row, edit_record_row_in_blank_model_persists_value, leaving_records_mode_cleans_empty_key_cells. Previous assignee cursor-f4c497bb shipped the fix but left the issue open.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-32r","title":"Drill into formula cell shows empty view","description":"Drilling into a cell whose value comes from a formula (e.g. Profit = Revenue - Cost) enters _Drill view with 0 rows. Formula cells have no raw data backing them, so the drill finds nothing. Should either show the constituent data rows that feed the formula, or display a meaningful message like 'Formula cell — no raw data'.","status":"closed","priority":2,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T22:03:59Z","created_by":"spot","updated_at":"2026-04-14T06:35:38Z","closed_at":"2026-04-14T06:35:38Z","close_reason":"Strip formula target from drill key so matching_cells finds raw data. Test: drill_into_formula_cell_returns_data_records","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gmx","title":"Bug: tile bar doesn't scroll when cursor moves offscreen","description":"When there are more category tiles than fit in the tile bar width, moving the cursor past the visible area doesn't scroll. The selected tile is simply not rendered. Found during demo recording with 8 categories.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T08:37:04Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:38:35Z","closed_at":"2026-04-09T08:38:35Z","close_reason":"Tile bar now auto-scrolls to keep selected tile visible, with ◀▶ overflow indicators.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-4ig","title":"Stress-test .improv parser for double quotes and commas","description":"Write targeted tests exposing bugs in the .improv parser around: embedded quotes in text values, commas in category/item names, commas in text values, escaped quotes, and round-trip fidelity for edge-case data.","notes":"Found 4 bugs:\n1. Newlines in text values break line-based parser (format_md writes newlines literally)\n2. Category names containing ', ' break coordinate split(\", \") parsing \n3. Item names containing '[...]' are misinterpreted as group syntax by parse_bracketed\n4. Extreme floats (subnormal numbers) don't round-trip through 4-decimal display format\n\nEmbedded double quotes actually work by coincidence (strip_prefix/strip_suffix only remove one char).\n\nTests added to src/persistence/mod.rs: 15 targeted unit tests + 5 proptest property tests (500 cases each).","status":"closed","priority":2,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T07:29:50Z","created_by":"spot","updated_at":"2026-04-09T09:01:59Z","closed_at":"2026-04-09T09:01:59Z","close_reason":"Fixed 6 parser bugs (newlines, commas in names, brackets in names, float precision, view name ambiguity, group brackets). Rewrote format: v2025-04-09 version line, Initial View, pipe quoting, Views→Formulas→Categories→Data order, comma-separated items. Added pest grammar, grammar-driven generator, 83 persistence tests including property tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-79u","title":"Bug: CommitFormula targets virtual category on empty model","description":"CommitFormula uses category_names().first() to pick the formula target category. category_names() includes virtual categories (_Index, _Dim). On a model with no regular categories, formulas would be assigned to _Index — which is meaningless since virtual categories exist only for drill-down rendering. Should filter to regular categories only, or show an error like 'Add at least one category first.' Found during test audit of command/cmd.rs.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:26:09Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:06Z","closed_at":"2026-04-09T06:38:06Z","close_reason":"Added Model::regular_category_names() that filters out virtual categories. Updated CommitFormula and ImportPipeline::build_model to use it. Regression test confirms empty model shows 'Add at least one category first.'","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-aa9","title":"Test audit: import/wizard.rs at 60% coverage","description":"import/wizard.rs has 60% line coverage with significant untested paths in the ImportPipeline and ImportWizard. Pipeline creation, schema inference, and complex nested JSON structures need more test coverage.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:43Z","created_by":"Edward Langley","updated_at":"2026-04-09T05:53:16Z","closed_at":"2026-04-09T05:53:16Z","close_reason":"Added 23 wizard tests covering step transitions, cursor movement, proposal toggle/cycle, formula lifecycle, date config, preview summary, sample formulas, and edge cases. Coverage from 60% to 94%.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2kg","title":"Test audit: ui/effect.rs at 18% coverage","description":"ui/effect.rs has 50+ effect types but only 18% line coverage. Most effects are thin apply() methods tested indirectly through app.rs integration tests. Complex effects like drill reconciliation and import deserve targeted unit tests per the testing guidelines.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:30Z","created_by":"Edward Langley","updated_at":"2026-04-09T05:36:48Z","closed_at":"2026-04-09T05:36:48Z","close_reason":"Added 41 direct effect tests covering model mutations, view navigation stacks, drill state (value edit, coord rename, clear), panels, toggles, search buffer special case, and more. Coverage from 18% to 75%.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-c02","title":"Test audit: command/cmd.rs at 52% coverage","description":"command/cmd.rs has 40+ commands but only 52% line coverage. Commands are pure functions (receive \u0026CmdContext, return Vec\u003cEffect\u003e) so they're easy to test. Many commands like add-category, add-item, set-cell, transpose, drill, export, and various panel commands lack tests. This is the highest bang-for-buck coverage improvement.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:01Z","created_by":"Edward Langley","updated_at":"2026-04-09T05:28:07Z","closed_at":"2026-04-09T05:28:07Z","close_reason":"Added 53 tests. Coverage from 52% to 75%. Also found bug: CommitFormula targets virtual categories on empty models (filed as improvise-79u).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-abz","title":"2.5 Verify all Phase 2 artifacts exist and are committed","description":"Confirm: README.md with all 10 sections, docs/demo.gif under 5MB referenced from README, docs/demo.tape regenerates GIF, all 4 .cast files exist, example files exist with synthetic data only, flake.nix includes asciinema/vhs/cargo-dist, nix develop succeeds.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:48Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T22:24:08Z","close_reason":"All Phase 2 artifacts verified: README, demo.gif (791KB), demo.tape, 4 casts at 120x37, example files, flake tooling, nix develop works","dependencies":[{"issue_id":"improvise-abz","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:25Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-abz","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T21:09:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-d4w","title":"2.4 Record asciinema casts","description":"Record four .cast files under docs/casts/: import.cast (CSV import wizard), pivot.cast (axis reassignment), drill.cast (drill into aggregated cell), formulas.cast (add Profit formula). Terminal: 100x30. Use -i 2 flag. Each under 60 seconds. Add recording helper script.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:47Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T08:20:50Z","close_reason":"All four casts recorded: pivot, drill, formulas, import.","dependencies":[{"issue_id":"improvise-d4w","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T21:09:23Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-d4w","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T21:09:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-odx","title":"2.3 Create docs/demo.tape and generate docs/demo.gif","description":"Write VHS .tape file scripting a ~20-second pivot reassignment demo. Generate GIF via nix develop --command vhs docs/demo.tape. Must be under 5MB, show start in pivot view -\u003e T -\u003e axis reassigns -\u003e T -\u003e axis reassigns again. Iterate until readable.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:44Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T08:03:49Z","close_reason":"docs/demo.tape and docs/demo.gif created, LFS-tracked, wired into README.","dependencies":[{"issue_id":"improvise-odx","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T21:09:21Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-odx","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T21:09:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gd28","title":"Drill over the wire needs the addressed command form","description":"From the ADR 002 spine landing (2026-09-19). The RESOLVER exists and is tested -- App::resolve_aggregate, layout-only by necessity since an aggregate has no existence outside the layout that derives it -- and CellId::Aggregate is emitted. What is missing is the command.\n\nModelCommand::Drill was deliberately NOT added. Drilling creates the _Drill view and writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a NULLARY Command -- exactly the reason ApplyAndClearDrill was removed from the wire in improvise-3x3u.\n\nSo a wire Drill needs the ADDRESSED form: the variant carries a SessionId and the staging map moves off the single App onto the session. That is ADR 001 layer 2 (context/adr/001-concurrent-edits.md), so this is blocked on it rather than being independent work.\n\nThe seam is ready: emit, resolve and the type-level guarantee (Drill takes an Aggregate and nothing else) are all in place and tested.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:18:34Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-qddo","title":"Make DataStore an implementation of a source trait, not the implementation","description":"The one piece of ADR 003 worth doing regardless of when or whether live-backing happens. See context/adr/003-live-backed-models.md.\n\nTODAY Model holds a DataStore directly, and a Workbook loads ENTIRE -- there is no partial-load path anywhere in the tree (verified while declining improvise-9ix). So \"where cell values come from\" is not a seam; it is an assumption spread across GridLayout, evaluation and persistence.\n\nTHE WORK: introduce a source trait in improvise-core and make the current in-RAM DataStore AN implementation of it rather than THE implementation. No behaviour change -- doctrine(characterization_survives_the_move) applies, and the existing tests must carry across unchanged in meaning.\n\nWHY IT IS WORTH DOING ON ITS OWN, before any decision about SQLite:\n * it is the seam every later option needs -- live-backing, import-once, or a test double;\n * it makes \"the model is fully in RAM\" an explicit property of one implementation rather than an invisible assumption of the whole crate;\n * done as a pure refactor it is cheap and reviewable, whereas doing it AS PART OF adding SQLite mixes a large refactor with a large feature and makes both unreviewable.\n\nCONSTRAINT: forbidden(improvise_core, io) -- the TRAIT belongs in improvise-core, any IO-touching implementation belongs in improvise-io. Do not let the trait signature drag IO types into core; if it needs Result, that is a boundary decision worth arguing rather than defaulting (AGENTS.md keeps Option/Result at the edges).\n\nWATCH: aggregation is the real interface. apply_agg computes Sum/Avg/Min/Max/Count over a matching cell set, and GridLayout asks for a VALUE, not for the rows behind it -- so the trait should be shaped around \"give me the aggregate for this coordinate set\", not around \"give me the cells\". Shaping it around cells would foreclose SQL pushdown, which is the whole point of the eventual live source.","notes":"\n--- READ-ONLY DECIDED (user, 2026-09-19), and it shapes the trait ---\nA live-backed model is READ-ONLY, and not reluctantly: for many use cases\nediting capabilities would be a DOWNSIDE. Pointing a pivot tool at a production\ndatabase and being unable to write to it is a feature.\n\nSO DO NOT PUT WRITES IN THE TRAIT. If writing were a trait method, a read-only\nsource would have to fail at RUNTIME for something it structurally cannot\nserve. Keep reads -- and crucially AGGREGATES, see the note above about shaping\nit around \"give me the aggregate for this coordinate set\" -- in the trait every\nsource implements, and let writing be a capability only the owned in-RAM\nimplementation has. Then read-only is UNREPRESENTABLE rather than enforced,\nwhich is the same discipline ADR 002 CellId uses and the same design-principles\n3 rule.\n\nRELATED CONVERGENCE WORTH KNOWING: ADR 002 already makes SetCell take a\nCellId::Record and nothing else, so a live-backed model that projects no Record\nids makes a client-side write unconstructible with no runtime check and no\n\"this model is read-only\" dialog. The settability distinction built for\naggregates carries read-only sources for free -- so the client half of this may\nalready be done.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:16:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:18:57Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-rucf","title":"Read arbitrary SQLite databases as a data source","description":"USER REQUIREMENT (2026-09-19): improvise must be able to handle ARBITRARY SQLite databases as models -- someone points it at a database they already have and pivots over it.\n\nTHIS IS NOT improvise-9ix AND ITS DECLINE DOES NOT BEAR ON IT. 9ix proposed SQLite as improvise OWN PERSISTENCE FORMAT -- writing our model semantics into a second container -- and was declined because that is a second implementation of the pest grammar with a silent-drift failure mode, and because it measured 22% larger than .improv and 4.7x larger gzipped. Reading SOMEONE ELSE database has none of those properties: no round-trip obligation, no grammar duplication, and the value is real (pivot over data you already have). The one thing that DOES carry over is the dependency constraint, below.\n\nTHE SEAM IS CLEAN AND ALREADY EXISTS. import/csv_parser.rs produces Vec\u003cserde_json::Value\u003e (rows as objects) and import/analyzer.rs::analyze_records(\u0026[Value]) -\u003e Vec\u003cFieldProposal\u003e does the Category/Measure/Time/Skip classification from there. A SQLite source slots in beside csv_parser.rs and needs only to produce Vec\u003cValue\u003e. ImportPipeline and the wizard then work unchanged.\n\nA GENUINE IMPROVEMENT OVER CSV, worth exploiting rather than ignoring: the analyzer currently infers field kinds from STRING SAMPLES -- detect_date_format guesses a date by pattern-matching text. A SQLite column has a DECLARED TYPE. So the classifier should take typed input where it has it and fall back to sampling only where it does not (SQLite being dynamically typed per-value, both cases are real). That is a better analyzer, not just a new source.\n\nDESIGN QUESTIONS TO SETTLE, none of them hard but all of them real:\n * WHICH ROWS? A table name, or a user-supplied SELECT? A query is strictly more useful (joins, filters, computed columns) and no harder to consume -- a result set is a result set. Prefer it, and let a bare table name be sugar.\n * MULTIPLE TABLES: answered by the above if the input is a query.\n * TYPE MAPPING: INTEGER/REAL -\u003e measure candidates, TEXT -\u003e category candidates, NULL -\u003e the missing path (see improvise-ych: null is ABSENCE, not the string \"null\"), BLOB -\u003e skip. Dates in SQLite may be TEXT, INTEGER (epoch) or REAL (julian) -- detect, do not assume.\n * READ-ONLY for now. Writing edits back to the source database is a separate and much larger question; say so in the code rather than leaving it implied.\n\nTHE CONSTRAINT THAT CARRIES OVER FROM 9ix: improvise-io IS IN THE WASM BUNDLE (.github/workflows/ci.yml gates it), and libsqlite3-sys C build fails for wasm32-unknown-unknown under this repo nix cc-wrapper. So this must be FEATURE-GATED AND NATIVE-ONLY, with the wasm job still passing untouched -- or the browser path uses a different SQLite (sqlite-wasm-rs / OPFS), which is a second decision and should be its own ticket. Verify the gate rather than assuming it.\n\nOPEN, and it changes the size of this ticket by an order of magnitude -- see the note being added: whether this is IMPORT-ONCE (read the database, build a Model in RAM) or a LIVE-BACKED model (queries go to SQLite as the user pivots).","notes":"\n--- SUPERSEDED IN FRAMING by ADR 003 (user, 2026-09-19) ---\nThe user chose LIVE-BACKED over import-once, and the goal is stated as: have\nTHE OPTION of essentially using improvise as a DB CLIENT. NOT A PRIORITY --\nnothing here outranks improvise-1ey. Re-prioritised P2 -\u003e P3.\n\nSo this ticket read-only import framing is not the target shape. See\ncontext/adr/003-live-backed-models.md. The import-once seam it described\n(csv_parser -\u003e Vec\u003cValue\u003e -\u003e analyze_records) is still accurate and still\nuseful, but as ONE implementation of a source rather than as the answer.\n\nWHAT SURVIVES UNCHANGED and is worth keeping from this ticket: the type-mapping\nnotes (SQLite columns carry declared types, which is BETTER input than CSV\nstring sampling -- detect_date_format currently guesses a date by pattern-\nmatching text; dates may be TEXT, INTEGER epoch or REAL julian, so detect\nrather than assume; NULL is ABSENCE not the string \"null\", per improvise-ych),\nand the wasm constraint (improvise-io is in the bundle, libsqlite3-sys does not\nbuild for wasm32 under this repo nix cc-wrapper, so any SQLite dependency is\nfeature-gated and native-only).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T00:14:11Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:16:27Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-i9lt","title":"View::axis_of panics for a category the view has not seen","description":"Noticed during improvise-35e (2026-09-19), reported not fixed per the mention-do-not-fix rule for pre-existing issues.\n\nView::axis_of(cat) panics for a category the view has never seen, and View::tileable_cats admits exactly such categories -- it filters on try_axis_of(c).is_none_or(...). So the pairing is a live panic waiting for the right view state.\n\nThe old TileBar called axis_of over that list. The migrated widget cannot panic there any more, because tile_entries uses try_axis_of(...).unwrap_or(Axis::None) -- so the reachable path is gone and no failing test was written, since the old path no longer exists to fail.\n\nWorth a guard anyway: either make axis_of total (returning Axis::None like tile_entries does) or make it private and force callers through try_axis_of. A panicking accessor over a list built to include the panicking case is a trap for the next caller, and the next caller will not have the migration to protect them.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:25:57Z","closed_at":"2026-09-20T00:25:57Z","close_reason":"The reachable path is gone. improvise-edp migrated CategoryContent to take (\u0026CategoryTreeCache, \u0026HashSet\u003cString\u003e) instead of \u0026View, and the cache is built with try_axis_of(...).unwrap_or(Axis::None) -- so the widget that called view.axis_of(name) over a list that admits unseen categories no longer exists. Same resolution as the TileBar path in improvise-35e.\n\nView::axis_of itself is unchanged and still panics for a category the view has not seen. Left deliberately: it is a pre-existing API sharpness with no remaining caller that can reach it, and the mention-do-not-fix rule applies. If it acquires a new caller, make it total (returning Axis::None like the cache does) or make it private and force callers through try_axis_of -- a panicking accessor over a list built to include the panicking case is a trap for whoever arrives next.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-j31x","title":"Server hosts several documents: a registry above Sessions","description":"DEFERRED by the user (2026-09-19) -- recorded so nothing forecloses it, not to be built now.\n\nimprovise is a multi-document app and the server should be able to host several separate models. The shape:\n server -\u003e many documents\n document -\u003e one App + one Sessions + many ClientSessions\nThat is NOT one App per session (what improvise-q08 ticket text says, written before the session model landed) and NOT one App total.\n\nNOTHING THAT HAS LANDED NEEDS TO CHANGE. Sessions does not own the App -- connect(\u0026mut self, app: \u0026App, ..), apply(\u0026mut self, app: \u0026mut App, ..) and project_all(\u0026mut self, app: \u0026App, ..) all take it as a parameter. So Sessions is already document-agnostic and a Sessions instance is implicitly bound to one document generation sequence, which is right: document_generation lives on the App. Multi-document is a REGISTRY ABOVE Sessions mapping a document id to its (App, Sessions) pair.\n\nWHY IT MATTERS BEYOND TIDINESS: sessions editing independent models are trivially non-conflicting, so ADR 001 concurrency design scopes per document FOR FREE -- the basis version tiers live on the App and each document has its own. A server that shared one App across documents would throw that away.\n\nWHAT TO AVOID IN THE MEANTIME: improvise-q08 must not hard-code a single App into a static or a lazily-initialised singleton. The document should be a PARAMETER even while there is only one, and a connection should be attachable to a document even if the MVP answers that from a CLI argument naming one .improv file.\n\nScope when picked up: the registry, document lifecycle (open/close/last-session-leaves), and how a client names the document it wants. Auth and access control are explicitly NOT in scope here -- file them separately if wanted.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:32:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:33:15Z","closed_at":"2026-09-20T00:33:15Z","close_reason":"Registry landed in crates/improvise-ws-server/src/registry.rs. 1051 tests pass.\n\nIT CAUGHT A REAL BUG BY VERIFYING THE SEAM INSTEAD OF TRUSTING IT. My brief and the q08 notes both said nothing below the registry needs to change. Nearly true -- but Sessions::next_id is a PER-Sessions counter (confirmed: project.rs holds next_id on Sessions, not globally), so EVERY documents first client is SessionId(0), and transport.rs keyed its outs: HashMap\u003cSessionId, _\u003e on it. A registry that kept that key would have delivered document Bs frames to document As client the moment two documents each had one client.\n\nThe server-wide address is the pair, ClientId { document, session }. Registry::route(document, Vec\u003cAddressed\u003e) is the ONLY place a SessionId acquires a document -- the one it came out of, since that is the only one in scope there -- so cross-document delivery of a projection, a SwitchView broadcast (gotcha 36) or an Applied::Rebased fan-out (gotcha 39) is UNREPRESENTABLE rather than checked. The test names the hazard: an_edit_is_projected_only_to_the_edited_documents_clients asserts on_alpha.session == on_beta.session FIRST.\n\nLIFECYCLE, and the non-obvious half. Open at STARTUP from the files named on the command line -- open-on-request was rejected because deciding which paths a client may open IS access control, which this ticket put out of scope; serving only what was named leaves the question unanswered rather than answered badly. NOT closed when the last session leaves: eviction loses unsaved edits, and worse, document_generation and record_versions start at ZERO in a fresh App (gotcha 40), so a client reconnecting with a STALE basis would have it accepted as current -- a hole in the exact mechanism ADR 001 is building, bought for a memory saving nobody asked for. Registry::is_idle is the hook a future policy asks from; nothing evicts.\n\nA client names its document in the URL PATH (ws://host:port/quarterly), which leaves wire.rs COMPLETELY unchanged so the wasm-client frames keep working; / resolves to the only document when there is exactly one, and with two is ConnectError::Ambiguous rather than a coin toss. A refusal is a websocket CLOSE FRAME carrying the reason -- no ServerMsg::Error variant invented, because that is a connection fact and wire.rs only grows variants the mutations demand.\n\nEvery required claim has a counter-test beside it so it cannot pass vacuously -- e.g. the broadcast-isolation fixture gives BOTH documents a view named Second, so betas silence is the routing and not a name it would have rejected anyway.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2wi8","title":"ADR-001 e: a forced overwrite is visible to the client it overwrote","description":"ADR 001 layer 1, step e. Depends on step c.\n\nWithout this, force is silent data loss with a confirmation step in front of it. The overwritten client edit was already committed, so this is a NOTIFICATION rather than a conflict -- but it must exist, or \"someone else won\" is indistinguishable from \"my edit never happened\".\n\nDesign note: the notification is about the loser OWN prior edit, so it is per-client and therefore UI -- the model is shared, the UI is not. Do not broadcast it as document state.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:34Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:27:34Z","dependencies":[{"issue_id":"improvise-2wi8","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-vb6l","title":"ADR-001 d: when an intent cannot be re-composed, show what changed","description":"ADR 001 layer 1, step d -- the expensive one, and the honest cost of this design. Depends on step c.\n\nConflict class 2 (shape change vs cell edit) has NOTHING COHERENT TO FORCE: the key names a coordinate that no longer exists, and applying anyway writes a CellKey naming a deleted category -- the ghost-coordinate corruption the whole ADR exists to prevent.\n\nSo for class 2 the UI owes the user WHAT CHANGED plus the concrete alternatives -- re-target the edit, or re-create the structure first -- rather than a bare refusal or a force button that cannot be honoured.\n\nFORCE NEVER CREATES STRUCTURE: re-instating a category someone deleted so an edit can land is a different and LARGER intent than the user expressed. It must be an explicit action, never a side effect of forcing a cell edit.\n\nClass 3 (a formula now shadows the value) is forceable WITH A WARNING: the write lands, the display stays derived.\n\nNOTE FOR WHOEVER PICKS THIS UP: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention -- e.g. a category deletion being explicit about the cells it destroys -- rather than making the conflict cheaper to resolve. That is a product call, not an implementation detail.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:33Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:27:33Z","dependencies":[{"issue_id":"improvise-vb6l","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:46Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-7b8v","title":"Concurrent edits: advisory reservations (ADR 001, layer 2 — UX only)","description":"Implements layer 2 of context/adr/001-concurrent-edits.md. BLOCKED ON layer 1 by design, not by dependency: correctness must not rest on this layer.\n\nGeneralise the drill staging mechanism so a client publishes the KEYS it has staged, letting other clients grey them out and warn before someone types into a cell that is about to be taken. The VALUES stay private -- the user ruled on that in improvise-tom1: staged edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState.\n\nTHE DESIGN CONSTRAINT: this layer is ADVISORY ONLY. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to \"you find out at commit\" -- never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is exactly why the basis check must land first and stand alone.\n\nNote the split it implies: the reservation KEY SET is shared (everyone must see it to be warned) while the VALUE is private. That maps onto the existing EffectKind distinction rather than fighting it.\n\nDo not add lease renewal or lock timeouts as correctness machinery. If a reservation outlives its client, the worst outcome is a stale warning, and the basis check still rejects the actual conflict.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:54Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:09:54Z","dependencies":[{"issue_id":"improvise-7b8v","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ry89","title":"cv_regress/cv_classify with k=1 now return plausible numbers instead of NaN","description":"Wave-4 review (2026-09-19), CONFIRMED, LOW. A signal loss, not a wrong answer.\n\nmean_over(total, 0) == 0.0 replaced a 0.0/0.0 NaN, which is right everywhere it is used -- the reviewer looked for a case where an empty set WINS a comparison it should lose and found none (inside fit every candidate scores the same rows; cv_* pool pairs rather than reduce to a per-fold metric).\n\nBut with k=1, folds(n,1) puts every index in the single fold, so the training complement is EMPTY and the model trains on nothing. Pre-change, standardize(\u0026[]) gave mean = NaN and every pooled prediction was NaN -- the degenerate call announced itself. Post-change:\n k=1 pooled[0] = (1.48e-6, 8.0), nan_predictions = 0\nSo a library caller passing k=1 gets 37 near-zero cross-validated predictions from a tree fit on nothing, and anyone computing R2 off them gets a number rather than a NaN.\n\nexamples/fit_csv.rs guards with k \u003e= 2, so no shipped path hits it. Fix by documenting on cv_regress/cv_classify that k\u003c2 is meaningless, or by returning empty for k\u003c2. Note the folds doc argues max(1) is \"the answer the shape gives\" -- true for folds, and no longer true for the cross-validators.\n\nNIT from the same review: standardize_is_bit_for_bit_what_it_was is insensitive -- both perturbations of standardize left it green (one was caught by cv_regress instead). Its four witnesses do not separate accumulation orders on the 37-row fixture. Worth strengthening while here.","notes":"\n--- PARTIALLY ADDRESSED on PR #11 (2026-09-19) ---\nCopilot independently re-found this. The PR agent DOCUMENTED it rather than\nguarding it, and the reasoning is right: returning empty for k \u003c 2 is a\nbehaviour change to the exact functions scoring_known_answers.rs pins\nbit-for-bit, inside a PR whose entire claim is bit-identity. It deserves its\nown commit where it is the headline rather than a rider on a reshape.\n\nSTILL OPEN: decide guard vs document-only. If guarding, the known-answer\nvectors must be re-established afterwards, not assumed -- that is the whole\npoint of them.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:04Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:40:24Z","closed_at":"2026-09-19T23:40:24Z","close_reason":"GUARDED, not documented. The returned value is pooled predictions from trees that never saw the row; with one fold there are none, so EMPTY IS THE TRUE COUNT, not a sentinel. Documenting would leave a library function whose degenerate answer looks like a models output.\n\nThe asymmetry is resolved by splitting the QUESTION, not by clamping differently: folds keeps its max(1) (a partition into one piece is still a partition) and gains a note that the answer is its alone; a new private cv_folds(n,k,rng) yields no fold below k=2 and is what both cross-validators ask. Declining BEFORE the shuffle costs the caller generator nothing.\n\nTHE TICKET PREMISE WAS FALSE and was checked rather than assumed: it said returning empty for k\u003c2 would change numbers the known-answer vectors pin bit-for-bit. Those vectors only ever call k=4 and k=3, so the guard changes none of them -- all six pass unchanged, not one pinned number edited.\n\nThe insensitive-witness NIT is fixed too: standardize_is_bit_for_bit_what_it_was pinned four numbers and let r.target/std - mean/std (same algebra, different rounding) through AND past both CV vectors; it now pins all 37 normalized targets, which that injection moves 21 of.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-k2i4","title":"The two nullary model commands are unaddressed and act on one client's staged state","description":"Wave-4 review (2026-09-19), CONFIRMED, LOW. Worth a decision and a note rather than an immediate change, since multi-client is not wired.\n\nApplyAndClearDrill and AcceptWizardModel are safe no-ops when nothing is staged (both early-return), so a client cannot corrupt the document by sending them blind. Two residual issues:\n\n(a) SPURIOUS PROJECTIONS. Dirty comes from the EffectKind, not from whether anything happened, so a no-op still dirties everything at that level and reaches every client. Measured: ApplyAndClearDrill dirties grid+category_tree+formulas; AcceptWizardModel dirties those plus view_list+tile_bar, emitting a non-empty projection list for a no-op.\n\n(b) THE STAGED STATE IS GLOBAL AND THE COMMAND IS NOT ADDRESSED. Sessions::apply routes Command::Model with no SessionId, while drill_state and wizard live on the single authoritative App. So client B sending Model(ApplyAndClearDrill) commits client A uncommitted staged edits, and Model(AcceptWizardModel) installs the workbook A half-finished wizard built -- replacing the document under everyone.\n\nThat collides with the user ruling on improvise-tom1: staged drill edits belong to whoever staged them and must not be visible to others. Committing them on someone else behalf is the stronger version of the same violation. Gotcha 30 calls this state session-local residue; the command layer now lets an unaddressed message act on it.\n\nDECIDE: either these commands become addressed (a SessionId, and the effect acts only on that session staged state -- which means the staging map moves off the single App), or they are removed from the wire vocabulary and stay key-path only. The second is cheaper and consistent with tom1.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:03Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:18:03Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ls3y","title":"[bug] A wire-driven SwitchView does not push the view history; the effect does","description":"Found during improvise-kmgs (2026-09-19), pre-existing, out of that ticket scope.\n\napply_command SwitchView arm calls app.switch_to_view(name) DIRECTLY, while effect::SwitchView first pushes a ViewFrame onto view_back_stack and clears the forward stack. So a switch driven over the wire is NOT undoable by ViewBack, and a key-driven one is -- two spellings of one user action that differ in whether history records it.\n\nSurfaced while building a ViewBack session test: the test had to walk forward through the EFFECT to fill the stack, because the command path never fills it. That asymmetry is the tell.\n\nDecide which is right rather than blindly making them agree: history is arguably UI state (the model is shared, the UI is not), in which case the per-client stack should be fed by whichever door the client used -- and then the command path is the one that is wrong. Note improvise-jytm will make active_view per-client, which touches the same ground.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:11:30Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:50:02Z","closed_at":"2026-09-19T22:50:02Z","close_reason":"Found again by Copilot on PR #11 and fixed in ee767f9, merged as eb0c271. apply_command performed Command::SwitchView itself, calling App::switch_to_view directly instead of running effect::SwitchView -- and the EFFECT is the only thing that pushes onto view_back_stack and clears view_forward_stack. It was also the last hand-rolled mutation left in apply_command, which sat badly against the PR own \"effects are the only mutation site\" thesis.\n\nWAVE 4 IS WHAT MADE IT BITE: ModelCommand::ViewBack pops exactly that stack, so a wire client could switch views and then be unable to go back, while the same two keypresses worked. Before ModelCommand there was no wire way to ask, so the missing push was invisible.\n\nDemonstrated first: a_wire_view_switch_records_history_the_way_a_key_switch_does failed with the document still on Second. Nothing else in the suite could see it -- switch_view_moves_the_document_and_the_client_together checks where a switch LANDED, not what it RECORDED.\n\nPart of Copilot suggestion was declined with reasons on the thread: the arm still returns a hand-written Dirty rather than Dirty::of(Model(LayoutGeometry)), because Dirty::of is a per-KIND over-approximation and a view switch is the one member of that kind which leaves the view list alone -- which view is ACTIVE is not in ViewListCache at all. switching_views_projects_the_axis_widgets_only pins that.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-dkqn","title":"gp-tree::fit should take one slice of rows, not parallel x/y slices","description":"Filed from Copilot PR #10 round 2 (2026-09-19). The PR agent DECLINED the literal request and was right to; this is the real remedy.\n\nCopilot asked five public scoring functions (mse, cost, fit, cv_regress/cv_classify, permutation_importance) to enforce their basic preconditions -- i.e. validate that the parallel x and y slices have matching lengths. That was declined on the grounds that validating a parallel length is exactly the ASSERTED form improvise-n3wt removed from prune_axis_items in the very same PR (make the invariant unrepresentable, do not check it), and that Result on five functions is what AGENTS.md means by keeping Option/Result at the boundaries. The reasoning was posted on the PR thread citing both.\n\nTHE FIX IS A TYPE CHANGE: give these functions one slice of rows rather than parallel x/y. That removes the zip truncation Copilot identified -- the thing that made mse wrong -- and answers the empty-input NaN in standardize/misclass_rate/mse in the same move, because an empty row set is then one shape rather than four independent edge cases.\n\nCONSTRAINT: wave 3 established byte-identical CLI output across nine scenarios including predictions and the saved model. That evidence must be re-established after this change, not assumed -- the whole point of that harness is that a refactor of the search cannot be trusted on a green suite alone.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T22:01:11Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:11:20Z","closed_at":"2026-09-19T22:11:20Z","close_reason":"Row\u003cT\u003e { feat: Vec\u003cf32\u003e, target: T } in fit.rs -- generic in the target because the two supervised shapes differ only there (Row\u003cf32\u003e regression, Row\u003cusize\u003e class code). mse/cost/fit/misclass_rate/standardize/cv_regress/cv_classify/permutation_importance all take one slice, so the parallel lengths zip used to truncate silently cannot be built. Rejected: a Rows newtype with a checked constructor (validation wearing a type -- the thing the doctrine declines), a bare tuple slice, and Result on five signatures. The pairing is made at the boundary that BUILDS the data: preprocess walks one row index and the rows live inside Target, because a Dataset carrying rows and target separately just moves the zip up one level into every caller. Empty input answered once in mean_over -- no rows scored is no error found, not the 0.0/0.0 NaN that propagated through cost and sorted unpredictably against real costs.\n\nBIT-IDENTITY ESTABLISHED, NOT ASSERTED: known-answer vectors were captured from the UNMODIFIED code first -- raw f32::to_bits for every scoring path plus rng.next_u64() after each stochastic run, which is what pins the generator call order and count (SplitMix64::seed() returns the original seed and witnesses nothing). Those literals are now tests/scoring_known_answers.rs (6 tests) and pass unchanged. 37 rows is prime, so no fold count divides evenly and the short last fold is exercised. Nine-scenario CLI byte-identity re-established including model.json sha256. Note the CLI prints 3-4 decimals, which is exactly why the bit-level tests exist: the diff alone could not see a moved accumulation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-t3ux","title":"cv_regress and cv_classify are O(n-squared)","description":"Filed from Copilot PR #10 round 2 (2026-09-19). Confirmed, not speculative: both do test.contains(i) inside a filter over 0..y.len(), in both loops, so the inner membership test is linear in the fold size.\n\nA bool mask over the row indices fixes it and changes no results. Wants a benchmark rather than a bare assertion of improvement -- and note that results must be bit-identical afterwards, since gp-tree fits are reproducible from a seed and any change in accumulation order would invalidate every recorded seed (see the SplitMix64 known-answer work, improvise-8x9o).\n\nPairs naturally with the row-slice reshape ticket; doing them together avoids touching these functions twice.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T22:01:11Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:11:21Z","closed_at":"2026-09-19T22:11:21Z","close_reason":"complement(n, test) marks the fold in a Vec\u003cbool\u003e and filters; both cross-validators call it. Measured on the split alone (release, 5 folds): n=1000 84us -\u003e 15us (5.8x), n=4000 1.01ms -\u003e 45us (22.7x), n=16000 16.2ms -\u003e 134us (120.6x), n=64000 258ms -\u003e 482us (535.5x). Quadrupling n multiplies the scan by ~16 and the mask by ~3.6 -- the shape changed, not just the constant.\n\nBit-identity: the_training_split_is_what_the_contains_scan_gave asserts complement(20, test) equals the old (0..20).filter(|i| !test.contains(i)) index-for-index over every fold of k in {1,2,3,5,7} -- the equivalence directly, not inferred from a fit that happened to agree. The known-answer vectors from improvise-dkqn also pass unchanged.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-pczv","title":".improv section headers are a second, unquoted naming rule","description":"Found during improvise-i2m5 (2026-09-19), pre-existing and deliberately left alone.\n\nPipe quoting now has one home and a category or item named SUM is written |SUM| in every REFERENCE -- axis lines, item lists, page selections, hidden:, data coords. But section headers never went through quote_name on either side and still do not: improv.pest has category_section = { \"## Category: \" ~ rest_of_line ~ ... } and view_section likewise, and parse.rs::section_title reads them with as_str().trim(). So a name is quoted everywhere it is referenced and raw in its own header.\n\nThat is coherent for a whole-line token, and it is why the round trip still works. But it means '# Model Name', 'Initial View:', '## Category:' and '## View:' are a second naming rule with different escaping. It matters if a name with leading/trailing whitespace, an embedded newline, or a line that looks like another section marker is ever reachable -- today the writer cannot produce one, but nothing enforces that.\n\nDECIDE: either route section headers through quote_name/extract_name like every other name position (grammar change first -- improv.pest is the source of truth), or state in the grammar that the whole-line token is the intended contract and add a test pinning what happens to an awkward name. The current answer is pinned only in a test comment.","notes":"\n--- CONTEXT (2026-09-19) ---\nThe user has confirmed the pipe-quoting canon itself is fine (\"Pipe quoting is\nfine\"), so this ticket is NOT about whether to quote -- it is only about the\none position that still does not: section headers. Decide it on its own merits\n(whole-line token vs. quoted name), not as a re-litigation of the guard.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T21:21:39Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:10:04Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-n89z","title":"Three Projection mutations have no producer","description":"Wave-2 review (2026-09-19), LOW/design note. document_projections only ever sends CategoryTreeUpdate::Categories(..). CategoryTreeUpdate::Items, CategoryTreeUpdate::Axes and GridUpdate::Invalidated have no emitter anywhere in the tree.\n\ndesign-principles 'nothing speculative': they are consumer-side machinery justified by prose rather than by a producer. Either emit them (Items and Axes are the fine-grained updates the producer would want once category-tree traffic matters) or record in the protocol why they exist ahead of a producer -- Invalidated has a stated meaning ('contents stale, frame unchanged') that the diff-based producer never needs, which is a legitimate reason to keep it, but it should be written down.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:42Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:04:42Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-jxp3","title":"Sessions::connect rebuilds ViewState instead of cloning, so a reconnect snapshot is inexpressible","description":"From improvise-cqq (2026-09-19). The producer agent worked from a branch where ViewState was not Clone, so Sessions::connect CONSTRUCTS the Snapshot's view a second time rather than cloning the session's. At connect time the two are identical by construction, so there is no live defect -- but it means a snapshot of an ALREADY-RUNNING session cannot be expressed, which is exactly what a reconnect or a late-joining second client needs.\n\nViewState now derives Clone + PartialEq (added on the wave-1 review-fix branch so Snapshot could have a round-trip test), so the blocker is gone. Change connect to clone the session's view, and add a test that snapshotting a session which has already folded some commands reproduces that session's state -- not merely a default one. That test is the point: the current code would pass any test taken at connect time.\n\nRelates to invariant(bootstrap_is_a_compaction): the seed is already the diff-from-nothing via Dirty::EVERYTHING, so only the view half needs this.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:56:07Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:53:05Z","closed_at":"2026-09-19T21:53:05Z","close_reason":"Fixed in the same commit (8475b2d, PR #10) as Copilot's second finding on that PR. The comment in Sessions::connect justified rebuilding the snapshot's view by claiming ViewState is not Clone -- which stopped being true when it gained the derive on the wave-1 branch so that Snapshot could have a round-trip test (gotcha 25). The snapshot now CLONES the mirror in both connect and rebase, which is also the correct shape: reseed is what decides what a freshly seeded mirror holds, and a client told anything else starts out disagreeing with the server's picture of it. That closes this ticket's actual point -- a snapshot of an already-running session is now expressible.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-i2m5","title":"improvise-io still carries its own pipe-quoting copy, without the keyword guard","description":"Follow-up from the wave-1 review fixes (2026-09-19). Pipe quoting now has one home -- improvise_formula::quote (quote_name/is_bare_name) -- and improvise-gp re-exports it. improvise-io/src/persistence/quoting.rs still has its own pub(super) is_bare_name/escape_pipe/quote_name/pipe_quote, and that copy lacks the keyword guard. It also holds a second copy of the UNQUOTING rule (unescape_pipe vs parser.rs::unquote_pipe).\n\nSwitching io to improvise_formula::quote_name is not purely mechanical: it would change .improv output for a category or item literally named SUM/WHERE/etc to |SUM|. That still parses (improv.pest accepts pipe_quoted), and the persistence round-trip tests will show the delta -- so the change is safe but visible, and someone should look at the diff rather than assume.\n\nDecide the keyword guard deliberately. It is NOT a correctness fix: probing showed the grammar accepts bare keywords in every position ('WHERE = A', 'X = SUM', 'SUM(WHERE)' all parse). It was kept in improvise-formula because that acceptance is an emergent property of PEG ordering rather than a stated rule, and a pipe-quoted name is an identifier everywhere with nothing to fall through. Cost is cosmetic.","notes":"\n--- CONFIRMED BY THE USER (2026-09-19) ---\n\"Pipe quoting is fine.\" The keyword guard as canonical behaviour is a USER\nDECISION, not an agent preference. So .improv writing |SUM| for a category or\nitem literally named SUM stays, and this is not to be revisited as\nover-caution by a later reader who notices that bare keywords happen to parse.\n\nThey do parse -- agg_call needs a ( and PEG backtracking falls through to\nref_expr -- but that is rule ordering rather than a stated rule, and a\n## Formulas line carries formula text verbatim, so a name format_md writes is\nhanded straight back to parse_formula. One namespace, one spelling rule.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:50:39Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:10:02Z","closed_at":"2026-09-19T21:21:38Z","close_reason":"improvise-io/src/persistence/quoting.rs deleted entirely. improvise_formula::quote is now the ONE home and owns both halves: is_bare_name/quote_name (writing a name, keyword guard included), pipe_quote (unconditional, for .improv text cells that must not read back as numbers), and unescape_pipe_body (reading either back). parser.rs::unquote_pipe is now a 3-line shim over it, down from a 26-line duplicate.\n\nKEYWORD GUARD MADE CANONICAL, with a reason beyond taste: a ## Formulas line carries formula text verbatim, so a name format_md writes is later handed to parse_formula -- one namespace, so one spelling rule, and the one that does not lean on emergent PEG backtracking is the safer canon. USER-VISIBLE FORMAT DELTA, measured not assumed: a characterization test was written FIRST and confirmed passing on the old code, then was the only failure in the workspace after the switch. .improv now writes |SUM| for a category or item literally named SUM, on axis lines, item lists and data coords. Section headers are unchanged (see the follow-up ticket).\n\nUNQUOTING DIVERGENCE ANSWERED: the two old copies agreed on every input either grammar's pipe_inner can produce. They differed on exactly one unreachable input, a trailing lone backslash, where io returned it and parser.rs panicked via .expect(GRAMMAR_INVARIANT). The total (io) behaviour is canonical and pinned by a_trailing_lone_backslash_is_kept, on the grounds that a caller arriving by a non-grammar route should get its bytes back rather than a panic. 5 tests added.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ipgl","title":"The pipe-quoting rule now has three copies, and two of them already differ","description":"From the wave-1 gp-tree review (2026-09-19). design-principles.md §3 calls this convention 'shared between the .improv persistence format and the formula parser's identifier syntax' -- one rule. It is now spelled three times:\n * improvise-io/src/persistence/quoting.rs:4-56 (is_bare_name/escape_pipe/quote_name), pub(super)\n * improvise-formula's tokenizer\n * improvise-gp/src/lib.rs:86-112 (new)\nThe gp copy was FORCED, not lazy -- the io one is pub(super) and genuinely unreachable. But the copies already diverge: gp guards the seven keywords (WHERE/SUM/AVG/MIN/MAX/COUNT/IF), io does not.\n\nNatural single home is improvise-formula, which both improvise-io and improvise-gp already depend on. Moving it there also settles which behaviour is canonical -- decide the keyword guard deliberately rather than by which copy you happened to read.","notes":"\n--- FIXED, AWAITING MERGE (2026-09-19) ---\nThe fix is on branch wave-1-review-fixes and is in PR #9\n(https://github.com/fiddlerwoaroof/improvise/pull/9), which is open pending\nCopilot review. Moved to in_progress rather than left open so nobody picks\nthis up again -- see the agent-issue-drift-pattern memory. Close it when #9\nmerges, not before.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:43:13Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:37Z","started_at":"2026-09-19T21:15:05Z","closed_at":"2026-09-19T21:50:37Z","close_reason":"Merged to main in PR #9 as e1acac691d70992b5dfcce9f11ce2c2b50fa1c29 (2026-09-19). Verified a real merge commit with both parents, not a squash, so all 18 branch commits are reachable on main per AGENTS.md's preference for merges. Copilot's second review on that PR: approval recommended, findings none. All eight non-skipped CI checks green, including claude-review. Synced to origin and gitea.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-8x9o","title":"Vendored SplitMix64 has no known-answer test","description":"From the wave-1 gp-tree review (2026-09-19). CONFIRMED by fault injection: changing the generator's gamma constant from 0x9E3779B97F4A7C15 to ...7C17 left 27/27 tests passing.\n\nimprovise-lzn's whole promise (gp-tree/src/lib.rs:58-68) is that 'a bug report must be reconstructible from its seed alone'. Nothing in the workspace pins the generator's output, so a future cleanup of the vendored RNG silently invalidates every seed already printed by a fit and every Model.seed already saved -- with no test failing. This is doctrine(check_the_checker) exactly: the guarantee nobody has seen fail.\n\nFIX: three lines -- a known-answer vector, e.g. assert SplitMix64::new(0).range(0, 1_000_000) equals its current value, and one more for a non-zero seed.\n\nRelated, LOW: the determinism pair (the_same_seed_fits_the_same_formula / a_different_seed_fits_a_different_formula) does not by itself prove the search MOVED -- a GA whose breed did nothing would still differ across seeds because the initial random_tree differs. It happens to be true (instrumented: best cost 3.55 -\u003e 1.80 and 6.46 -\u003e 0.76 over 20 generations), but assert it: compare the fit against the generation-0 best.","notes":"\n--- FIXED, AWAITING MERGE (2026-09-19) ---\nThe fix is on branch wave-1-review-fixes and is in PR #9\n(https://github.com/fiddlerwoaroof/improvise/pull/9), which is open pending\nCopilot review. Moved to in_progress rather than left open so nobody picks\nthis up again -- see the agent-issue-drift-pattern memory. Close it when #9\nmerges, not before.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:43:12Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:37Z","started_at":"2026-09-19T21:15:04Z","closed_at":"2026-09-19T21:50:37Z","close_reason":"Merged to main in PR #9 as e1acac691d70992b5dfcce9f11ce2c2b50fa1c29 (2026-09-19). Verified a real merge commit with both parents, not a squash, so all 18 branch commits are reachable on main per AGENTS.md's preference for merges. Copilot's second review on that PR: approval recommended, findings none. All eight non-skipped CI checks green, including claude-review. Synced to origin and gitea.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ki7c","title":"EnterMode writes a runtime-named buffer without going through write_buffer","description":"From the wave-1 review (2026-09-19). Latent, not live.\n\nimprovise-gdl made cmd::core::write_buffer(name, value) the single accessor for a RUNTIME-NAMED buffer, so that the 'search' name routes to SetSearchQuery instead of a dead map entry. But cmd/mode.rs's EnterMode still builds effect::SetBuffer { name: mb.buffer_key.to_string(), .. } directly. Every other remaining direct SetBuffer uses a LITERAL name ('command', 'export', 'edit') that is statically not 'search', so EnterMode is the one site that writes a name it does not know at compile time and bypasses the router.\n\nUnreachable today because no AppMode carries buffer_key: \"search\". Concrete failure when that changes: the natural follow-up to 'SearchMode is Normal + a flag' (gotcha 11/14) is a real AppMode::Search { minibuf: ... buffer_key: \"search\" }. EnterMode would then clear buffers[\"search\"] while append-char/pop-char read and write view_state.search_query -- so entering search mode would leave the PREVIOUS query on screen. Exactly the desync gdl closed, reopened from the other side.\n\nFIX: one line -- effects.push(write_buffer(\u0026mb.buffer_key, String::new())) in EnterMode.","notes":"\n--- FIXED, AWAITING MERGE (2026-09-19) ---\nThe fix is on branch wave-1-review-fixes and is in PR #9\n(https://github.com/fiddlerwoaroof/improvise/pull/9), which is open pending\nCopilot review. Moved to in_progress rather than left open so nobody picks\nthis up again -- see the agent-issue-drift-pattern memory. Close it when #9\nmerges, not before.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:42:38Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:35Z","started_at":"2026-09-19T21:15:03Z","closed_at":"2026-09-19T21:50:35Z","close_reason":"Merged to main in PR #9 as e1acac691d70992b5dfcce9f11ce2c2b50fa1c29 (2026-09-19). Verified a real merge commit with both parents, not a squash, so all 18 branch commits are reachable on main per AGENTS.md's preference for merges. Copilot's second review on that PR: approval recommended, findings none. All eight non-skipped CI checks green, including claude-review. Synced to origin and gitea.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-odi4","title":"Nothing ties AppMode::hint_text to the actual keymap","description":"From the wave-1 review (2026-09-19). only_quit_has_no_hint asserts a hint is PRESENT; nothing asserts it is ACCURATE. Rebinding a mode's keys leaves its hint silently lying to the user -- the same class of silent drift that doctrine(binding_dump_diff) exists for.\n\nConcretely: improvise-1c9 added 'Enter:export Esc:cancel — type a file path' to ExportPrompt. The reviewer confirmed it matches the real keymap today (Enter =\u003e SEQ[commit-export, clear-buffer export], Esc =\u003e SEQ[clear-buffer export, enter-mode normal]). Nothing keeps that true.\n\nFIX: for each mode, split its hint on whitespace and for every 'Key:verb' token assert keymap.lookup(key, NONE).is_some() in that mode. Prove it fires by rebinding or removing a key named in a hint.\n\nCosmetic while there: every other hint uses two spaces around the em dash; the new ExportPrompt one uses one.","notes":"\n--- FIXED, AWAITING MERGE (2026-09-19) ---\nThe fix is on branch wave-1-review-fixes and is in PR #9\n(https://github.com/fiddlerwoaroof/improvise/pull/9), which is open pending\nCopilot review. Moved to in_progress rather than left open so nobody picks\nthis up again -- see the agent-issue-drift-pattern memory. Close it when #9\nmerges, not before.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:42:37Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:35Z","started_at":"2026-09-19T21:15:03Z","closed_at":"2026-09-19T21:50:35Z","close_reason":"Merged to main in PR #9 as e1acac691d70992b5dfcce9f11ce2c2b50fa1c29 (2026-09-19). Verified a real merge commit with both parents, not a squash, so all 18 branch commits are reachable on main per AGENTS.md's preference for merges. Copilot's second review on that PR: approval recommended, findings none. All eight non-skipped CI checks green, including claude-review. Synced to origin and gitea.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ksst","title":"A gp-tree fit records its seed but not its GA config","description":"Follow-up from improvise-lzn (2026-09-19). data::Model now records the seed, so a fit looks reproducible, but the GA configuration (population, generations, elite count, penalty) is not recorded with it. A saved model is therefore only reproducible if the caller independently remembers the config -- and nothing says so, which is the trap: the seed's presence implies a completeness that is not there.\n\nEither record the config alongside the seed in data::Model, or document at the seed field that it is necessary but not sufficient.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:38:55Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:23:03Z","closed_at":"2026-09-19T21:23:03Z","close_reason":"Model::config: Option\u003cFitConfig\u003e recorded alongside the seed -- the preferred option, record rather than document. Both stay Option for the same reason: every FitConfig value is legal, so none can stand for unknown, exactly as 0 is a legal seed. The seed doc now says it is necessary but not sufficient and points at config. FINDING worth keeping: the first injection -- deleting serde(default) from config -- did NOT fail any test, because serde already treats an Option field as optional. That attribute is documentation, not the mechanism, and the PRE-EXISTING a_model_saved_before_seeds_still_loads added earlier this session is equally non-discriminating against the fault it appears to guard. The injection was re-aimed at faults that matter: an absent config loading as the DEFAULT rather than as unknown (caught), and one FitConfig field silently not persisted (caught). Per populate_before_roundtrip the fixture differs from Default in every field, and the round trip asserts the loaded config is not the default, so a silent substitution cannot pass.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-szrt","title":"Wire improvise-gp into improvise (nothing depends on it yet)","description":"Follow-up from improvise-izk (2026-09-19). crates/improvise-gp exists and is tested but has no consumers -- it is a bridge with nothing crossing it. The next step is a user-facing path: fit a tree from the current model/view and install the emitted formula, presumably as a command in the Cmd/Effect layer.\n\nDesign constraints when doing it: the Cmd must stay pure and return effects (the fit itself is expensive, so decide whether it belongs in an Effect or off the main loop), and the emitted formula must go through the same add_formula path as a hand-typed one so _Measure target handling stays identical.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:38:55Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:38:55Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-d44s","title":"gp_tree::data::preprocess writes to stderr from library code","description":"Follow-up from improvise-0yr (2026-09-19). preprocess() eprintln!s '(column X is entirely missing — skipped)'. That was correct in examples/fit_csv.rs but it is now library code, and a library deciding to write to the user's stderr is a layering problem -- the caller cannot suppress, redirect or localize it.\n\nLeft in place deliberately during 0yr so the extraction stayed byte-identical (the baselines it was verified against include that output). Fix by returning the skipped columns as data -- a warnings vec on Dataset, mirroring how improvise-io's build_model_with_warnings already surfaces import warnings rather than printing them.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:38:54Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:22:56Z","closed_at":"2026-09-19T21:22:56Z","close_reason":"Dataset gains pub warnings: Vec\u003cString\u003e; preprocess pushes the skipped-column notice instead of printing it, and fit_csv.rs prints the vec at the same point so the CLI output does not move. Mirrors improvise-io build_model_with_warnings, which the ticket named as the shape -- a Warning enum was rejected because design-principles enums-over-strings is about state, not human-readable notices. Bug-first: the test failed to COMPILE before the fix (no field warnings on Dataset, 5 sites). Fault injected: replaced the push with a discarded format!, so the column is still dropped but silently -- an_entirely_missing_column_is_skipped_and_reported_as_data failed (left 0, right 1) while 24 others stayed green. CLI output verified byte-identical across 9 scenarios.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-z5eg","title":"Gate improvise-gp in the CI wasm job","description":"Follow-up from improvise-izk (2026-09-19). The new crates/improvise-gp is not in the wasm job in .github/workflows/ci.yml. Its dependency set (gp-tree + improvise-formula) should be wasm-clean, but 'should be' is exactly what the job exists to stop us asserting -- see invariant(compile_check_is_not_runtime_proof) and the ywd precedent. Add it to the job and confirm, or record why it is deliberately excluded.","status":"closed","priority":3,"issue_type":"chore","owner":"el-github@elangley.org","created_at":"2026-09-19T20:38:54Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:15:51Z","closed_at":"2026-09-19T21:15:51Z","close_reason":"Added improvise-gp to the wasm job in .github/workflows/ci.yml. Verified by compiling first rather than asserting: cargo check -p improvise-gp --target wasm32-unknown-unknown finishes clean -- the review that filed this could not verify it (no wasm target installed there) and marked it SUSPECTED. Gated with a comment saying it is NOT in the browser bundle (nothing depends on it yet) but is a candidate -- fitting a tree in the browser -- and that staying wasm-clean is free now and expensive to retrofit. That answers the ticket's 'or record why it is deliberately excluded' branch by choosing the other one.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-heqk","title":"Lift parse_md's inner helpers to module scope in persistence/parse.rs","description":"Follow-up from improvise-nr5 (2026-09-19), explicitly out of scope there because nr5 was a pure relocation and this is a real restructuring.\n\nAfter the split, crates/improvise-io/src/persistence/parse.rs is 1378 lines, of which ~950 are the four pre-existing test modules. The remaining parse_md is ~300 lines of nested tree-walk with its helpers (next, first_str, extract_name, extract_name_pair) declared INSIDE the function body, so the parser cannot be reviewed section by section.\n\nLift those helpers and the per-section walkers to module scope. The pest grammar stays the single source of truth and must not change; the characterization_survives_the_move doctrine applies -- the 97 persistence tests must carry across unchanged in meaning.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:36:45Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:21:37Z","closed_at":"2026-09-19T21:21:37Z","close_reason":"Lifted to module scope: next, first_str, extract_name, extract_name_pair, and the PCategory/PView collectors; added one walker per top-level grammar section (walk_category_section, walk_formulas_section, walk_data_section, walk_view_section and friends) plus parse_tree and section_title. parse_md is now a three-line composition: build_workbook(walk_file(parse_tree(text)?)?). improv.pest untouched.\n\nBehaviour preservation proven in the strongest form available: only lines 1-332 were replaced, and the four test modules are BYTE-IDENTICAL afterwards (diff of the old tail against the new differs by one leading blank line). No test needed a new assertion because no test line changed at all. invariant(indexmap_order) holds -- PCategory::items is still a Vec replayed in file order, now documented on the field. No tests added deliberately: adding them would have muddied the byte-identical proof.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3qz2","title":"Cloud-session tooling: session-start hook, code-review skill, nix input seeding","description":"Retroactive record of PR #7 (merged into main as f01a07a on 2026-09-19, branch claude/cloud-agent-tooling). Cloud sessions could not reach the tracker or build the project: nix develop needs flake inputs that are not fetchable there, and a session had no way to pick up bd state at start. Filed during the 2026-09-19 groom because the work landed with no issue of its own.","design":"Four artifacts: .claude/hooks/session-start.sh (bd prime at session start, with a tracker swap that has to be safe on every failure path), .claude/skills/code-review/SKILL.md (review skill grounded in context/design-principles.md + kb.pl; renamed from design-review so Copilot picks it up), scripts/nix-seed-flake-inputs.sh (seeds locked flake inputs over git so nix develop works in a cloud session without touching the flake), context/cloud-agents.md (what the cloud environment can and cannot reach). Five hardening commits followed review: pin the bd installer, stop hiding jq failures, guard mktemp, if/else for the cargo build check (SC2015), and a documented deliberate SC2016.","notes":"The durable lesson is in kb.pl as doctrine(force_the_failure) (84c3c70): four unchecked statuses on that branch each produced a usable-looking value on failure — bd init leaving an empty database, mv landing the new tracker inside the old directory, jq's status swallowed by a process substitution, and a failed mktemp turning $TMP/$NAME into an absolute path at the filesystem root. Reading the failure paths found none of them; stubbing mv and mktemp to fail did.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T19:49:34Z","created_by":"Edward Langley","updated_at":"2026-09-19T19:49:34Z","closed_at":"2026-09-19T19:49:34Z","close_reason":"Merged as f01a07a (PR #7, plus #8 review follow-up). Filed retroactively 2026-09-19 during the groom; the work predates the ticket.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-sef","title":"Audit and repair prolog-sessions/kb.pl, add a maintained consistency check","description":"Retroactive record of work already landed (a406b52, 3a58d3c, 3697171, 2026-08-30/31). kb.pl had been appended to every session without anyone checking that it loads or reasons correctly; it needed an audit, and then a mechanical check so the same drift cannot accumulate again.","design":"Two unsound rules: may_depend_on/2 compared a crate against a concern and left its second argument unconstrained (so may_depend_on(improvise_core, banana) succeeded), and before_fixing/2's A=C guard bound a fresh variable and constrained nothing. Replaced with concern/1 + may_use/2 + crate_depends_on/2 facts read from the Cargo.tomls, and illegal_dependency/2 for inverted layering. The MCP runtime's own predicates (sleep, js_eval, future, await_any) had been captured into the file by a saveSession and were removed. kb_report/0 and kb_consistent/0 enumerate kb_error/1 over four classes: domain integrity, layering, rule soundness (a rule must REJECT a sentinel from no declared domain), and functional keys. Wired into CI as a third job.","notes":"Proving the checker fires found two bugs in the checker itself: depends_transitively/2 looped forever on the very input illegal_dependency/2 exists to detect (now carries a visited set), and trealla rejects the unparenthesized :- discontiguous form that the MCP server had silently tolerated. Every check class was verified by injecting the fault it targets. 3697171 additionally recorded the backlog-grooming method as kb facts: cycle detection, priority inversion, stale file:line citations and dissolved premises are mechanically checkable; prose mentions of other issues are not (of 21 one-way mentions, 2 were real dependencies).","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-19T19:34:39Z","created_by":"Edward Langley","updated_at":"2026-09-19T19:34:47Z","closed_at":"2026-09-19T19:34:43Z","close_reason":"Landed in a406b52 (rule repair + shim removal), 3a58d3c (kb_report/kb_consistent + CI job, with every check class fault-injected), 3697171 (backlog-grooming method recorded). Filed retroactively 2026-09-19 while reconciling commits against the tracker; the work itself predates the ticket.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-fuj","title":"Two effects read files directly instead of going through Storage","description":"Found during the wasm audit (improvise-ywd). improvise-6mq put persistence behind a Storage trait, but two effects still call std::fs directly and so bypass it:\n\n crates/improvise-command/src/ui/effect.rs:931 StartImportWizard -\u003e std::fs::read_to_string\n crates/improvise-command/src/ui/effect.rs:1052 LoadModel -\u003e std::fs::read_to_string\n\nBoth COMPILE for wasm32-unknown-unknown — std::fs exists on that target — but they fail at runtime in a browser, which is exactly the class of bug a cargo check gate cannot catch. That is why they are filed rather than fixed under the check-based acceptance criteria of improvise-ywd.\n\nLoadModel is the clearer case: it duplicates what persistence::load already does, and persistence::load_from(\u0026dyn Storage, path) now exists. StartImportWizard reads a JSON file to seed the wizard; it wants the same treatment.\n\nBlocked in practice on how a wasm build gets its Storage instance — App has no Storage today, the free functions persistence::save/load hardwire PhysicalFs. Deciding where the backend is injected (App field? a parameter on the effect? a thread-local?) is the real design question, and it also determines how improvise-i34 (OPFS backend) plugs in.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-30T16:50:43Z","created_by":"Edward Langley","updated_at":"2026-08-30T17:01:09Z","started_at":"2026-08-30T16:57:21Z","closed_at":"2026-08-30T17:01:09Z","close_reason":"Wider than filed. The two effects I named were the visible symptom; the actual gap was that App had no storage backend at all, so Save, SaveAs, ExportCsv, LoadModel and CSV import ALL hardwired the filesystem through persistence::save/load/export_csv and csv_parser::parse_csv.\n\nINJECTION POINT: App::storage: Box\u003cdyn Storage\u003e, chosen by the host at construction. App::new still supplies PhysicalFs so every existing caller and all 14 suites are untouched; App::with_storage takes any other backend. That is the plumbing improvise-i34 and improvise-9x6 need — a worker constructs App with an OPFS backend and nothing else changes.\n\nConsidered the alternatives named on the ticket: a parameter on each effect spreads the backend through every signature for something that is per-session config, and a thread-local hides a dependency that App is the natural owner of. App already owns the other host config (keymap_set, terminal size).\n\nimprovise-io keeps its path-taking wrappers so the native CLI is unchanged, each delegating to a backend- or bytes-taking variant:\n save -\u003e save_to, load -\u003e load_from (from improvise-6mq)\n export_csv -\u003e export_csv_to, via a new render_csv that produces bytes\n parse_csv_with_warnings -\u003e parse_csv_bytes_with_warnings\n\nRESULT: production code in improvise-command now contains no std::fs at all — only tests do.\n\nThe test is the deliverable, not the refactor: the_document_surface_runs_on_a_non_filesystem_backend drives SaveAs, ExportCsv, LoadModel and StartImportWizard over a MemoryFs with no disk touched, and inspects the backend afterwards to confirm the bytes landed there. Any of those effects reaching for std::fs fails it. That is the property a browser backend depends on, verified now rather than discovered later in wasm.\n\nAlso verified: both targets still check clean (native + wasm32 for improvise-io and improvise-command), and the real CLI still imports, runs a headless command, and exports CSV.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-lae","title":"ZZ / :wq with no file path should prompt for a path instead of only erroring","description":"Follow-up to improvise-8tk. That fix stops ZZ/:wq from quitting when the save fails: with no file path the user now stays in the app with 'No file path — use :w \u003cpath\u003e' in the status bar. That is correct but not helpful — the vim reflex is ZZ, and the user has to know to type ':w \u003cpath\u003e' next.\n\nimprovise-8tk's fix sketch suggested opening the export prompt, but AppMode::ExportPrompt commits to ExportCsv (command/cmd/commit.rs::CommitExport) — it writes CSV, not a .improv model. There is no save-as prompt mode; ':w \u003cpath\u003e' goes straight through SaveOrSaveAs (command/cmd/effect_cmds.rs:390).\n\nOptions: (a) add a SavePrompt minibuffer mode built with the TextEntry builder in keymap.rs, committing to SaveAs; (b) make SaveAndQuit emit a mode change to that prompt when ctx has no file path — but CmdContext does not currently expose file_path, so that needs a context field.\n\nPrefer (a) plus a keymap binding; decide separately whether ZZ should route into it automatically.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-08-29T17:34:38Z","created_by":"Edward Langley","updated_at":"2026-08-29T17:34:38Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ai7","title":"Collapse the row/col duplication in view/layout.rs","description":"Supersedes improvise-1wu and improvise-669. Both are the same defect — a row function and a col function that are near-identical — inside the same ~120-line region of crates/improvise-core/src/view/layout.rs, so they are one editing pass, not two.\n\n(1) from improvise-1wu — row_label (layout.rs:327) and col_label (layout.rs:342) are character-identical except for which items list they read. Extract fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String and call it from both. ~14 LOC.\n\n(2) from improvise-669 — the row prune loop (~layout.rs:249) and the col prune loop (~layout.rs:271) both walk a keep_row/keep_col bool vector while tracking data_idx separately from iteration order. The deep review flagged the data_idx tracking as brittle. Extract fn prune_axis_items(items: \u0026mut Vec\u003c_\u003e, keep: \u0026[bool]) so the invariant (data_idx == keep.len() at loop exit) is asserted in one place instead of zero. ~20 LOC. This is the half with actual correctness content: today a bug in the row prune can hide while the col prune stays correct.\n\nWHY TOGETHER: same file, same region, same row/col-symmetry smell, and the same test surface — layout tests would be re-read once instead of twice. Neither is big enough to justify its own session.\n\nDo (2) first; it is the one that can hide a bug. (1) is cosmetic.\n\nNOT included: improvise-rml (consolidate Axis display into methods on Axis) stays separate — different file (view/axis.rs plus the UI widgets) and a different concern (display strings and colors, not layout arithmetic).","notes":"STALE REF (groomed 2026-08-30, line numbers shifted by this session's work): view/layout.rs:249/271/327/342 -\u003e ALL FOUR are stale after improvise-c5v rewrote records mode; 249 and 271 now land inside the new records_columns(). The row/col duplication this ticket is about now lives in prune_empty (crates/improvise-core/src/view/layout.rs:321), which still filters row_items and col_items through two near-identical blocks. Re-verify the scope before starting: c5v deleted the unreachable records branch that used to sit at the end of that function.\nGROOMED 2026-09-19: prune_empty is layout.rs:323 now (the 2026-08-30 note said 321). Confirmed the c5v cleanup: the unreachable records tail is gone (improvise-ede closed today), so the remaining duplication is the row_items/col_items pair inside prune_empty plus row_label/col_label.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-08-14T01:11:07Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:33:46Z","closed_at":"2026-09-19T20:33:46Z","close_reason":"Collapsed the row/col duplication in view/layout.rs into four private free functions over \u0026[AxisEntry]: prune_axis_items (the half with correctness content -- the two hand-tracked data_idx loops in prune_empty now share one debug_assert on the parallel-length invariant, with bounds behaviour preserved via keep.get().unwrap_or(false)), axis_label, data_items (an iterator that replaced six copies of the DataItem filter), and data_index_to_visual. Net -46 lines. Deliberately left asymmetric: records mode entirely (rows are coordinate tuples, columns are measures plus the + column -- real asymmetry, not duplication), the has_value/keep_row/keep_col derivations, row_group_for/col_group_for, and the GridLayout row/col field pairing (unifying would be a speculative abstraction churning every consumer). No test changed meaning; 805 workspace tests pass, clippy clean.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-djn","title":"AddCategory effect silently swallows failures","description":"ui/effect.rs AddCategory does 'let _ = app.model_state.workbook.add_category(\u0026self.0)'. Duplicate names and the 12-regular-category limit are both dropped on the floor, so 'a' in the category panel with 12 categories already present reports 'Added category \"X\"' and adds nothing.\n\nAddItem and AddFormula already write their error to view_state.status_msg, and since improvise-6t3 the commit command emits its optimistic status BEFORE the add effect precisely so the add gets the last word. AddCategory just needs to do the same:\n\n if let Err(e) = app.model_state.workbook.add_category(\u0026self.0) {\n app.view_state.status_msg = e.to_string();\n }\n\nFound while unifying the commit commands (improvise-6t3). Needs a test that applies AddCategory over a full model and asserts the status bar reports the limit.","notes":"GROOMED 2026-09-19: still true; the site is crates/improvise-command/src/ui/effect.rs:71-74 (let _ = app.model_state.workbook.add_category(\u0026self.0)). Noticed while verifying: that effect declares EffectKind::Model(ModelChange::CellData), though adding a category changes the grid geometry — worth a look when fixing this, but not part of this ticket.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-08-01T23:22:54Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:35:55Z","closed_at":"2026-09-19T20:35:55Z","close_reason":"Bug confirmed by a failing test written first: add_category_past_the_limit_reports_the_failure fills to MAX_CATEGORIES and asserts the 13th reports the limit. It failed on exactly the status-bar assertion -- the category was correctly rejected, only the message was lost. One-line fix as the ticket specified; test kept as a regression guard with the bug in its doc-comment.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-toc","title":"Effect::changes_mode() is a static flag but ViewBack/ViewForward/StartImportWizard change the mode conditionally","description":"Effect::changes_mode() is a per-type constant, and its only consumer is ExecuteCommand: 'return to Normal unless a command already changed mode' (command/cmd/text_buffer.rs).\n\nThree effects mutate view_state.mode only on some runs, so no static answer is right:\n- ViewBack / ViewForward (ui/effect.rs) restore frame.mode only when the back/forward stack is non-empty.\n- StartImportWizard (ui/effect.rs) sets AppMode::ImportWizard only when the file parses.\n\nReporting false (today) means the success path is wrong: ':import data.csv' opens the wizard and is then snapped back to Normal, leaving the wizard drawn with the Normal keymap bound. Same for ':view-back' when there IS history. Reporting true would make the failure path wrong: a failed ':import' would strand the user in CommandMode.\n\nEnterEditAtCursor had the same symptom but is unconditional, so it was fixed by overriding changes_mode() (improvise-6sq).\n\nOptions:\n(a) have these effects set an app-level 'mode was changed' flag during apply, and let ExecuteCommand decide after applying rather than before — but ExecuteCommand is a pure Cmd and cannot observe apply.\n(b) split the conditional effects into their decision (in the Cmd, where the stack/file state is visible) and an unconditional ChangeMode effect. ViewBack's stack is on App though, not CmdContext.\n(c) give ExecuteCommand a trailing 'return to Normal if still in CommandMode' effect instead of the compile-time check — an effect CAN read app state at apply time, which is where this decision actually belongs.\n\n(c) looks cleanest and would let changes_mode() be deleted entirely.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-08-01T20:29:19Z","created_by":"Edward Langley","updated_at":"2026-09-19T19:48:50Z","closed_at":"2026-09-19T19:48:50Z","close_reason":"Fixed on the cloud-agent branch merged as f01a07a: a341717 \"settle : command mode by observation, not prediction\" plus e8dcdb1 (Copilot review follow-up). Effect::changes_mode() is gone; ExecuteCommand now ends every batch with ReturnToNormalUnlessMoved(ctx.mode) (effect.rs:556), which drops to Normal only if the mode is still what the batch started in — so ViewBack/ViewForward/StartImportWizard need no up-front answer. repo-map gotcha 31 documents it. Closed during the 2026-09-19 groom; the commits landed without closing the ticket.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-1c9","title":"ExportPrompt mode shows no status-bar hint","description":"AppMode::hint_text() returns \"\" for AppMode::ExportPrompt, so when the user hits the export prompt the status bar goes blank instead of telling them what to type (a path) or how to cancel (Esc).\n\nFound while making hint_text exhaustive in improvise-2hi: the old match had a '_ =\u003e \"\"' arm that hid the gap. The exhaustive match now names ExportPrompt explicitly, and app.rs::only_export_prompt_and_quit_have_no_hint pins the current behavior — update that test when adding the hint.\n\nQuit is also blank, but correctly so: it is never rendered.\n\nFix: give ExportPrompt a hint along the lines of 'Enter:export Esc:cancel — type a file path'.","notes":"GROOMED 2026-09-19: still true, new home — AppMode and hint_text moved to improvise-protocol in improvise-cqi. The empty arm is crates/improvise-protocol/src/lib.rs:218 (ExportPrompt and Quit share it; Quit having no hint is fine).","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-08-01T19:17:44Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:35:55Z","closed_at":"2026-09-19T20:35:55Z","close_reason":"Bug confirmed by a failing characterization test: only_export_prompt_and_quit_have_no_hint became only_quit_has_no_hint and failed with left: [EXPORT, QUIT]. Added the hint in improvise-protocol's AppMode::hint_text: 'Enter:export Esc:cancel — type a file path', with keys matching the actual ExportPrompt keymap (TextEntry + .commit(Enter, commit-export)). Quit keeps its blank arm and the doc-comment now says why.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-7na","title":"Decide whether fitted gp-tree models are persisted alongside the formula","description":"The emitted formula is enough to recompute existing cells, so the .improv file needs nothing extra for the common case. But scoring NEW rows — the --predict path in fit_csv — needs the encoding plan (which columns are categorical, their training-set distinct values, imputation means), which the formula text does not carry.\n\nDecide: (a) formula only, and refitting is the way to score new data; (b) store the gp_tree Model as a sidecar/JSON blob; (c) store the seed + config so the fit is reproducible without storing the tree. Option (c) is cheap and matches the format's plain-text character, but only works if the training data is unchanged. This is a format decision, so settle it before the fit command ships and starts writing files.","acceptance_criteria":"A decision is recorded with its rationale, and the .improv grammar either changes or is explicitly confirmed unchanged.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-08-01T17:25:21Z","created_by":"Edward Langley","updated_at":"2026-08-01T17:25:21Z","dependencies":[{"issue_id":"improvise-7na","depends_on_id":"improvise-izk","type":"blocks","created_at":"2026-08-01T10:26:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-vx3","title":"fit-measure command: fit a tree and add it as a formula","description":"The user-facing surface. A command (:fit-measure \u003ctarget\u003e or similar) that extracts features from the active view, runs the fit, emits the champion as formula text and adds it as a new measure — after which it is an ordinary improvise formula: recomputed by the fixed-point evaluator, editable in the formula panel, saved in the ## Formulas section, drillable.\n\nReport quality alongside it, not just the rule: cross-validated R²/RMSE against the predict-the-mean baseline, and the permutation feature-importance ranking. A decision tree that looks tidy but generalizes no better than the mean is worse than useless — it is a rule the user will believe. Per the command/effect split, the fit belongs in a Cmd (pure over CmdContext) with effects that add the formula and set status.","acceptance_criteria":"(1) A command fits and installs the formula. (2) The formula survives save/load and recomputes. (3) Fit quality vs baseline is reported. (4) Fitting is cancellable or bounded — a GA run must not freeze the TUI.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-08-01T17:25:21Z","created_by":"Edward Langley","updated_at":"2026-08-01T17:25:21Z","dependencies":[{"issue_id":"improvise-vx3","depends_on_id":"improvise-0ai","type":"blocks","created_at":"2026-08-01T10:26:21Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-vx3","depends_on_id":"improvise-4s8","type":"blocks","created_at":"2026-08-01T10:26:22Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-vx3","depends_on_id":"improvise-izk","type":"blocks","created_at":"2026-08-01T10:26:20Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-vx3","depends_on_id":"improvise-lzn","type":"blocks","created_at":"2026-08-01T10:26:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-4s8","title":"Extract a gp-tree feature matrix from a View's records","description":"Turn the active view's data into gp-tree's fixed-width f32 feature vectors: pick a target measure, use the remaining measures as numeric features and the regular categories as categorical features (one-hot, via the preprocessing promoted out of fit_csv). One row per distinct combination of the non-measure coordinates — which is what records mode already enumerates, so GridLayout's records path is the natural source rather than a new traversal.\n\nOpen questions worth settling here: whether hidden items and page filters should scope the training rows (the user's mental model is probably 'fit what I can see'), whether formula-derived measures may be used as features (they can, but a feature that already depends on the target makes the fit trivially perfect and useless — needs a guard), and what to do with Label categories, which are high-cardinality per-row text and would one-hot into thousands of columns.","acceptance_criteria":"(1) A function maps Model + View + target measure to (feature matrix, targets, Spec with names and ranges). (2) Spec ranges are supersets of the actual values — gp-tree's simplify prunes reachable branches otherwise, silently changing outputs. (3) A feature that is the target, or a formula depending on it, is rejected or excluded. (4) Tests over a small fixture model.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-08-01T17:25:20Z","created_by":"Edward Langley","updated_at":"2026-08-01T17:25:20Z","dependencies":[{"issue_id":"improvise-4s8","depends_on_id":"improvise-0yr","type":"blocks","created_at":"2026-08-01T10:26:10Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-lzn","title":"Seeded Rng implementation for gp-tree fits in improvise","description":"gp_tree::Rng is a trait the caller implements, deliberately, so evolution is reproducible and the crate pulls in no RNG. improvise has no PRNG dependency today. Decide between a small vendored deterministic generator (xorshift/PCG, ~20 lines, no new dep) and taking on rand. Reproducibility is not optional here: a fit writes a formula into the user's file, so re-running the same fit on the same data must produce the same rule, and a bug report must be reconstructible from the seed. Whatever is chosen, the seed should be visible to the user and recorded with the fit.","acceptance_criteria":"(1) An Rng impl exists for improvise fits. (2) Same seed + same data + same config yields an identical formula, asserted by a test. (3) The seed is surfaced, not hidden.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-08-01T17:24:34Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:38:43Z","closed_at":"2026-09-19T20:38:43Z","close_reason":"Vendored SplitMix64 as gp_tree::SplitMix64 beside the Rng trait rather than adding a rand dependency -- the same generator was already written out three times, and the requirement is reproducibility, not statistical quality. Seed is surfaced, not hidden: SplitMix64::seed() keeps the starting value, fit_csv takes --seed (decimal or 0x hex) and prints it on every fit, and --save records it in data::Model::seed as #[serde(default)] so earlier models still load (verified by --predict against a pre-change baseline). Determinism tests pair the_same_seed_fits_the_same_formula with a_different_seed_fits_a_different_formula, so 'reproducible' cannot be satisfied by a search that never moved.","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0ai","title":"gp-tree: expose a fit/evolve entry point in the library","description":"The library exposes the GP operators — random_tree, mutate_at, crossover, subtree_at, graft_at, simplify, plus Population, GaConfig and breed — but no loop that drives them. The search itself lives in examples/fit_csv.rs, so every consumer reimplements generation-stepping, elitism, parsimony penalty and stopping. Add a fit entry point taking a dataset, a GaConfig and an Rng, returning the champion (simplified). Keep the k-fold cross-validation and permutation feature-importance reporting available too — those are what make the output trustworthy rather than just plausible.","acceptance_criteria":"(1) A public fit function evolves a champion from data + config + Rng. (2) fit_csv uses it. (3) Same seed and config produce the same tree.","notes":"GROOMED 2026-09-19 — STALE REF: examples/fit_csv.rs does not exist at that path; it is crates/gp-tree/examples/fit_csv.rs (gp-tree is a workspace member, not the root crate).","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-08-01T17:24:33Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:23:04Z","closed_at":"2026-09-19T21:23:04Z","close_reason":"New crates/gp-tree/src/fit.rs (425 lines, 7 tests): FitConfig with Default = the CLI historical numbers, fit() returning the simplified champion across restarts, plus cv_regress/cv_classify/folds/standardize/permutation_importance/mse. Nothing in the module prints -- CV returns pooled prediction/actual pairs and importance a score per feature; tables stay in the CLI, the same boundary d44s drew. fit_csv.rs drops 787 to 598 lines. Premise verified before designing: the generation loop really was Trainer::fit in the example with pop/gens as function-local consts. Fault injected: the generation loop bounded to zero, i.e. a fit returning its seed tree unevolved. The two DETERMINISM tests stayed GREEN -- they would have been fooled, which is why they are not sufficient -- while a_fit_improves_on_the_tree_it_started_from and the_config_changes_the_fit_at_a_fixed_seed failed. Reproducibility verified end to end: same seeds produce the same fits after promoting the loop, predictions and saved model included, so RNG call order is provably unchanged.","dependencies":[{"issue_id":"improvise-0ai","depends_on_id":"improvise-0yr","type":"blocks","created_at":"2026-08-01T10:26:10Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0yr","title":"gp-tree: promote fit_csv preprocessing into the library","description":"examples/fit_csv.rs carries 1261 lines of genuinely reusable preprocessing that no library consumer can reach: per-column type inference (numeric/date/categorical), one-hot encoding of categoricals, mean-imputation with a col.missing 0/1 indicator so absence is learnable, and the Model struct pairing fitted trees with the encoding plan needed to re-encode fresh rows.\n\nimprovise needs all of this to turn a view's records into gp-tree's fixed-width f32 feature vector — categorical dimensions are the norm in a pivot model, not the exception. Move it behind a public API; the example becomes a thin CLI over the library.","acceptance_criteria":"(1) Type inference, encoding, imputation and Model are public API. (2) examples/fit_csv.rs is a CLI wrapper with no preprocessing logic of its own. (3) Existing fit_csv behavior unchanged — same output for the same CSV.","notes":"GROOMED 2026-09-19 — STALE REF: examples/fit_csv.rs does not exist at that path; it is crates/gp-tree/examples/fit_csv.rs (gp-tree is a workspace member, not the root crate).","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-08-01T17:21:15Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:38:43Z","closed_at":"2026-09-19T20:38:43Z","close_reason":"Preprocessing promoted into a public gp_tree::data module (534 lines): ColumnKind, ColumnSpec, Target, Model, Dataset, preprocess, encode_row, read_csv, parse_datetime and friends. examples/fit_csv.rs went 1261 -\u003e 787 lines and now holds only CLI concerns. Two deliberate shape changes: preprocess returns a Dataset struct instead of a positional 5-tuple, and build_date/encode_row share a date_parts helper. Behaviour preservation verified by captured baselines compared before/after -- demo fit, a mixed numeric/date/categorical CSV with a missing cell, the saved model JSON and a --predict run are byte-identical; the only difference anywhere is one panic message, now that it is library code. 12 new tests.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-izk","title":"gp-tree step 1: emit a fitted Tree as an improvise formula string","description":"Pure function turning a gp_tree::Tree plus its Spec into improvise formula text: Split{feat,thresh,lo,hi} -\u003e IF(\u003cname\u003e \u003c \u003cthresh\u003e, \u003clo\u003e, \u003chi\u003e), Leaf(s) -\u003e the number. Feature names come from Spec::names and must go through the same CL-style pipe quoting the formula tokenizer uses, so a feature called 'Net Revenue' or one colliding with a keyword still parses.\n\nLives in a new crate improvise-gp (depends on gp-tree + improvise-formula). It cannot live in gp-tree — that would invert the dependency and drag improvise into a generic GP crate — and it cannot live in improvise-formula, which must not know GP exists.\n\nThis is the proof-of-concept for the whole epic: it needs no GA, no UI, and no feature extraction, and it either works or kills the idea early.","design":"fn formula_text(tree: \u0026Tree, spec: \u0026Spec, target: \u0026str) -\u003e String. Float thresholds need enough precision to round-trip; check what the formula tokenizer accepts for decimals and whether f32-\u003etext-\u003ef64 shifts a boundary case (a threshold that reads back slightly larger flips rows across the split).","acceptance_criteria":"(1) formula_text output parses via parse_formula. (2) Property test over random trees: gp_tree::eval(tree, features) equals Model::evaluate of the emitted formula for the same feature values, within tolerance. (3) Feature names needing pipe quotes are handled. (4) A nested tree emits nested IFs.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-08-01T17:21:14Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:38:42Z","closed_at":"2026-09-19T20:38:42Z","close_reason":"New crate crates/improvise-gp: formula_text(tree,spec,target) emits IF(\u003cname\u003e \u003c \u003cthresh\u003e, \u003clo\u003e, \u003chi\u003e) / leaf numbers, and fitted_formula runs the text back through parse_formula so the emitter checks its own contract. Key finding: threshold precision is load-bearing, not cosmetic -- the grammar's number has no exponent form, so emission is the shortest fixed-point decimal that parses back to the IDENTICAL f64. The shortest-round-trip-to-f32 form is unsafe: 0.7f32 is 0.699999988..., '0.7' parses as a strictly greater f64, and a row whose feature equals the threshold then routes lo in the formula but hi in gp_tree::eval. Named regression test verified to fail against a naive format!. 7 tests incl. 12 awkward pipe-quoted names round-tripped on the parsed Expr::Ref, and 60 random trees x 10 feature vectors evaluated through a real improvise_core::Model vs gp_tree::eval with assert_eq! and no tolerance.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-21d","title":"Epic: Integrate gp-tree — fit interpretable decision trees as computed measures","description":"crates/gp-tree is a workspace member that builds and tests alongside improvise, but nothing in improvise depends on it. Goal: let a user fit a decision tree over the model's own data and land the result as a first-class computed measure.\n\nThe enabling fact: gp-tree's Tree maps 1:1 onto improvise's EXISTING formula AST. Tree::Split{feat,thresh,lo,hi} is IF(feat \u003c thresh, lo, hi) and Tree::Leaf(s) is a number. improvise-formula already parses exactly this shape, nesting included — see parser.rs:765, parse_formula(\"X = IF(A \u003e B, IF(C \u003e D, 1, 2), 3)\"). So a fitted tree can be emitted as formula TEXT and inherit everything the formula layer already does: fixed-point recomputation, persistence in the ## Formulas section, the formula panel, drill-down. No changes to the formula engine, evaluator, or file format.\n\nThree known obstacles, addressed by the child issues: (1) gp-tree's preprocessing (type inference, one-hot encoding, imputation) lives in examples/fit_csv.rs, 1261 lines that library consumers cannot call; (2) the GA search loop lives there too — the library exposes operators (Population, GaConfig, breed) but no fit entry point; (3) gp-tree's Rng is a trait the caller implements by design, and improvise has no PRNG dependency.","design":"New bridging crate improvise-gp depending on gp-tree + improvise-formula + improvise-core, keeping the enforced-boundary graph from improvise-xgl intact: the emitter cannot live in gp-tree (that would invert the dependency) and cannot live in improvise-formula (which must not know about GP). improvise-gp sits above both. The fitted tree's encoding plan is only needed to score NEW rows; the emitted formula alone is enough to recompute existing cells, so persisting the tree itself is a separate decision (see the child on model persistence).","acceptance_criteria":"A user can fit a measure from the model's other measures/dimensions and have the rule appear as an editable, persisted formula.","notes":"Sequenced so the cheapest, highest-signal piece comes first: the Tree-\u003eformula emitter is pure, needs no GA and no UI, and is testable by asserting gp_tree::eval agrees with improvise's eval_formula on the emitted string. That single child proves the whole idea before anyone builds a fitting UI.\nGROOMED 2026-09-19 — STALE REF: examples/fit_csv.rs does not exist at that path; it is crates/gp-tree/examples/fit_csv.rs (gp-tree is a workspace member, not the root crate).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-08-01T17:20:36Z","created_by":"Edward Langley","updated_at":"2026-09-19T19:48:57Z","dependencies":[{"issue_id":"improvise-21d","depends_on_id":"improvise-0ai","type":"blocks","created_at":"2026-08-01T10:26:07Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-21d","depends_on_id":"improvise-0yr","type":"blocks","created_at":"2026-08-01T10:26:06Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-21d","depends_on_id":"improvise-4s8","type":"blocks","created_at":"2026-08-01T10:26:08Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-21d","depends_on_id":"improvise-7na","type":"blocks","created_at":"2026-08-01T10:26:09Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-21d","depends_on_id":"improvise-izk","type":"blocks","created_at":"2026-08-01T10:26:06Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-21d","depends_on_id":"improvise-lzn","type":"blocks","created_at":"2026-08-01T10:26:08Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-21d","depends_on_id":"improvise-vx3","type":"blocks","created_at":"2026-08-01T10:26:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gfq","title":"Import wizard defers the 'no category accepted' failure to the very last keystroke","description":"build_model errors with 'At least one category must be accepted' only when Enter is pressed on NameModel — the final step. An import with no category-classified field (e.g. a single numeric column, or an empty array) walks the user through ReviewProposals, DefineFormulas and NameModel before refusing. Contrast improvise-mzv, which added a pre-check for the opposite failure (too many categories) at the step where the user can still fix it. Same treatment wanted here: block advancing out of ReviewProposals while zero categories are accepted, with a message naming the fix. Noticed during improvise-2wm.","acceptance_criteria":"Advancing out of ReviewProposals with no accepted Category/TimeCategory shows a message and stays on the step; a test covers it alongside the existing category_limit_message tests.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-07-31T07:21:55Z","created_by":"Edward Langley","updated_at":"2026-08-14T01:10:29Z","closed_at":"2026-08-14T01:10:29Z","dependencies":[{"issue_id":"improvise-gfq","depends_on_id":"improvise-907","type":"supersedes","created_at":"2026-08-13T18:10:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-o1c","title":"Import wizard has no way back; Esc discards all field decisions silently","description":"Every browsing step binds Esc to close-the-wizard, and no step binds a back key. A user who reaches ConfigureDates or NameModel and wants to change a field kind must Esc (losing every proposal toggle, formula, and name they entered) and restart the import. This is sharpest with the category-limit guard: advance() refuses to leave ReviewProposals/ConfigureDates telling the user to fix their proposals, but from ConfigureDates there is no key that returns to ReviewProposals. Suggest binding a back key (e.g. Backspace or Left) to a WizardStep::previous(), and/or requiring a confirm on Esc once proposals have been edited. Noticed during improvise-2wm.","acceptance_criteria":"A user can move backwards through wizard steps without losing entered state; Esc's destructiveness is either confirmed or reversible.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-07-31T07:21:42Z","created_by":"Edward Langley","updated_at":"2026-08-14T01:10:30Z","closed_at":"2026-08-14T01:10:30Z","dependencies":[{"issue_id":"improvise-o1c","depends_on_id":"improvise-907","type":"supersedes","created_at":"2026-08-13T18:10:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-c6j","title":"WizardStep::Done is unreachable from the UI","description":"ImportWizard::advance() maps NameModel -\u003e Done, but the key layer never calls advance() on NameModel: Enter there commits the build (BuildWizardModel), which closes the wizard outright. So Done is only reachable from wizard.rs unit tests calling advance() directly. Either drop the variant and its close-on-any-key branch in command/cmd/wizard.rs, or route the successful build through Done so there is a real confirmation step. Noticed while writing characterization tests for improvise-2wm — the test for Done has to force the step by hand.","acceptance_criteria":"Either WizardStep::Done is removed (with its key branch and advance() arm) or a UI path reaches it; no state in the enum is unreachable.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-07-31T07:21:37Z","created_by":"Edward Langley","updated_at":"2026-08-14T01:10:30Z","closed_at":"2026-08-14T01:10:30Z","dependencies":[{"issue_id":"improvise-c6j","depends_on_id":"improvise-907","type":"supersedes","created_at":"2026-08-13T18:10:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ych","title":"Null category values become the literal item \"null\" on import","description":"ImportPipeline::build_model_with_warnings resolves a category value as v.as_str() or else v.to_string(). A JSON null therefore yields the item name \"null\" rather than being treated as missing. The Label branch a few lines below explicitly maps null to \"\", so the two paths disagree on what a null means. Noticed while implementing improvise-5j9 (skipped-record warnings).","acceptance_criteria":"A record with a null category value is either skipped-and-counted (like an absent key) or normalized consistently with the Label path; test covers the chosen behavior.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-07-31T06:02:57Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:33:47Z","closed_at":"2026-09-19T20:33:47Z","close_reason":"Fixed via a single scalar_name(\u0026Value) -\u003e Option\u003cString\u003e helper used by both the Category and Label branches. Value::Null is absence (csv_parser already maps a blank CSV field to Null) so the record is skipped and counted in the existing improvise-5j9 warning; the literal string 'null' stays a real item; numbers/booleans still stringify. Two failing tests written first demonstrated the bug, plus two pinning tests that must not break. NOTE beyond the ticket's letter: empty/whitespace-only strings are now also treated as missing, so JSON '' and a blank CSV field behave alike; revert by dropping the .trim().is_empty() clause in scalar_name and the distinct_values filter. wasm check clean.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2ud","title":"CmdContext copies individual fields instead of borrowing ModelState/ViewState slices","description":"crates/improvise-command/src/command/cmd/core.rs:15-56 + crates/improvise-command/src/ui/app.rs cmd_context(): after the ModelState/ViewState split (improvise-x2c/ew0), CmdContext still copies ~20 individual fields (dirty, search_query, panel flags/cursors, nav stacks) out of the slices. Follow-up: have CmdContext borrow \u0026ModelState/\u0026ViewState (or narrowed sub-views) so new fields don't require threading through cmd_context() and there is one source of truth. Also: display_value is computed eagerly on every context build (app.rs ~388-406) even when the command never reads it — consider lazy/on-demand. Complements improvise-drg (narrow Effect::apply signatures). Found in systematic review 2026-06-09.","notes":"GROOMED 2026-09-19: still true; CmdContext is cmd/core.rs:18-59 (cited 15-56) and still copies 25 fields, including the eagerly-computed display_value.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:05Z","created_by":"Edward Langley","updated_at":"2026-09-19T19:49:15Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gai","title":"AGENTS.md session-completion workflow has bare 'git merge' with no target","description":"AGENTS.md Session Completion step 4 reads 'git merge' with no ref (CLAUDE.md's copy correctly says 'git merge origin/main'). An agent following AGENTS.md literally runs bare git merge, which errors or merges the wrong thing. The two files also duplicate the whole beads/session block with this one divergence — consider a single source of truth. Found in systematic review 2026-06-09.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:03Z","created_by":"Edward Langley","updated_at":"2026-06-10T04:46:48Z","closed_at":"2026-06-10T04:46:48Z","close_reason":"Fixed (test-first where applicable): records_display returns None for OOB columns (f04fe51); try_axis_of added + axis_of invariant documented + cycle_axis no longer panics on unregistered categories (f04fe51); scrolled header suppression fixed via pure show_sublabel helper for both row and column headers (df9a02b); CyclePanelFocus cycles from current panel with wrap (9e02245); AGENTS.md merge target corrected (9634c95).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-wg1","title":"Add GNU Global tags and graphviz artifacts to .gitignore","description":"git status shows untracked GPATH, GRTAGS, GTAGS (GNU Global, generated by the gtags devShell tooling added in 62d18ff) and graph.dot/graph.html/graph.svg at the repo root. None are covered by .gitignore, risking accidental commits. Add: GPATH, GRTAGS, GTAGS, /graph.dot, /graph.html, /graph.svg (or /graph.*). Found in systematic review 2026-06-09.","notes":"2026-06-09: the git-smart-commit hook added these artifacts to .git/info/exclude (machine-local), so they no longer show in status here — but the shared .gitignore still lacks them; issue remains valid for other clones.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:00Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:32:19Z","closed_at":"2026-09-19T20:32:19Z","close_reason":"Added exact-name ignores to the root .gitignore: GTAGS/GRTAGS/GPATH/GSYMS for GNU Global, and root-anchored /graph.dot, /graph.html, /graph.svg for the graphviz artifacts. Deliberately not globs like *.svg, which would swallow checked-in assets. Verified no tracked file becomes ignored: 'git ls-files | git check-ignore --stdin' printed nothing.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-v9w","title":"Remove unused pest/pest_derive from root crate [dependencies]","description":"Root Cargo.toml:38-39 lists pest and pest_derive under [dependencies], but nothing under src/ uses them (grammar parsing lives in improvise-io; examples use pest_meta from dev-dependencies). They are also duplicated under [dev-dependencies]. Remove from [dependencies]; verify with cargo build --workspace \u0026\u0026 cargo test --workspace. Found in systematic review 2026-06-09.","notes":"Also unused: dirs = \"6\" (root Cargo.toml:36). Found while doing improvise-6mq, whose acceptance criterion 3 was 'dirs crate usage is isolated behind a function that can be stubbed for wasm' — there is no dirs usage anywhere under src/ or crates/*/src/ to isolate, so that criterion is vacuous. Same class as the pest/pest_derive removal this ticket already covers; fold it in.\nGROOMED 2026-09-19: still true; line numbers shifted — pest/pest_derive are Cargo.toml:40-41 under [dependencies] and 44-46 under [dev-dependencies] (with pest_meta).","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:53Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:32:18Z","closed_at":"2026-09-19T20:32:18Z","close_reason":"Removed pest, pest_derive and dirs from the root crate [dependencies]. Premise verified by build, not by reading: examples/gen-grammar.rs uses pest_meta but gets it from [dev-dependencies], so cargo build --examples stays green. dirs had no usage anywhere under src/ or crates/*/src/ and was folded in per this ticket's own notes. cargo build --workspace, cargo test --workspace (801 passed), cargo build --examples and the four-crate wasm32 check all clean. Cargo.lock drops only dirs + transitives; pest* remain for improvise-formula/improvise-io.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gdl","title":"SetBuffer effect special-cases the \"search\" buffer name","description":"crates/improvise-command/src/ui/effect.rs:433-440: SetBuffer routes to view_state.search_query when self.name == \"search\", otherwise to the buffers map. Callers must know the magic name; the special case is undiscoverable and weakens the buffer abstraction. Fix: a dedicated SetSearchQuery effect (or fold search_query into the buffers map). Related to improvise-k8h (minibuffer buffer_key strings). Found in systematic review 2026-06-09.","notes":"STALE REF (groomed 2026-08-30, line numbers shifted by this session's work): effect.rs:433-440 -\u003e SetBuffer is now at crates/improvise-command/src/ui/effect.rs:614 (pub struct SetBuffer). The special case is unchanged: SetBuffer still routes name==\"search\" to view_state.search_query.\nGROOMED 2026-09-19: still true; SetBuffer is effect.rs:621-630 now, not 433-440. The comment on the special case still reads \"backward compat\".\n\n--- CORRECTION to this ticket's close reason (2026-09-19 review) ---\nThe close reason said 'no keymap binds it, so no user-visible change'. That is\ntrue only of KEYBINDINGS, and was verified: zero occurrences of\nclear-buffer [\"search\"] across all 9,760 (mode, key, modifier) lookups in all\n16 modes. But ClearBufferCmd is registered with a free-form arg, so\n':clear-buffer search' typed in CommandMode, a script file, and\n'improvise cmd clear-buffer search' all reach it.\n\nSo the behaviour delta is reachable by the user, just not by a keypress: it\nused to park a dead map entry and now blanks view_state.search_query\n(SetSearchQuery is a bare ViewOnly setter with no side effects). Harmless and\narguably more correct -- but do NOT record 'unreachable' as an invariant.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:41Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:42:43Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"Premise true and worse than stated: the 'search' special case was in three places, not one (the SetBuffer effect plus AppendChar and PopChar). Fixed with one cmd::core::write_buffer(name, value) placed under the read_buffer it mirrors; SetBuffer is now a plain map write. Side effect: 'clear-buffer search' now clears the query instead of parking an unread map entry -- it previously worked only by accident of the effect's special case, and no keymap binds it, so no user-visible change.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-jjx","title":"Scrolled grid suppresses first visible row-header label","description":"src/ui/grid.rs:338-350: repeat-suppression shows a row-header sub-label only when ri == 0 or the prefix differs from data_row_items[ri-1]. ri is the absolute data-row index (starts at row_offset, grid.rs:269), so when the view is scrolled the first visible row compares against the off-screen row above and its label is blanked — the user loses the group context at the top of the viewport. Fix: compare against the previous *visible* row, i.e. always show labels for the first rendered data row. Found in systematic review 2026-06-09.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:38Z","created_by":"Edward Langley","updated_at":"2026-06-10T04:46:47Z","closed_at":"2026-06-10T04:46:47Z","close_reason":"Fixed (test-first where applicable): records_display returns None for OOB columns (f04fe51); try_axis_of added + axis_of invariant documented + cycle_axis no longer panics on unregistered categories (f04fe51); scrolled header suppression fixed via pure show_sublabel helper for both row and column headers (df9a02b); CyclePanelFocus cycles from current panel with wrap (9e02245); AGENTS.md merge target corrected (9634c95).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-byz","title":"records_display returns Some(\"\") for out-of-bounds columns instead of None","description":"crates/improvise-core/src/view/layout.rs:186-202: records_display() calls col_label(col), which returns an empty string for out-of-bounds indices; the empty string then fails the == \"Value\" check, is looked up as a category name, and the function returns Some(\"\") via unwrap_or_default(). cell_key() (~line 396) explicitly returns None for an empty col_label — records_display should be symmetric so view/data mismatches are not masked. Found in systematic review 2026-06-09.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:35Z","created_by":"Edward Langley","updated_at":"2026-06-10T04:46:47Z","closed_at":"2026-06-10T04:46:47Z","close_reason":"Fixed (test-first where applicable): records_display returns None for OOB columns (f04fe51); try_axis_of added + axis_of invariant documented + cycle_axis no longer panics on unregistered categories (f04fe51); scrolled header suppression fixed via pure show_sublabel helper for both row and column headers (df9a02b); CyclePanelFocus cycles from current panel with wrap (9e02245); AGENTS.md merge target corrected (9634c95).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-zgb","title":".improv formula loader mis-splits category suffix via rfind(\" [\")","description":"crates/improvise-io/src/persistence/mod.rs:403-411: the formulas_section walker finds the [Category] suffix with raw.rfind(\" [\") + ends_with(']'). Two failure modes: (1) a pipe-quoted identifier at the end of the formula containing ' [' (e.g. |Revenue [USD]|) is mis-split, treating part of the quoted name as the category; (2) a suffix written without a leading space (X=Y[Cat]) is not recognized and the whole line is parsed as a _Measure formula, losing the target category. Fix: move the category suffix into the pest grammar instead of string scanning. Write failing tests first per AGENTS.md. Found in systematic review 2026-06-09.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:32Z","created_by":"Edward Langley","updated_at":"2026-06-10T04:45:53Z","closed_at":"2026-06-10T04:45:53Z","close_reason":"Fixed in 3418162/47ad9e6 (test-first): export_csv via csv::Writer honoring number format; malformed numbers -\u003e CellValue::Error; formula [Category] suffix moved into pest grammar; parse errors report line/col + offending line. 155 io tests green.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"(1) Effect impls that only mutate one slice take the narrower borrow. (2) Cross-cutting effects retain \u0026mut App with a doc comment naming what makes them cross-cutting. (3) All tests pass.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-05-03T05:03:03Z","labels":["human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-06-10T04:05:13Z","closed_at":"2026-06-10T04:05:13Z","close_reason":"Done in commit 0fe04de (2026-06-09 repo-map refresh): AppMode list now reads 16 variants including RecordsNormal and RecordsEditing, and notes MinibufferConfig + the ModelState/ViewState split.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"crates/improvise-command/src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:54Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"simple_effect! macro added beside the Effect trait; eight single-field ViewOnly setters declared through it (-42 lines net). The macro HARD-CODES EffectKind::ViewOnly by design: an effect other clients must see is not 'simple' and keeps a hand-written impl, which is how gotcha 3's no-default-kind rule survives a macro at all. SetSelected (two fields) and SetNumberFormat (a Model change) stay hand-written. Existing effect tests already cover all eight, including view_only_effects_leave_shared_state_alone.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:07Z","closed_at":"2026-08-14T01:11:07Z","dependencies":[{"issue_id":"improvise-1wu","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-669","title":"Extract axis pruning helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:245-262 (rows) and :265-284 (cols) contain near-identical pruning loops that track data_idx separately from iteration order against a keep_row/keep_col bool vector. The data_idx tracking was flagged as brittle in the deep review.\\n\\nAbstraction: fn prune_axis_items(items: \u0026mut Vec\u003cAxisEntry\u003e, keep: \u0026[bool]) -\u003e Vec\u003cAxisEntry\u003e\\n\\nCorrectness win: centralizes the invariant (one place to assert data_idx == keep.len() at loop exit). A bug in the row prune can't hide while the col prune stays correct. ~20 LOC collapsed.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:12Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:08Z","closed_at":"2026-08-14T01:11:08Z","dependencies":[{"issue_id":"improvise-669","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:08Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-aaw","title":"Replace CreateAndSwitchView with Binding::Sequence","description":"command/cmd/panel.rs:546-560 bundles create-view + switch-view + change-mode. Both primitives already exist via effect_cmds.\\n\\nFix: bind Sequence [(create-view nix-shell-env), (switch-view nix-shell-env), (enter-mode normal)] in the keymap. If the auto-generated view name is load-bearing, either thread through a minibuffer (matches other add flows) or keep a single create-view primitive that both creates and switches by default.\\n\\n~12 LOC.","notes":"Groomed 2026-08-13 — considered merging improvise-git, improvise-aaw and improvise-s13 into one 'collapse composite commands into keymap composition' ticket and DECIDED AGAINST IT. They are the same principle (do not name a command AAndB; do not wrap a generic command in a shim) and they touch the same file cluster, so batching them in one session is worthwhile — hence the 'related' links. But merging the tickets would have taken improvise-git's new dependency on improvise-8tk (the ZZ/:wq silent-data-loss bug) and applied it to the whole group, blocking two pieces of work that are ready today behind a P1 bug they have nothing to do with. Keeping them separate keeps s13 and aaw actionable.\n\nIf batching them, do improvise-s13 first (biggest and cleanest, ~40 LOC across four shims), then improvise-aaw (has an unresolved design question about whether the auto-generated view name is load-bearing), and improvise-git last, only after improvise-8tk. Verify all of them in one pass with the (mode, key, modifier) binding-table dump described in improvise-4pg's notes.\nGROOMED 2026-09-19: still true; CreateAndSwitchView is cmd/panel.rs:658, not 546-560.\n\n--- INVESTIGATED 2026-09-19, NOT DONE: premise false ---\nCreateAndSwitchView is NOT a shim. It computes the new view's name from\ncontext -- format!(\"View {}\", ctx.workbook.views.len() + 1) -- and a static\nBinding::Sequence can only carry literal args, so [create-view X, switch-view X,\nenter-mode normal] would hard-code one name and produce duplicates on the\nsecond 'n'. The auto-generated name is load-bearing, so the command has a real\ndecision and is not replaceable by composition as written.\n\nThe ticket's own two fallbacks are both BEHAVIOUR changes needing a product\ndecision, not refactors:\n (a) thread the name through a new minibuffer mode -- that is a feature, and\n it changes what 'n' does in the view panel;\n (b) make the create-view primitive also switch -- that changes :create-view\n for scripts and duplicates what grid.rs already does by hand for the\n _Drill/_Records views.\nNeither was forced. RE-SCOPE this ticket to 'should creating a view from the\npanel prompt for a name?' or close it won't-fix.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:54Z","created_by":"Ed L","updated_at":"2026-09-19T20:36:35Z","dependencies":[{"issue_id":"improvise-aaw","depends_on_id":"improvise-s13","type":"related","created_at":"2026-08-13T18:34:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-git","title":"Replace SaveAndQuit with Binding::Sequence in keymap","description":"command/cmd/mode.rs:232-241 is an 8-line command chaining Save + ChangeMode(Quit). Both primitives already exist.\\n\\nFix: delete the struct; bind via Sequence [(w, []), (force-quit, [])] in the keymap.\\n\\nTrivial application of the 'if the keybinding is A then B, the binding should be a Sequence, not a command named AAndB' principle.\\n\\n~8 LOC.","notes":"Groomed 2026-08-13 — considered merging improvise-git, improvise-aaw and improvise-s13 into one 'collapse composite commands into keymap composition' ticket and DECIDED AGAINST IT. They are the same principle (do not name a command AAndB; do not wrap a generic command in a shim) and they touch the same file cluster, so batching them in one session is worthwhile — hence the 'related' links. But merging the tickets would have taken improvise-git's new dependency on improvise-8tk (the ZZ/:wq silent-data-loss bug) and applied it to the whole group, blocking two pieces of work that are ready today behind a P1 bug they have nothing to do with. Keeping them separate keeps s13 and aaw actionable.\n\nIf batching them, do improvise-s13 first (biggest and cleanest, ~40 LOC across four shims), then improvise-aaw (has an unresolved design question about whether the auto-generated view name is load-bearing), and improvise-git last, only after improvise-8tk. Verify all of them in one pass with the (mode, key, modifier) binding-table dump described in improvise-4pg's notes.\nUNBLOCKED (groomed 2026-08-30): improvise-8tk is closed, and its fix removes this ticket's stated objection rather than merely satisfying it.\n\nThe blocker was recorded as: 'a Sequence has no way to stop between steps, so converting first would cement the ZZ/:wq data-loss bug into the keymap'. That is no longer true. Keymap::dispatch flattens a Binding::Sequence into ONE effect vec applied by a single App::apply_effects call, and the Save effect now sets abort_effects when the save fails — so the abort carries across sequence steps exactly as it does within a command's own effect list. A [w, force-quit] Sequence would stop after a failed save.\n\nVerified by ui/effect.rs::tests::save_without_file_path_aborts_the_chain and failed_save_to_path_aborts_the_chain, plus ui/app.rs::tests::prefix_z_then_z_reaches_save_and_quit, which drives the real ZZ path.\n\nSo the conversion is now safe. Still check improvise-lae first: it may change what ZZ does when there is no file path, which would change what the Sequence should contain.\nGROOMED 2026-09-19: the \"still check improvise-lae first\" sentence in this ticket is a real ordering constraint, now recorded as a dependency edge rather than prose. Stale ref: SaveAndQuit is cmd/mode.rs:243 (cited 232-241).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:48Z","created_by":"Ed L","updated_at":"2026-09-19T19:47:59Z","dependencies":[{"issue_id":"improvise-git","depends_on_id":"improvise-8tk","type":"blocks","created_at":"2026-08-13T18:08:05Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-git","depends_on_id":"improvise-lae","type":"blocks","created_at":"2026-09-19T12:47:58Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-git","depends_on_id":"improvise-s13","type":"related","created_at":"2026-08-13T18:34:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-s13","title":"Delete mode-entry shim commands; use enter-mode with arg","description":"command/cmd/mode.rs:311-319 has EnterExportPrompt and similar shims (EnterSearchMode, EnterTileSelect, EnterFormulaEdit) that are each ~9-line wrappers around change_mode(AppMode::foo()). The generic EnterMode already exists with parse_mode_name() in the registry.\\n\\nFix: delete the shims; bind 'enter-mode' with the mode name as an arg in the keymap.\\n\\nCorrectness win: one code path for mode transitions. A new invariant on mode enter (e.g., 'always rebuild layout') needs to be added in one place instead of N shims.\\n\\n~40 LOC across all shims.","notes":"Groomed 2026-08-13 — considered merging improvise-git, improvise-aaw and improvise-s13 into one 'collapse composite commands into keymap composition' ticket and DECIDED AGAINST IT. They are the same principle (do not name a command AAndB; do not wrap a generic command in a shim) and they touch the same file cluster, so batching them in one session is worthwhile — hence the 'related' links. But merging the tickets would have taken improvise-git's new dependency on improvise-8tk (the ZZ/:wq silent-data-loss bug) and applied it to the whole group, blocking two pieces of work that are ready today behind a P1 bug they have nothing to do with. Keeping them separate keeps s13 and aaw actionable.\n\nIf batching them, do improvise-s13 first (biggest and cleanest, ~40 LOC across four shims), then improvise-aaw (has an unresolved design question about whether the auto-generated view name is load-bearing), and improvise-git last, only after improvise-8tk. Verify all of them in one pass with the (mode, key, modifier) binding-table dump described in improvise-4pg's notes.\nGROOMED 2026-09-19: still true; the shims have moved apart — EnterExportPrompt cmd/mode.rs:326, EnterSearchMode :338, EnterTileSelect :353, and EnterFormulaEdit is in cmd/panel.rs:465, not mode.rs.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:44Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:53Z","closed_at":"2026-09-19T20:35:53Z","close_reason":"Deleted the two genuine shims: EnterExportPrompt and EnterFormulaEdit, rebinding Ctrl-e to 'enter-mode export-prompt' and formula-panel a/n/o to 'enter-mode formula-edit'. Premise was half false -- the ticket named four. EnterSearchMode changes no mode at all (SearchMode is Normal + a flag, not an AppMode) and EnterTileSelect guards on category count and seeds SetTileCatIdx(0); both are real commands and were left alone. Side effect, a fix not a regression: EnterMode clears the target mode's minibuffer, which the shims did not, though EnterFormulaEdit's doc-comment already claimed it did.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-7yx","title":"Close test gaps flagged by deep review","description":"Deep review identified several test-coverage gaps worth closing:\\n\\n1. Keymap fallback chain: command/keymap.rs has 22 tests but no case for 'transient keymap exists but key does not match' (app.rs:396-406).\\n2. Ambiguous date detection: import/analyzer.rs:49-77 first-match heuristic not tested against ['01/02/2025', '02/03/2025'] style ambiguity.\\n3. CSV adversarial inputs: no tests for BOM, CRLF, empty file, single-column file.\\n4. Records missing a category field mid-import: wizard.rs:160-229 has the valid=false/break path but it is not covered by build_model_cells_match_source_data or similar.\\n5. Gzip corruption error path: save_and_load_roundtrip_gzip tests happy path only; add a test that load() on a truncated .gz file surfaces an error.\\n\\nLow-effort, high-value additions. Batch into a single test-coverage PR.","notes":"GROOMED 2026-09-19 — two of the five items are now covered; three remain. (1) Keymap fallback COVERED: ui/app.rs::tests::unmatched_key_after_prefix_is_swallowed, added by improvise-jc8 when the transient keymap became a prefix address. (4) Records missing a category mid-import COVERED: wizard.rs::build_model_record_with_missing_category_value_skipped and build_model_warns_about_records_skipped_for_missing_category. STILL OPEN: (2) ambiguous date detection — analyzer.rs has detect_iso_date_format / detect_us_date_format / detect_no_date_format and no case where two readings both parse; (3) CSV adversarial — rfc4180 quoting and short rows ARE tested; BOM, CRLF, empty file and single-column file are not; (5) gzip corruption — only gzip_round_trips_through_memory_storage and save_and_load_roundtrip_gzip, both happy-path. Also all paths in the description predate the crate split: command/keymap.rs -\u003e crates/improvise-command/src/command/keymap.rs, import/* and persistence/* -\u003e crates/improvise-io/src/.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:19:07Z","created_by":"Ed L","updated_at":"2026-09-19T19:49:03Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-61f","title":"Add invariant test for command registry name mapping","description":"crates/improvise-command/src/command/cmd/registry.rs is 586 lines with 0 tests. Each registration manually duplicates the command name as a string alongside the Cmd::name() implementation on the struct. Nothing verifies they match. Fix: add a test that iterates all registered entries and asserts registry_name == command.name() after construction. Low effort, locks down a class of typo bugs.","notes":"Groomed 2026-06-09: complementary to improvise-9cn (register_cmd! macro). The macro prevents future drift at compile/registration time; this test catches drift that predates or escapes the macro. Doing 9cn first likely shrinks this to a trivial registry-walk test — sequence them together.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:55Z","created_by":"Ed L","updated_at":"2026-08-29T19:10:27Z","closed_at":"2026-08-29T19:10:27Z","close_reason":"Delivered as part of improvise-9cn. See that issue for why the invariant as specified here ('assert registry_name == command.name() for all entries') is necessary but NOT sufficient: a wrong-variant prototype collides with its sibling rather than producing a mismatch, so the roundtrip test passes while a command name vanishes from the registry. Shipped alongside registered_names_are_unique, which is the check that actually reaches that class, plus a keymap-side test that every bound command name resolves. All four verified by fault injection.","dependencies":[{"issue_id":"improvise-61f","depends_on_id":"improvise-9cn","type":"blocks","created_at":"2026-04-16T11:54:34Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-nr5","title":"Split persistence/mod.rs into focused sub-modules","description":"crates/improvise-io/crates/improvise-io/src/persistence/mod.rs is 2402 lines mixing four cohesive units: pipe quoting (lines ~20-82), format_md (~155-282), parse_md (~285-580), export_csv (~581-630). Split into persistence/{quoting,format,parse,export}.rs behind the existing pub facade; no public-API change. Improves navigability and makes adding new format features easier to review.","notes":"GROOMED 2026-09-19 — STALE REFS. Path was written twice: crates/improvise-io/crates/improvise-io/src/persistence/mod.rs -\u003e crates/improvise-io/src/persistence/mod.rs. Size is 2783 lines now, not 2402, and every line range in the description is stale. Current boundaries by symbol (prefer these; they survive edits): quoting = is_bare_name/escape_pipe/unescape_pipe/quote_name/pipe_quote (24-86) plus format_number/parse_number_cell; entry points save/load/save_to/load_from/autosave_path (128-178); format_md (197); parse_md (330); coord_str (630); export_csv/export_csv_to/render_csv (639-700). Tests start at line 706 (#[cfg(test)]) and are roughly three quarters of the file — the split should move each function with its tests.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:52Z","created_by":"Ed L","updated_at":"2026-09-19T20:36:35Z","closed_at":"2026-09-19T20:36:35Z","close_reason":"Split persistence/mod.rs (2783 lines) into six files: mod.rs (259, document lifecycle + facade + test_helpers), quoting.rs (63, the five pipe-quoting helpers as pub(super)), format.rs (350), parse.rs (1378, incl. the four existing test modules moved whole), export.rs (156), roundtrip_tests.rs (635). Two deviations from the ticket sketch, both deliberate: format_number/parse_number_cell went to their own direction's file rather than into quoting.rs, leaving quoting.rs a real shared leaf; and the 34 tests that call BOTH format_md and parse_md got their own file rather than being arbitrarily assigned. Pure relocation verified body-by-body: every function body identical modulo indentation, only signature-level pub(super)/pub changes; persistence test count 97 before and after; 815 workspace tests pass; wasm, clippy and fmt clean. Grammar untouched.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-t8s","title":"View::axis_of panics on missing category","description":"crates/improvise-core/src/view/types.rs:118 uses expect('axis_of called for category not registered with this view'). The invariant is that Workbook::add_category registers every category with every view, but the panic surface is fragile for new callers. Fix: either audit all callers and document the invariant clearly, or change signature to fn axis_of(\u0026self, cat: \u0026str) -\u003e Option\u003cAxis\u003e and let callers handle None.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:48Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:47Z","closed_at":"2026-06-10T04:46:47Z","close_reason":"Fixed (test-first where applicable): records_display returns None for OOB columns (f04fe51); try_axis_of added + axis_of invariant documented + cycle_axis no longer panics on unregistered categories (f04fe51); scrolled header suppression fixed via pure show_sublabel helper for both row and column headers (df9a02b); CyclePanelFocus cycles from current panel with wrap (9e02245); AGENTS.md merge target corrected (9634c95).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-oaq","title":"Redundant file re-read in ImportJsonHeadless effect","description":"src/ui/effect.rs:846 chains 'serde_json::from_str(\u0026std::fs::read_to_string(\u0026self.path).unwrap_or_default()).unwrap_or(serde_json::Value::Array(records.clone()))'. This re-reads and re-parses the JSON file after records were already parsed earlier (lines 799-812). Dead code path from earlier refactor. Fix: remove the redundant read, use the already-parsed records array.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:45Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:39Z","closed_at":"2026-06-10T04:46:39Z","close_reason":"Fixed in f0b9227/77a5124 (test-first): short CSV rows padded with explicit nulls + per-column warnings threaded to status line (stderr on CLI path); wizard pre-checks 12-category limit at advance with actionable message rendered in UI; ImportJsonHeadless parses the file exactly once via extracted json_import_records helper.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-mzv","title":"Import wizard does not pre-check 12-category limit","description":"crates/improvise-io/src/import/wizard.rs:84-150 build_model() fails when \u003e12 categories are proposed, but the ImportWizard UI does not pre-check before allowing the user to confirm. A 20-column CSV imported as 20 categories fails late with 'Category limit exceeded' after the user commits. Fix: validate category count in the UI step and require the user to mark some as Measure/Skip before proceeding. Also add a test for the \u003e12 case.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:44Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:38Z","closed_at":"2026-06-10T04:46:38Z","close_reason":"Fixed in f0b9227/77a5124 (test-first): short CSV rows padded with explicit nulls + per-column warnings threaded to status line (stderr on CLI path); wizard pre-checks 12-category limit at advance with actionable message rendered in UI; ImportJsonHeadless parses the file exactly once via extracted json_import_records helper.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0hb","title":"Make Sequence bindings transactional at command level","description":"Currently Keymap::dispatch (crates/improvise-command/src/command/keymap.rs:261-267) calls each Sequence step's execute(ctx) with the same pre-dispatch ctx. Effects accumulate in order and apply atomically as a batch via App::apply_effects, but later commands in a Sequence cannot observe earlier commands' effects through ctx — only effects-after-effects can see prior mutations.\n\nFor example, the records 'o' sequence [add-record-row, enter-edit-at-cursor records-editing] works only because the EnterEditAtCursor effect itself calls app.rebuild_layout() before reading display_value. Effects can self-heal mid-batch, but commands cannot.\n\nTrue command-level transactionality would require, between Sequence steps: apply effects-so-far, rebuild layout, build a fresh ctx, then dispatch the next command. This would change the semantics of every existing Sequence and is worth its own design pass.\n\nAcceptance: design doc + implementation; existing Sequences keep working; new tests prove a later step sees prior steps' model mutations.","notes":"Surfaced while parameterizing EnterEditAtCursor / CommitAndAdvance to remove is_records() runtime checks (improvise-4ju).\nGROOMED 2026-09-19: still true; Keymap::dispatch is keymap.rs:255-272 now (cited 261-267) and the Sequence arm still feeds every step the same pre-dispatch ctx.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-16T05:45:49Z","created_by":"fido","updated_at":"2026-09-19T19:49:14Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-m91","title":"(Stretch) Command log + native undo/replay","description":"Once Commands are the universal unit of intent, log them per-session and support undo/redo via inverse commands or periodic snapshots. Enabled by the unified reducer; valuable for all three deployment modes but especially natural in the native TUI. Also enables debugging and reproducing bugs: a command log is a deterministic replay.","design":"Two approaches: (1) Inverse commands — each command definition includes how to undo it; undo-stack walks the log in reverse applying inverses. Clean but requires per-command inverse logic. (2) Snapshot-and-replay — periodic snapshots of ModelState + current log index; undo rolls back to nearest snapshot and replays up to target index. Simpler, works for any command, costs memory + replay time. Start with (2) for simplicity. The log is per-session (ClientSession.command_log) and survives only within a session. Persistence to disk as a .improv-journal companion file is a further enhancement. Replay use case: load a file, enable log recording, hit a bug, save the log, bug-report-as-replay.","acceptance_criteria":"(1) Every dispatched Command is logged in the ClientSession (or equivalent server-side state). (2) An Undo Command pops the log and restores state via snapshot+replay. (3) Redo re-applies. (4) Native TUI bindings for Ctrl-Z / Ctrl-Shift-Z. (5) Replay test: record a sequence, reset, replay, assert final state equals record-end state.","notes":"Stretch goal — valuable across all deployment modes but not part of the core architectural unification. Depends on issue improvise-gxi (reduce_full) and ideally on a persistence mechanism (improvise-6mq VFS abstraction) if logs should survive restart.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:02Z","created_by":"spot","updated_at":"2026-07-30T23:06:32Z","dependencies":[{"issue_id":"improvise-m91","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-uq7","title":"(Stretch) Hybrid mode: native TUI with remote browser observer","description":"Once the native binary has the in-process client/server split (issue improvise-e0u), allow a second subscriber to attach via a local ws-server thread. Result: a developer running the native TUI can share their session with a teammate in a browser, or have their own browser tab observing while they work in the terminal. Both subscribers receive projection commands from the same in-process server; commands from either side flow through the same reduce_full.","design":"Native binary gains a --serve PORT flag (or similar) that, when set, spawns a ws-server thread alongside the native client. The ws-server is exactly improvise-q08's server logic, attached to the same in-process App as the native client. Two ClientSessions: one for the local ratatui client (zero-transport), one (or more) for remote websocket clients. Each has its own ViewState + render cache + subscription set. The projection layer fans out effect-driven updates to every session. The two subscribers see independent cursor/scroll/mode state (because view state is per-session) but the same model state (because model is shared). This is the deployment mode that fully justifies per-session view state tracking on the server.","acceptance_criteria":"(1) Native binary has an optional --serve flag that spawns a ws-server in the same process. (2) A remote browser can connect and see the native user's current session (shared model, independent view). (3) Edits from either side show up on the other after a round trip. (4) Graceful disconnect/reconnect of remote clients while native keeps running.","notes":"Stretch goal — not required for the core unification. Depends on issue e0u (binary split done) and improvise-q08 (ws-server exists). Orthogonal to the browser and standalone epics. The architectural investment in per-session view state and subscription-based projections is what makes this possible with minimal additional code.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:52:57Z","created_by":"spot","updated_at":"2026-07-30T23:06:32Z","dependencies":[{"issue_id":"improvise-uq7","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-uq7","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:53:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-9ix","title":"SQLite persistence format (alternative save/restore)","description":"Implement .sqlite as an alternative file format alongside .improv. Tables for categories, items, cells, formulas, views. Works over any VFS backend — native filesystem, OPFS, etc. — because the underlying storage layer is abstracted. Enables structured queries over model state and provides a more scalable container than plaintext for very large models. Orthogonal to the core standalone deployment; filed as a backlog follow-on since the MVP doesn't need it.","design":"Use rusqlite with the 'bundled' feature so sqlite itself compiles into the wasm bundle. Schema: one row per cell keyed by (coord_hash, category_coords...); one row per category with kind + ordering; one row per item with group membership; one row per formula with raw + target. Views stored as JSON or as structured rows. Persistence API mirrors .improv: save_sqlite(storage, path) / load_sqlite(storage, path). Rusqlite on wasm may need custom VFS hookup — investigate sqlite-wasm-rs or similar for browser-friendly builds. Round-trip tests: model→sqlite→model must be identity.","acceptance_criteria":"(1) save_sqlite and load_sqlite functions exist and round-trip cleanly. (2) rusqlite links against the VFS abstraction correctly on both native and wasm. (3) Unit tests for schema, round-trip, and edge cases. (4) File manager UI (if any) offers .sqlite as a save format option.","notes":"Depends on improvise-6mq (VFS abstraction). Independent of the standalone deployment critical path — can be tackled any time after the Storage trait lands. Filed as backlog (P3).","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:41Z","created_by":"spot","updated_at":"2026-09-20T00:08:35Z","closed_at":"2026-09-20T00:08:35Z","close_reason":"DECLINED with measurements, not intuition. No code written; the tree was left untouched.\n\nALL THREE OF THE TICKET CLAIMS WERE TESTED:\n\n(1) \"Works over any VFS backend because the storage layer is abstracted\" -- FALSE. improvise-6mq did not deliver a VFS. Storage is four whole-document operations (read/write bytes, list, delete) with no offsets, seeking, handles or locking; invariant(storage_is_two_ops) states it as doctrine. An sqlite3_vfs needs xRead(offset,len)/xWrite/xTruncate/xLock and only the first two could even be faked -- by rewriting the whole database on every page access.\nBUT the agent PROVED SQLite can still fit before declining: rusqlite with features=[bundled,serialize] round-trips a database through bytes with zero filesystem contact (probe test passed; a one-row DB serializes to 8192 bytes). So the decline rests on VALUE, not on imagined plumbing trouble -- \"declining on a capability that actually exists would have been the wrong decline\".\n\n(2) \"A more scalable container than plaintext for very large models\" -- FALSE, MEASURED on an 18,000-cell model using the ticket own schema, VACUUMed:\n big.improv 1,280,846 bytes\n big.sqlite 1,568,768 +22%\n big.improv.gz 136,264\n big.sqlite.gz 642,897 4.7x LARGER\nFloor case: a 5-table schema holding 3 rows is 24,576 bytes against roughly 150 bytes of .improv -- about 160x. And because writes go through Storage::write(whole bytes), every save still rewrites the entire document, so the one dimension where SQLite normally wins -- incremental page writes -- is unavailable.\n\n(3) \"Enables structured queries over model state\" -- NOT DELIVERED by this design. The artifact is a closed blob; querying means deserializing the whole database into RAM beside the Model that is already fully in RAM. Anything you could ask it you can already ask DataStore::matching_values/matching_cells over interned, indexed data.\n\nALSO: cargo check --target wasm32-unknown-unknown fails in libsqlite3-sys C build. The agent was precise that this is the NIX CC-WRAPPER injecting -fzero-call-used-regs, not proof bundled SQLite can never target wasm -- but it is real evidence the repo wasm CI gate would break the day this landed.\n\nTHE COST THE TICKET DOES NOT PRICE: .improv is grammar-defined and the pest grammar is the single source of truth. A SQLite writer/reader is a SECOND IMPLEMENTATION OF THE WHOLE FORMAT -- groups, formula target categories, hidden items, collapsed groups, number formats, page/filter selections, IndexMap insertion order, the _Measure explicit-vs-formula-target split, virtual-view suppression -- which must track every future grammar change forever. And the ticket own acceptance test (model-\u003esqlite-\u003emodel identity) CANNOT DETECT DRIFT from .improv, because it never compares the two. That missing cross-format test is what made the ticket look cheap.\n\nFormat dispatch and naming were both checked and are NOT the problem: save_to/load_from are the only chokepoints, SQLite 16-byte header is a cleaner discriminator than the existing leading-brace JSON sniff, and .improv.sqlite would follow invariant(gzip_is_a_name_property).\n\nSALVAGE FILED SEPARATELY: the one claim with standing is INTEROP -- opening a model in sqlite3 or DB Browser. That is EXPORT, not persistence.","dependencies":[{"issue_id":"improvise-9ix","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:34Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-avy","title":"Formula tokenizer: support quoted identifiers for ambiguous names","description":"The formula tokenizer currently uses heuristics to handle multi-word identifiers (greedy space consumption with keyword/operator break rules). This is fragile — we just fixed a bug where WHERE inside aggregates was consumed as part of an identifier.\n\nA more robust approach: support SQL/CL-style quoted identifiers. Two candidate syntaxes:\n- SQL style: double-quotes for identifiers, e.g. \"Total Revenue\" = \"Base Revenue\" + Bonus\n- CL style: pipe-delimited, e.g. |Total Revenue| = |Base Revenue| + Bonus\n\nSQL double-quoting is natural since the formula syntax already uses quotes for WHERE filter string literals. Disambiguation: in expression position, a quoted string becomes Token::Ident; in WHERE value position, it becomes Token::Str (parser already distinguishes these contexts).\n\nThis would let us simplify the tokenizer's space-handling heuristics and cleanly support category/item names that collide with keywords (WHERE, SUM, IF, etc.).","status":"closed","priority":3,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:45:27Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:48:41Z","closed_at":"2026-04-09T06:48:41Z","close_reason":"Implemented pipe-quoted identifiers |...| in the formula tokenizer. Pipes produce Token::Ident, work in expressions, aggregates, and WHERE clauses. split_where and parse_where also updated. 6 new tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0zf","title":"Bug: WHERE inside aggregate parens broken by greedy identifier tokenizer","description":"The tokenizer treats multi-word identifiers greedily — 'Revenue WHERE' becomes a single token. This means SUM(Revenue WHERE Region=East) doesn't parse correctly. The WHERE keyword is consumed as part of the identifier. Top-level WHERE (outside parens) works because split_where handles it before tokenization. Fix: either stop allowing spaces in identifiers inside parens, or detect WHERE as a keyword break point in the tokenizer.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:06:15Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:08Z","closed_at":"2026-04-09T06:38:08Z","close_reason":"Fixed tokenizer to break multi-word identifiers at keywords (WHERE, SUM, AVG, MIN, MAX, COUNT, IF). Two-pronged: (1) break when current identifier IS a keyword, (2) break when next word IS a keyword. SUM(Revenue WHERE Region=East) now parses correctly.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-w82","title":"Test audit: persistence/mod.rs at 82% coverage","description":"persistence/mod.rs is at 82% - just above the floor. Missing coverage for edge cases: empty models, special characters in names, view state persistence details, and formula preservation corner cases.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:46Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:15:10Z","closed_at":"2026-04-09T06:15:10Z","close_reason":"Added 16 tests covering save/load roundtrip (plain + gzip), autosave_path, export_csv, collapsed groups, page-without-selection, none axis, number format, text values, multiple views, and full feature roundtrip. Coverage from 82% to 97%.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-kq6","title":"Test audit: formula/parser.rs at 76% coverage","description":"formula/parser.rs is at 76% line coverage, just under the 80% target. Missing coverage for MIN, MAX, COUNT aggregate functions, complex nested expressions, and error/edge case paths.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:44Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:10:12Z","closed_at":"2026-04-09T06:10:12Z","close_reason":"Added 19 tests covering MIN/MAX/COUNT aggregates, comparison operators, power/unary/mul/div, WHERE with quotes, error paths, multi-word identifiers, aggregate-name-as-ref edge case. Coverage from 76% to 89%. Found bug: WHERE inside aggregate parens broken by greedy tokenizer (improvise-0zf).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3tj","title":"3.3 Tag v0.1.0 release","description":"Create git tag v0.1.0, push it, verify cargo dist workflow produces release artifacts. Update README prebuilt binaries link to point at actual release.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:02Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:28:48Z","dependencies":[{"issue_id":"improvise-3tj","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:28:48Z","dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:28:48Z","dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-poia","title":"The records-mode + column has no projected identity","description":"From the ADR 002 spine landing (2026-09-19). In records mode the trailing + (NewMeasure) column creates a new measure on the row (gotcha 1). It carries no CellId, so creating a measure over the wire has no address.\n\nThat is correct for now rather than an oversight -- a new measure is not a write to an existing cell, so it is not a SetCell and should not borrow one identity. But it means a browser client cannot do something the TUI can.\n\nDecide what it should be: a distinct command carrying the record surrogate plus the new measure name (derived from the effect that does it today, per doctrine(derive_the_enum_from_the_effects)), or a client-side flow that adds the measure and then writes the cell as two commands. The second is probably right -- it composes from operations that already exist -- but check whether the TUI treats it as one atomic action first, because splitting an atomic action across two commands is how a half-applied state appears.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:18:35Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-mjy7","title":"Export a model as SQLite for interop (one-way, native-only)","description":"The salvageable part of improvise-9ix, which was declined as a PERSISTENCE format (see its close reason for the measurements).\n\nThe one claim with real standing is INTEROP: a user opening their model in sqlite3 or DB Browser to run their own queries. That is EXPORT, not persistence -- ONE-WAY, no round-trip obligation, and therefore none of the grammar-duplication risk that killed 9ix. A second FORMAT must track every future change to improv.pest forever; a second VIEW need not, because nothing loads it back.\n\nIt sits naturally beside export_csv/export_csv_to in crates/improvise-io/src/persistence/export.rs.\n\nTHE ONE REAL COST: the rusqlite dependency. cargo check --target wasm32-unknown-unknown fails in libsqlite3-sys C build under this repo nix cc-wrapper, so this must be NATIVE-ONLY and FEATURE-GATED, and the wasm CI job must keep passing untouched. Verify that rather than assume it -- the 9ix agent was careful to note the wasm failure is the nix wrapper injecting -fzero-call-used-regs rather than proof that bundled SQLite cannot target wasm, so the gating is about protecting the browser bundle, not about an impossibility.\n\nScope: choose a schema that is USEFUL TO QUERY rather than one that mirrors internal storage -- a reader wants cells joined to their coordinates, not an interned key blob. Do not add a reader.","status":"open","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T00:08:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:08:37Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-zavn","title":"Drill previews on hover: a bounded record keyset per aggregate","description":"DEFERRED by the user (2026-09-19) -- a good idea, recorded so the shape is not lost and so nobody builds the UNBOUNDED version by accident.\n\nTHE IDEA: show the underlying rows when the user hovers an aggregate cell, without a round trip.\n\nTHE TRAP THIS AVOIDS. Projecting the full keyset for every aggregate is untenable and the cost runs OPPOSITE to the value. A stored cell has fixed coordinates so it lands in exactly one displayed cell -- the keysets PARTITION rather than multiply -- but that still makes the payload scale with N (the data) while everything else in a projection scales with the VIEWPORT. Aggregation is precisely what makes a large document fit a screen, so shipping the keysets undoes that compression. Worked numbers for a 1000-cell viewport at u128: ~16KB at one record per cell (where the cell would be settable anyway and no keyset is needed), ~160KB at ten, and ~16MB over a million-record document -- to render a thousand cells. Per client, re-diffed as data changes. And the cheap cases are the ones where drilling barely matters.\n\nTHE SHAPE THAT WORKS: ship at most k record hashes per aggregate PLUS A COUNT, so the payload is viewport-proportional again, and fetch the full set on demand when the user actually drills. k is a product decision -- enough rows to make a hover useful, e.g. 3 to 5.\n\nPREREQUISITE: ADR 002 (context/adr/002-naming-a-cell.md). An aggregate already carries CellId::Aggregate(AggregateHash), so drill-on-demand works without this ticket; this is purely the latency optimisation for hover. Do not conflate the two -- drilling must stay correct without any preview.\n\nNote the preview hashes are RECORD hashes, so they resolve through the same DataStore index and obey the same staleness rules: a preview row whose record has since gone simply fails to resolve.","notes":"\n--- BLOOM FILTER IDEA (user, 2026-09-19): right tolerance, different question ---\nProposal was a Bloom filter instead of an explicit keyset, on the grounds that\nfalse positives are fine given a deterministic aggregate id to ask\nauthoritatively. The tolerance argument is right; the direction needs\ncorrecting.\n\nA BLOOM FILTER CANNOT ENUMERATE. A hover preview needs ROWS -- identities and\nvalues -- while a filter answers \"is X probably in this set?\" for an X you\nalready hold. The client holds aggregates, not the underlying records, so it has\nno candidates to test. So this does not serve previews directly.\n\nWHERE IT FITS INSTEAD, and it fits well: the REVERSE question, \"which visible\naggregates does record R feed?\" A client staging an edit to R tests R against\neach visible cell filter and marks the hits pending -- greyed, spinner -- with\nno round trip. A false positive marks one extra cell pending; it never misses\none. That asymmetry is exactly gotcha 35 rule: over-reporting costs work,\nunder-reporting is the bug, which is why Dirty::of is a deliberate\nover-approximation. Same shape.\n\nSIZE CORRECTION THAT MAKES IT CHEAP: a conventionally-sized filter still scales\nwith N -- roughly 9.6 bits per element at 1% FP, so ~1.2KB for a cell\naggregating 1000 records, and the payload again scales with the DATA. FIX THE\nWIDTH instead: a fixed 64- or 128-bit filter per cell is viewport-proportional\nby construction. Small aggregates are precise; large ones SATURATE and answer\n\"maybe\" to everything, degrading to mark-everything -- the safe direction, and\nthe same failure the design already tolerates.\n\nThe deterministic aggregate id is what makes the sloppiness safe: the\nauthoritative answer is one Drill(AggregateHash) away, so the filter is never\nload-bearing for correctness. Same structure as ADR 001 advisory reservations,\nwhere correctness never rests on the advisory layer.\n\nSO THIS TICKET NOW HOLDS TWO SEPARABLE IDEAS -- split them when picked up:\n (a) hover previews: needs a BOUNDED EXPLICIT keyset (k hashes + a count),\n because a preview needs rows;\n (b) optimistic edit feedback: a FIXED-WIDTH Bloom filter per aggregate,\n answering which visible cells a pending record edit affects.\n(b) belongs with ADR 001 layer 2 rather than here.","status":"open","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:59:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:00:46Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ut7p","title":"improvise-gp fit_from_gen0 is a third copy of the GA loop","description":"Noted during improvise-0ai (2026-09-19), deliberately not changed. The generation loop now lives in gp_tree::fit::fit. improvise-gp test helper fit_from_gen0 is still its own loop, kept because it also returns the GENERATION-0 best, which the library fit does not expose -- and that is exactly what breeding_improves_on_generation_zero needs in order to prove the search moved rather than merely differed. So the duplication is load-bearing today. Two honest options: expose the generation-0 champion from gp_tree::fit (a fit result carrying the starting cost, or fit_with_progress) and rewrite the helper onto it, or leave it and document why. Prefer the first -- did the search improve on where it started is a question any caller of a GA wants answerable, not only a test.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T21:23:05Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:40:24Z","closed_at":"2026-09-19T23:40:24Z","close_reason":"Took the preferred option. gp_tree::fit gains Fit { tree, cost, start, start_cost } and fit_detailed; fit is now fit_detailed(..).tree. start is the best member of GENERATION 0 of the restart that produced tree -- the stronger claim, and the one improvise-gp needed. improvise-gp fit_from_gen0 is gone, replaced by seeded_fit over fit_detailed, so the GA loop has one home.\n\nLoop shape preserved deliberately: generation i is still what is scored at the top of iteration i, the sort is still stable so member 0 wins its own ties and generation 0 champion equals the seed ancestor whenever nothing beat it -- that is what makes the restructure behaviour-preserving rather than merely plausible. breed runs the same number of times with the same arguments; the extra scoring pass draws nothing from the RNG.\n\nREPRODUCIBILITY EVIDENCE RE-ESTABLISHED, not assumed: all six known-answer vectors pass unchanged and the nine-scenario fit_csv harness is byte-identical including model.json, so every recorded seed still names the same tree. Two faults injected, both fired.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0vh1","title":"number_text emits grammar-rejected 'inf' and panics on NaN","description":"From the wave-1 gp-tree review (2026-09-19). Both are guarded by documented preconditions, hence P4.\n\nimprovise-gp/src/lib.rs:124. number_text(f32::INFINITY): at p=0 it yields \"inf\", and \"inf\".parse::\u003cf64\u003e() succeeds, so the round-trip loop ACCEPTS it and emits 'inf' -- which the formula grammar rejects (number has no such form). fitted_formula then returns Err, which is fine, but a bare formula_text caller gets silently unparseable text. number_text(f32::NAN) never matches (NaN != NaN) and falls through to unreachable!(), i.e. panics.\n\nAlso a doc imprecision worth correcting while there (lib.rs:22-25 and 114-123): the comment says it emits 'the exact decimal value of the f32'. It emits the shortest decimal that ROUND-TRIPS to the same f64 -- which is exactly what the routing argument needs, and is a weaker and more accurate claim. Verified: over 20,000 random f32 bit patterns plus the extremes, every value found a width inside 0..=149, worst observed width 60, none emitted e/E/inf/NaN, all parsed and round-tripped.","notes":"\n--- FIXED, AWAITING MERGE (2026-09-19) ---\nThe fix is on branch wave-1-review-fixes and is in PR #9\n(https://github.com/fiddlerwoaroof/improvise/pull/9), which is open pending\nCopilot review. Moved to in_progress rather than left open so nobody picks\nthis up again -- see the agent-issue-drift-pattern memory. Close it when #9\nmerges, not before.","status":"closed","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:43:13Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:38Z","started_at":"2026-09-19T21:15:05Z","closed_at":"2026-09-19T21:50:38Z","close_reason":"Merged to main in PR #9 as e1acac691d70992b5dfcce9f11ce2c2b50fa1c29 (2026-09-19). Verified a real merge commit with both parents, not a squash, so all 18 branch commits are reachable on main per AGENTS.md's preference for merges. Copilot's second review on that PR: approval recommended, findings none. All eight non-skipped CI checks green, including claude-review. Synced to origin and gitea.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-k099","title":"AppMode::hint_text/label put terminal UI strings on a serde wire enum","description":"From the wave-1 review (2026-09-19). Pre-existing, introduced by improvise-2hi, not by this wave -- filed so it is tracked rather than rediscovered.\n\ngotcha 26's principle is that wire tokens are not UI labels. But AppMode::hint_text and AppMode::label are English, terminal-specific, keybinding-naming UI strings hanging off an enum that is serialized into the client Snapshot. A non-terminal or localized client cannot use them and has to map AppMode itself anyway.\n\nThe established fix shape in this repo is the AxisColor/MinibufColor one: keep a semantic value in the portable crate and let the render layer resolve it (invariant(semantic_palette_at_the_core_boundary)). Decide whether hints are worth the same treatment or whether they should simply move out of the protocol crate.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:42:38Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:42:38Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-n3wt","title":"prune_axis_items: keep-slice length invariant is only a debug_assert","description":"From the wave-1 review (2026-09-19). NOT a defect today -- filed so the latent risk is recorded rather than rediscovered.\n\nimprovise-ai7 replaced the old guard 'data_idx \u003c rc \u0026\u0026 keep_row[data_idx]' with 'keep.get(data_idx).copied().unwrap_or(false)' plus a debug_assert_eq!(data_idx, keep.len()). The reviewer confirmed the lengths cannot diverge today: keep_row/keep_col are built as (0..rc)/(0..cc) collections, rc is row_count() which is itself data_items(\u0026row_items).count(), nothing mutates the axis vectors between building keep and consuming it, and the fn is private with exactly two call sites. The debug_assert can never fire, and cargo test --workspace --release (assert compiled out) is green.\n\nThe risk is what happens IF a third caller ever derives keep from something other than row_count()/col_count(), because debug_assert is compiled out in release and the two builds then differ in OPPOSITE directions from the old code:\n * keep too SHORT: old code panicked (loud, index out of bounds); new code silently drops the item in release -- a crash becomes silent data loss from the grid.\n * keep too LONG: old dropped every item at index \u003e= rc; new keeps them if flagged.\n\nFix by making the count unsupplied-able rather than asserted -- e.g. have prune_axis_items derive keep's length from items itself, or take a newtype that can only be built from the same vector. Failing that, a doc-comment on the fn naming the invariant and both failure directions.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:41:44Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:52:13Z","closed_at":"2026-09-19T20:52:13Z","close_reason":"Fixed structurally rather than by assertion: prune_axis_items now takes impl Fn(usize) -\u003e bool over the DATA index instead of a \u0026[bool] plus a debug_assert. The function no longer receives a length at all -- the index set is generated by walking items -- so there is no parallel quantity a caller can derive wrongly, and debug and release cannot diverge because both the assertion and the unwrap_or fallback are gone. Both failure directions the ticket named become unrepresentable rather than detected. prune_empty now consults the has_value grid directly and the two Vec\u003cbool\u003e allocations disappear; the eager-to-lazy change is safe because pruning row_items touches neither col_items nor has_value, so the column pass still reads the original grid with the original rc (reasoning left in a comment at the call site). Two tests added, including prune_axis_items_indexes_data_positions_not_visual_ones, which pins the contract the old debug_assert only gestured at -- a group header must not shift the numbering the predicate sees. Records mode untouched.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-th7m","title":"Dead F(1) binding: bound to bare 'enter-mode' then immediately rebound","description":"Noticed while working improvise-s13 (2026-09-19), not touched per AGENTS.md (mention dead code, do not delete unasked).\n\nIn crates/improvise-command/src/command/keymap.rs, F(1) is bound to a bare \"enter-mode\" with NO args -- which would fail require_args if it were ever dispatched -- and is then rebound on the very next line with [\"help\"]. The first line is dead: it can never take effect.\n\nTrivial deletion, but worth confirming the second binding is the intended one rather than deleting the wrong line.","status":"open","priority":4,"issue_type":"chore","owner":"el-github@elangley.org","created_at":"2026-09-19T20:36:45Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:36:45Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2zq","title":"Records mode has no inline way to add a category, unlike the + column for measures","description":"improvise-c5v gave records mode a trailing '+' column: type a name, the measure is created, and it becomes a real column you can immediately type values into — without leaving the grid. There is no equivalent for categories, which is an asymmetry the wide-format work introduced.\n\nWHAT EXISTS TODAY (all leave the grid):\n- N in normal or records mode -\u003e search-or-category-add (command/cmd/search.rs:172). Emits SetPanelOpen{Category} + enter-mode category-add, so it opens the side panel and switches mode. Records-normal inherits it from the normal keymap (keymap.rs:645 builds rn with_parent(normal)).\n- n inside the category panel -\u003e the same CategoryAdd mode.\n- :add-cat Region (alias for add-category).\n\nNone of them set a coordinate on the row you were standing on, so after adding 'Region' you still have to navigate to that column and type the value.\n\nWHY IT LOOKS TRACTABLE:\n- GridLayout::records_columns filters on cat.kind.is_regular() and does NOT require the category to have items, so a freshly added empty category appears as a records column immediately.\n- CellKey::with(cat, item) ADDS a coordinate when absent, so the existing rename_record_coordinate path already handles a row that has no coordinate for the new category — it will move every measure cell in the row onto the new coordinate, which is the correct semantics.\n\nSo the shape could mirror '+': a second trailing column (say '+cat') whose commit runs add-category and then, optionally, sets that coordinate for the current row.\n\nOPEN QUESTIONS, decide before building:\n1. Two trailing columns ('+' for measure, '+cat' for category) may be more clutter than it is worth. An alternative is one '+' column that disambiguates by prefix, or a key rather than a column.\n2. Adding a category is more consequential than adding a measure: every existing record lacks a coordinate for it, so the column is blank everywhere and MAX_CATEGORIES (12) applies. Adding a measure only adds a blank column. Worth deciding whether inline creation is even desirable, or whether the panel is the right home for a schema change of that weight.\n3. Should creating the category also set its value on the current row in one commit, or just create the column?\n\nFiled after the user asked whether such a bead existed; it did not.","status":"open","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-08-29T19:32:40Z","created_by":"Edward Langley","updated_at":"2026-08-29T19:33:23Z","dependencies":[{"issue_id":"improvise-2zq","depends_on_id":"improvise-c5v","type":"blocks","created_at":"2026-08-29T12:32:45Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ede","title":"prune_empty's records branch was unreachable dead code","description":"Noticed while doing improvise-c5v. GridLayout::prune_empty starts with 'if self.is_records_mode() { return; }', which guarantees self.records is None for the rest of the function — but the tail carried an 'if let Some(records) = \u0026self.records' branch that re-indexed row_items and rebuilt the records vec. It could never run.\n\nI removed it rather than leaving it, because the RecordRow type change made it stop compiling, so it became my mess to clean rather than pre-existing dead code to leave alone (AGENTS.md).\n\nFiling this only as a record of the removal, in case the intent was that records mode SHOULD prune empty rows and the early return is the actual bug. If so, that is a separate behavior question: today records mode deliberately shows every row ('the user drilled in to see all the raw data', per the doc comment on prune_empty). Close this if the early return is intended.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-08-29T19:29:02Z","created_by":"Edward Langley","updated_at":"2026-09-19T19:48:04Z","closed_at":"2026-09-19T19:48:04Z","close_reason":"Answered by the code the ticket points at. prune_empty carries the doc comment \"In records mode every column is shown (the user drilled in to see all the raw data)\", and repo-map gotcha 1 says the same: a records row is one coordinate tuple and every raw record is meant to be visible. So the early return is intended and the removed branch was correctly dead. Groomed 2026-09-19 per the ticket own instruction (\"Close this if the early return is intended\"). Reopen if P in records mode doing nothing is itself worth a UX ticket.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ct5","title":"i on an empty records grid reports 'cannot drill', leaking a pivot-mode concept","description":"In records mode every regular category sits on Axis::None (ToggleRecordsMode, command/cmd/grid.rs:432), so EditOrDrill's regular_none is always true and the edit branch depends entirely on is_synthetic. On an empty records grid there is no synthetic record at the cursor, so i takes the drill branch, finds no cell key, and sets 'cannot drill — no cell at cursor'.\n\nThe outcome (nothing to edit) is defensible, but the message talks about drilling — a pivot-mode concept the user is not in. In records mode the user's model is 'a table I type into', and o one key away does add a row and open an edit.\n\nOptions: (a) reword the message for records mode; (b) make i on an empty records grid behave like o. (b) is more helpful but makes i mode-dependent, which cuts against edit-or-drill being mode-agnostic — decide deliberately.\n\nPinned by ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid, which exists to record that i and o diverge here (see improvise-6sq). Changing this behavior means updating that test; read its doc-comment first — the divergence itself is load-bearing.","notes":"STALE REF (groomed 2026-08-30, line numbers shifted by this session's work): command/cmd/grid.rs:432 -\u003e the axis loop moved to crates/improvise-command/src/command/cmd/grid.rs:443 (the '!CategoryKind::of_name(name).is_index_or_dim()' loop in ToggleRecordsMode).","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-08-29T17:52:53Z","created_by":"Edward Langley","updated_at":"2026-08-30T17:06:17Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ont","title":"Migrate existing callers of workbook.views.get() to Workbook::active_view","description":"Workbook already exposes active_view() and active_view_mut() (crates/improvise-core/crates/improvise-core/src/workbook.rs:87-97) but many callers still do workbook.views.get(\u0026workbook.active_view).unwrap().X or wb.views.get('view_name').unwrap().axis_of(...).\\n\\n11 lookup sites identified:\\n- crates/improvise-core/crates/improvise-core/src/workbook.rs:167-168, 176-177 (within workbook.rs itself — these may be the implementations and be fine)\\n- crates/improvise-core/crates/improvise-core/src/model/types.rs:1868, 1871, 1873, 1886, 1890 (tests)\\n- crates/improvise-io/crates/improvise-io/src/persistence/mod.rs:943, 946, 1165\\n\\nFix: audit each site; migrate to Workbook::active_view() / Workbook::view(name) / add Workbook::view_axis(view_name, cat_name) -\u003e Option\u003cAxis\u003e to absorb the common chain.\\n\\nMostly a cleanup task but reduces future refactor exposure. ~15 LOC.","notes":"Groomed 2026-08-13 — re-verified against the tree; the premise has largely evaporated.\n\nCurrent state: 14 `views.get(` call sites remain, and 13 of them are inside #[cfg(test)] modules (workbook.rs, model/types.rs, persistence/mod.rs). Test code asserting on a NAMED view is not the chain this ticket wants to remove — `active_view()` is the wrong tool there, because the whole point of those assertions is to look at a view that is not active.\n\nThe single production site is src/ui/view_panel.rs:31:\n let Some(view) = self.workbook.views.get(view_name) else { ... }\nThat takes a dynamic view name from the panel list, so active_view() does not apply to it either. At most it wants a `Workbook::view(name) -\u003e Option\u003c\u0026View\u003e` accessor, which is a 3-line change with no correctness content.\n\nThe ticket's original count of '11 lookup sites' predates the improvise-tvt item-facade work, which removed most of the interesting ones. What is left is not worth a P3 slot. Dropped to P4.\n\nRECOMMEND: either close as wont-do, or rescope to exactly 'add Workbook::view(name) and use it in view_panel.rs' — which is small enough to fold into whatever next touches that file.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:37Z","created_by":"Ed L","updated_at":"2026-08-30T17:07:07Z","closed_at":"2026-08-30T17:07:07Z","close_reason":"OBSOLETE — the pattern this ticket targets no longer exists (verified 2026-08-30 while grooming).\n\nThe description says 'many callers still do workbook.views.get(\u0026workbook.active_view).unwrap().X'. A grep for 'views.get(\u0026' across crates/ and src/ now returns ZERO matches; the migration to Workbook::active_view() has already happened, presumably in the crate-split work.\n\nOf the 14 remaining '.views.get(' call sites, 13 are in test modules (workbook.rs, persistence/mod.rs, model/types.rs — all asserting on a specific named view, which is the correct API for that) and exactly one is production code:\n\n src/ui/view_panel.rs:31 self.workbook.views.get(view_name)\n\nThat one looks up a view BY NAME from the panel's own list, so active_view() does not apply to it. There is nothing left to migrate.\n\nNot carried forward: the ticket also floated adding Workbook::view_axis(view_name, cat_name) to absorb the .unwrap().axis_of(...) chain. That chain now appears only in tests, so the absorption would buy little; file it fresh if it ever earns its keep.\n\nAlso noted while here: the description's paths were malformed — 'crates/improvise-core/crates/improvise-core/src/workbook.rs' — with the crate prefix doubled throughout, which is why they resolved to nothing.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ete","title":"Add Model/Category getter methods to absorb .unwrap().items/groups chains","description":"Several callers reach through Model and Category to get item names, groups, or counts — each chain includes an .unwrap() that panics if the category is missing:\\n\\n- persistence/mod.rs:1635-1639, 2006-2007: model.category(name).unwrap().items.values().map(|i| i.name.clone()).collect() — 4 sites\\n- persistence/mod.rs:831: \u0026m2.model.category('Month').unwrap().groups — 1 site\\n- crates/improvise-command/src/ui/cat_tree.rs:32: cat.map(|c| c.items.len()) — item count\\n\\nAdd:\\n- Model::item_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Model::group_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Category::item_count() -\u003e usize\\n\\nCorrectness win: (a) prevents panics if the category lookup fails; (b) hides the items/groups IndexMap/HashMap from callers — internal-rep changes don't cascade; (c) the Option return type forces explicit handling.\\n\\n~15 LOC plus cleaner call sites.","notes":"GROOMED 2026-09-19 — DISSOLVED PREMISE, mostly. The four persistence/mod.rs sites this ticket was built on (model.category(name).unwrap().items.values()...) no longer exist in production code; grep for unwrap().items / unwrap().groups now hits only test modules (persistence/mod.rs is #[cfg(test)] from line 706 on, workbook.rs likewise). The stated correctness win — \"prevents panics if the category lookup fails\" — is therefore void: there is no production panic left to prevent. What survives is encapsulation in test code plus cat_tree.rs:32 (cat.map(|c| c.items.len()).unwrap_or(0)), which already handles the missing category. P3 -\u003e P4 on that basis; close it if the encapsulation alone is not worth 15 LOC.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:34Z","created_by":"Ed L","updated_at":"2026-09-19T20:52:14Z","closed_at":"2026-09-19T20:52:14Z","close_reason":"OBSOLETE -- premise dissolved, verified item by item rather than accepted from the groom's note. (1) Model::item_names would DUPLICATE the existing Model::effective_item_names, and in a worse form: the ticket's version reading category(name).items would silently omit formula targets for _Measure, which is exactly the trap gotcha 7 warns about; the existing one routes _Measure through measure_item_names and returns Vec rather than Option\u003cVec\u003e, which is also the right call under 'keep Option at the boundaries'. (2) Model::group_names has no caller inside improvise-core -- the site the ticket cites now lives in an improvise-io test. (3) Category::item_count has no in-scope caller either; the live one is improvise-command's cat_tree.rs, which already handles the missing category.\n\nThe ticket's stated correctness win -- 'prevents panics if the category lookup fails' -- is void: every unwrap().items / unwrap().groups chain in the repo is now inside a #[cfg(test)] module. The single production reader of cat.items.values() is persistence/format.rs, the serializer legitimately walking items to write them, which is not an unwrap chain and not something a getter should hide from a formatter.\n\nIf the encapsulation is still wanted for improvise-command's cat_tree.rs, that is one method for whoever owns that crate, not the three proposed here.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-a5q","title":"Parquet columnar export (read-only)","description":"One-way export to Apache Parquet for interop with data tools (pandas, Arrow, BI tools). Columnar layout maps naturally to improvise's category/measure/cell structure: each category becomes a column of the key space, each measure a value column. Read-back not required for MVP; this is primarily a data-out path. Orthogonal to the core standalone deployment; filed as a backlog follow-on.","design":"Use arrow-rs + parquet crates (or polars as a simpler wrapper). Model shape → Arrow RecordBatch: one row per cell, columns are (category_1, category_2, ..., measure_name, value). Write the batch to a parquet file via the VFS. Read-back (parquet → model) is a stretch goal — most users will export once and consume the file elsewhere. Browser build: parquet + arrow crates are heavy; may push the wasm bundle size significantly. Consider making parquet export a separate optional wasm chunk loaded on demand.","acceptance_criteria":"(1) Export function writes a valid parquet file from the current model. (2) File opens correctly in pandas / pyarrow and reproduces the improv data. (3) Works over VFS (native filesystem + OPFS).","notes":"Depends on improvise-6mq (VFS abstraction). Very low priority — a nice data-science handoff story but not required for core functionality. Filed as P4 backlog.","status":"open","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:46Z","created_by":"spot","updated_at":"2026-07-30T23:06:32Z","dependencies":[{"issue_id":"improvise-a5q","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3gy","title":"4.2 Enable GitHub Pages","description":"Enable Pages in GitHub repo settings: source=main branch, folder=/docs. Verify site is live at the GitHub Pages URL.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:10Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:28:48Z","dependencies":[{"issue_id":"improvise-3gy","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T21:09:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-e61","title":"4.1 Create docs/index.html landing page","description":"Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:07Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:28:48Z","dependencies":[{"issue_id":"improvise-e61","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-kh8","title":"Phase 4: Landing page (optional)","description":"Create docs/index.html with embedded asciinema casts and enable GitHub Pages. Optional but recommended for launch.","status":"open","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:53Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:28:48Z","dependencies":[{"issue_id":"improvise-kh8","depends_on_id":"improvise-3gy","type":"blocks","created_at":"2026-04-08T23:37:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kh8","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|