From b1a989d8cf5d514a152cc1459b538a02949fa8b7 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 19:34:07 -0700 Subject: [PATCH] docs(kb): the command lane's doctrine, and a second vector for the base trap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From PRs #144/#148/#151. `AGENTS.md`'s remote note gains the worktree-base vector: a branch cut from the orchestrator's local `main` inherits whatever that checkout merged but did not push, and nothing in the branch says so — which is how #148's diff came to name the three orchestrator-only files and read as a rule violation rather than a base problem. Four facts. A fold test must fold the batch the producer really emits — a `Binding::Sequence` flattens into one vec, and a per-step batching cannot see intra-batch order at all. A type name is not a field, measured: one 14-site rival left 14 of 16 assertions passing, while an assertion on a *fieldless* effect is complete rather than smoke. A registry with two arms demotes the weaker caller, and neither direction is safe by default. And a clean text merge can delete an evidence entry that no gate checks. Two gate traps: `--check-evidence` prints the comment cap FIRST, so reading it through `tail -1` drops the half that was always there; and a compound command refused by the worktree shell guard runs NEITHER part. kb_consistent: TRUE, 798 facts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH --- .beads/issues.jsonl | 20 +++++++++++--------- AGENTS.md | 2 +- prolog-sessions/kb.pl | 6 ++++++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b26ed4a0..1f3ac13a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,4 @@ -{"_type":"issue","id":"improvise-6uva","title":"improvise script cannot move the cursor: every Move resolves through the pure-parse arm","description":"`improvise script` and `improvise cmd` resolve every command through CmdRegistry::parse -- the PURE arm, which builds CursorState::default() (row 0, col 0, row_count 0, col_count 0). The interactive arm, which builds CursorState::from_ctx(ctx), is never reached from a script. See command/cmd/core.rs:188 for the two-arm register.\n\nConsequence: every Move command (move-selection, scroll-rows, page-scroll, all four jump-*) and enter-advance snapshots a ZEROED cursor at PARSE time, clamps to (0,0), and emits SetSelected(0,0). It is worse than a no-op -- it RESETS the cursor. There is no way to position the cursor from a script, so every cursor-reading command is permanently pinned to the origin: clear-cell, yank, paste, cycle-axis-at-cursor, hide-selected-row-item, edit-or-drill, drill-into-cell, toggle-group-under-cursor, delete-*-at-cursor, commit-and-advance.\n\nDEMONSTRATED AT HEAD (a9cad15), web/demo.improv, Product on row:\n script A: \"move-selection 2 0\" then \"hide-selected-row-item\"\n script B: \"hide-selected-row-item\"\nBoth documents came out IDENTICAL, both carrying `hidden: Product/Espresso` -- row 0. diff reports IDENTICAL.\n\nBOTH DIRECTIONS DEMONSTRATED. Built the parent of the breaking commit (649d80c) in a worktree and probed:\n \"move-selection 2 0 . clear-selected-cell\" -\u003e cleared Product=Cold (row 2)\n \"clear-selected-cell\" -\u003e cleared Product=Espresso (row 0)\nThe cursor moved there. At HEAD it does not. So neither result is vacuous.\n\nBROKEN BY 35946af (2026-04-04), \"refactor(command): pre-resolve cell key and grid dimensions in CmdContext\", which introduced CursorState, InteractiveFn and the register/register_pure/register_nullary split. Verified ancestry: 35946af is an ancestor of HEAD and of 30c4bb6 (2026-09-19, the first wasm-client commit), so THE REGRESSION PREDATES THE BROWSER WORK BY FIVE MONTHS. The commit message says nothing about scripts.\n\nTHE SHAPE TO REMEMBER: a registry with two construction arms silently demotes every caller of the weaker arm, and nothing type-checks which arm a caller deserves.\n\nREPAIR (not written): run_headless_commands already builds a CmdContext for execute -- it just does not use one for CONSTRUCTION. There is no context-aware script parser today (parse_line_with also calls registry.parse), so closing this needs a parse_line_interactive(registry, line, ctx) calling registry.interactive, plus a PER-COMMAND re-parse inside the loop, since command n must see command n-1's cursor.\n\nA regression test must assert the cursor MOVED, not that the script ran: three identical replays of a fixture whose cursor moves are silent no-ops prove nothing, which is how this survived.\n\nFound by the improvise-ilvl replay audit.","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:42Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:45:53Z","started_at":"2026-09-23T01:45:53Z","lease_expires_at":"2026-09-23T01:50:53Z","heartbeat_at":"2026-09-23T01:45:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6uva","title":"improvise script cannot move the cursor: every Move resolves through the pure-parse arm","description":"`improvise script` and `improvise cmd` resolve every command through CmdRegistry::parse -- the PURE arm, which builds CursorState::default() (row 0, col 0, row_count 0, col_count 0). The interactive arm, which builds CursorState::from_ctx(ctx), is never reached from a script. See command/cmd/core.rs:188 for the two-arm register.\n\nConsequence: every Move command (move-selection, scroll-rows, page-scroll, all four jump-*) and enter-advance snapshots a ZEROED cursor at PARSE time, clamps to (0,0), and emits SetSelected(0,0). It is worse than a no-op -- it RESETS the cursor. There is no way to position the cursor from a script, so every cursor-reading command is permanently pinned to the origin: clear-cell, yank, paste, cycle-axis-at-cursor, hide-selected-row-item, edit-or-drill, drill-into-cell, toggle-group-under-cursor, delete-*-at-cursor, commit-and-advance.\n\nDEMONSTRATED AT HEAD (a9cad15), web/demo.improv, Product on row:\n script A: \"move-selection 2 0\" then \"hide-selected-row-item\"\n script B: \"hide-selected-row-item\"\nBoth documents came out IDENTICAL, both carrying `hidden: Product/Espresso` -- row 0. diff reports IDENTICAL.\n\nBOTH DIRECTIONS DEMONSTRATED. Built the parent of the breaking commit (649d80c) in a worktree and probed:\n \"move-selection 2 0 . clear-selected-cell\" -\u003e cleared Product=Cold (row 2)\n \"clear-selected-cell\" -\u003e cleared Product=Espresso (row 0)\nThe cursor moved there. At HEAD it does not. So neither result is vacuous.\n\nBROKEN BY 35946af (2026-04-04), \"refactor(command): pre-resolve cell key and grid dimensions in CmdContext\", which introduced CursorState, InteractiveFn and the register/register_pure/register_nullary split. Verified ancestry: 35946af is an ancestor of HEAD and of 30c4bb6 (2026-09-19, the first wasm-client commit), so THE REGRESSION PREDATES THE BROWSER WORK BY FIVE MONTHS. The commit message says nothing about scripts.\n\nTHE SHAPE TO REMEMBER: a registry with two construction arms silently demotes every caller of the weaker arm, and nothing type-checks which arm a caller deserves.\n\nREPAIR (not written): run_headless_commands already builds a CmdContext for execute -- it just does not use one for CONSTRUCTION. There is no context-aware script parser today (parse_line_with also calls registry.parse), so closing this needs a parse_line_interactive(registry, line, ctx) calling registry.interactive, plus a PER-COMMAND re-parse inside the loop, since command n must see command n-1's cursor.\n\nA regression test must assert the cursor MOVED, not that the script ran: three identical replays of a fixture whose cursor moves are silent no-ops prove nothing, which is how this survived.\n\nFound by the improvise-ilvl replay audit.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:42Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:17:29Z","started_at":"2026-09-23T01:45:53Z","closed_at":"2026-09-23T02:17:29Z","close_reason":"PR #151. improvise cmd/script now build each command PER SEGMENT against a live CmdContext (parse::segments + parse::parse_segment_interactive); construction cannot be hoisted, because command n must see n-1's cursor. Line atomicity preserved by keeping the whole-line parse_line as a syntax gate, so the only behavioural change is which cursor the commands see. VERIFIED BY THE ORCHESTRATOR with a real binary on main: 'move-selection 2 0 . hide-selected-row-item' now writes hidden: Region/East while a bare hide writes Region/North. THREE INSTANCES OF THE TICKET'S OWN LESSON, all found during the fix, none predicted: (1) the TUI's ':' line had the IDENTICAL defect -- ExecuteCommand also used parse_line_with, so :move-selection 2 0 reset the cursor; (2) register_pure's two arms DISAGREED, its interactive arm refusing empty args while its pure arm accepted them, so 'w' parsed one way and refused the other; (3) found by ChatGPT Codex -- clear-cell, yank, paste and drill-into-cell had interactive arms taking |_args, ctx| that DISCARDED explicit coordinates, harmless only while scripts reached the pure arm, so after the fix 'clear-cell Region/East' cleared the origin. Here the WEAKER arm was the one that HAD the context. Resolved by key_from_args_or_cursor: explicit coordinates win, cursor is the fallback.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xgra","title":"pr-reviews is blind to inline review comments","description":"scripts/pr-reviews printed review BODIES only. On PR #122 the user reviewed with the body 'Generally good, added comments inline.' and five substantive rulings as inline comments; the script printed the one-line body and nothing else, and an agent reported the PR as awaiting review. Extend it to all three surfaces (reviews, pulls/{n}/comments, issues/{n}/comments) interleaved in time order, and give it a fixture-driven check_the_checker.","notes":"FIXED in PR #133 (merge 22ab58f). pr-reviews now prints all three surfaces --\nreview bodies, INLINE comments, issue comments -- interleaved in time order, with\na fixture-driven check_the_checker (22 cases) in CI's kb job.\n\nTHE DAMAGE IT REPAIRS, for the record: on PR #122 the user reviewed with the body\n\"Generally good, added comments inline.\" and five substantive rulings underneath.\npr-reviews printed the one-liner, and the orchestrator reported the PR as awaiting\nreview while the answers had been sitting there. The user had to say so.\n\nTHE FINDING THAT IS SHARPER THAN THE BUG -- VERIFIED BY THE ORCHESTRATOR AGAINST\nTHE LIVE API: commit_id is PER-COMMENT, not per-review, and they differ INSIDE ONE\nSUBMISSION. On #122, one review submitted 19:48-19:53 carries five inline\ncomments: three naming 246e8f4a and two naming 6a4c7dcd, while its body names\n246e8f4a. GitHub pins each comment to the file version it was written against, so\na comment on an outdated diff position keeps the older commit while its siblings\nmove on.\n\nSo the old head check was answering about the WRONG OBJECT, not merely reading too\nfew of them. A head comparison against the review BODY is not even a proxy for its\nown comments.\n\n--wait: AN INLINE COMMENT AT THE HEAD SATISFIES IT, argued rather than assumed --\na pulls/{n}/comments entry carries a commit_id, so it is a claim about which tree\nwas read. The clincher is that GitHub's \"Add single comment\" CREATES A REVIEW\nWHOSE BODY IS EMPTY: under the old rule that reviewer's exit code was right while\ntheir words were invisible, so the surfaces and the wait had to move together or\nthe tool would be honest and useless. AN ISSUE-LEVEL COMMENT DOES NOT satisfy it\n-- no commit, so no claim about a tree; \"I am rebasing shortly\" satisfying a wait\nfor a review is the widening that looks like the fix, and there is a fixture and\nan injected fault for exactly it.\n\nSIX FAULTS INJECTED, each caught by the case that names it. The two found by Codex\non the PR are the interesting ones and are bash-5-only:\n - `local n=\"$1\" name=\"$2\" f=\"...$n...\"` is NOT left-to-right on bash 5: every\n name is created before the assignments run, so $n is unbound under set -u.\n macOS ships bash 3.2, where it works. THE SUITE PASSED 19/19 LOCALLY AND 2/19\n IN CI ON THE SAME COMMIT -- which is the argument for wiring a shell checker\n into CI in the SAME change that adds it.\n - gh api --paginate emits one array PER PAGE, so a long thread is several\n concatenated documents.\n\nAND THE DIAGNOSTIC WORTH CARRYING: both failed as EXIT 2 WITH NO OUTPUT AT ALL,\nbecause a dead command substitution hands jq --argjson an empty string and jq\nanswers an invalid --argjson with a USAGE error, which is 2. A shell fault at the\ntop of a script arrives wearing jq's exit code at the bottom.\n\nNOT VERIFIED: the LIVE --paginate path. No PR in this repo has enough inline\ncomments to reach a second page (#122 has five), so pagination is exercised by\nfixture only -- and the fixture runs through the SAME pages_to_array, so it can\nhold the concatenated shape the live fetch actually produces.\n\nTHE AGENT ALSO WALKED INTO THIS PR'S OWN TRAP WHILE BUILDING IT: it ran\n`./scripts/pr-watch 133 \u003e f 2\u003e\u00261; echo \"EXIT=$?\"` and its tooling reported exit 0\nover a FAILING kb job, because the echo took the status. pr-watch had correctly\nexited 1. That is the strongest argument for the widened header wording being in\nthe file rather than in anyone's memory.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T20:44:08Z","created_by":"Edward Langley","updated_at":"2026-09-22T21:04:54Z","closed_at":"2026-09-22T21:04:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-e0ex","title":"A ratio renders as an integer: number_format is per view, which is too coarse for one grid","description":"USER 2026-09-22: \"Right now, a Formula like `Margin = Profit / Revenue` displays\nuseless numbers. Probably, in most cases 2 decimal places displayed is sufficient\"\n\nTHE IMMEDIATE CAUSE IS THE FORMAT, NOT THE ARITHMETIC, and that is worth stating\nplainly because the user's own diagnosis pointed at decimal math (filed separately\nas the numeric-tower ticket, which is legitimate on its own merits but is NOT what\nproduces this symptom).\n\nView::default() sets number_format to \",.0\" (improvise-core/src/view/types.rs:56),\nand web/demo.improv sets \",.0\" explicitly on both views. `.0` is ZERO DECIMAL\nPLACES. So Margin = Profit / Revenue, a ratio around 0.66, is rounded\nhalf-away-from-zero (gotcha 8) and drawn as \"1\". Exact decimal arithmetic would\nnot change that by one character: 0.66 formatted with \",.0\" is still \"1\".\n\nTHE REAL DEFECT IS THAT number_format IS PER VIEW, WHICH IS TOO COARSE. One\nformat cannot serve Revenue (18,400 -- wants \",.0\") and Margin (0.66 -- wants\n\".2\" or a percent) in the same grid. Blanket-changing the default to 2 decimals\njust moves the damage: every currency measure becomes \"18,400.00\". So the fix is\nNOT a new default; it is a format that can vary per measure.\n\nWHAT TO DECIDE, in the PR body, rather than assume:\n - PER WHAT? Per _Measure item is the obvious unit and matches how a user\n thinks (\"Margin is a percentage\"). Per formula is another candidate and is\n narrower -- note a formula TARGET is a _Measure item (gotcha 7), so the two\n are not independent and picking both would be two answers to one question.\n - WHERE DOES IT LIVE? A per-measure format is document data, so it persists --\n which means the .improv grammar gains something and persistence/improv.pest\n is the single source of truth (design-principles section 3). Grammar first,\n then the tree-walker.\n - WHAT IS THE FALLBACK? The view's number_format must remain the answer for a\n measure with no format of its own, or every existing document changes\n appearance on load.\n - IS A SENSIBLE DEFAULT INFERRABLE? A formula whose expression is a division\n is almost always a ratio. Inferring is tempting and is the kind of rule that\n is wrong 10% of the time and unexplainable when it is -- if you propose it,\n propose it as a DEFAULT THE USER CAN OVERRIDE and say how they override it.\n\nROUND-TRIP IS THE TEST THAT MATTERS: persistence/roundtrip_tests.rs and the\ngrammar-walking generator. A new format field that does not survive\nformat_md -\u003e parse_md is a silent data loss, and the generator is what will find\nthe spellings you did not think of.\n\nNOTE FOR SCOPE: this is display only. format_f64 is called in rendering and\nnowhere else; formula eval uses full f64 and must keep doing so (gotcha 8 -- never\nfeed a rounded value back into eval). Nothing here touches the evaluator.","notes":"USER RULING 2026-09-22, which NARROWS this ticket from what it was filed as:\n\"We need to default to showing decimals: the expectation is that 2/3 shows\nsomething like .66... rather than 1 for this sort of application, unless the\nformat is explicit in the formula\"\n\nTWO CLAUSES, and the second changes the design:\n\n1. THE DEFAULT SHOWS DECIMALS. View::default()'s \",.0\" becomes decimal-showing.\n Combined with the user's earlier \"in most cases 2 decimal places displayed is\n sufficient\", that reads as \",.2\": Revenue draws 18,400.00 and Margin draws\n 0.66. Accounting applications do exactly this, so the noise on whole-number\n measures is the expected cost rather than a regression.\n\n2. THE OVERRIDE IS SPELLED IN THE FORMULA. This SUPERSEDES the \"per-_Measure\n item\" framing this ticket was filed with. Per-formula is narrower and is what\n the user asked for -- and the two nearly coincide, because a formula TARGET is\n a _Measure item (gotcha 7). So: a formula may carry its own format, which wins\n for its target; a RAW measure keeps the view's format. Clean split, and the\n grammar change lands in the `## Formulas` line rather than in a new section.\n\nTHE MIGRATION IS CLEANER THAN EXPECTED -- verified, not assumed. format_md writes\n`format: \u003cfmt\u003e` whenever number_format is non-empty (persistence/format.rs:121),\nand View::default() is non-empty, so EVERY DOCUMENT IMPROVISE HAS EVER WRITTEN\ncarries an explicit format line. format_line is optional in the grammar\n(improv.pest:118/126), so changing the default affects only (a) newly created\nviews and (b) hand-written documents that omit the line. No migration is needed\nfor round-tripped files.\n\nBUT web/demo.improv SETS \",.0\" EXPLICITLY ON BOTH VIEWS (lines 11 and 19), so the\ndefault change alone leaves the front page showing the bug the user reported.\nChange the demo in the same commit, and note tests/web_demo_model.rs asserts\nproperties of that document.\n\nGRAMMAR NOTES for the formula-side format:\n - The `## Formulas` line already carries a bracket suffix for the target\n category: `- Tax = Revenue * 0.1 [CustomCat]`. A format needs a spelling that\n cannot be confused with a category name. Decide it in improv.pest FIRST --\n the grammar is the single source of truth and the tree-walker follows\n (design-principles section 3).\n - The grammar-walking generator reads improv.pest via pest_meta and will\n exercise whatever you write, including spellings you did not intend. Note it\n SKIPS PREDICATES (gotcha 34), so a rule guarded only by a lookahead is a rule\n the generator can violate -- use a real token, as WHERE's `sp+` does.\n - Formula::raw is what the writer emits, not the reconstructed text, so a\n format that round-trips through `raw` may appear to work while being dropped\n from the parsed structure. gotcha 34 records exactly that failure for the\n formula TARGET: the round-trip tests never saw it because raw was unaffected.\n Assert the PARSED format, not the re-emitted line.\n\nSTILL DISPLAY ONLY. format_f64 is called in rendering and nowhere else; eval uses\nfull f64 and must keep doing so (gotcha 8). ADR 005 (improvise-l2b8) is the\nseparate question of what the arithmetic itself is, and nothing here waits on it.\nUSER CONSTRAINT 2026-09-22: \"improvise-e0ex should not affect persistence at all,\nexcept to store format metadata.\"\n\nALREADY TRUE BY CONSTRUCTION -- verified before recording, so the job is to NOT\nBREAK IT rather than to build it:\n\n - DISPLAY formatting is format_f64 in improvise-core/src/format.rs. Its only\n callers are rendering paths.\n - PERSISTENCE formatting is a DIFFERENT function in a DIFFERENT crate:\n persistence/format.rs:38 `fn format_number(n: f64)`. It writes `{n}` --\n Rust's shortest round-trip representation -- for non-integers, `{n as i64}`\n for integers below 1e15, and named forms for inf/-inf/nan.\n - `grep format_f64` over crates/improvise-io, crates/improvise-core/src/*.rs\n and src/*.rs returns ONLY format.rs's own definition and its tests.\n PERSISTENCE NEVER CALLS THE DISPLAY FORMATTER.\n\nSo stored values keep full round-trip precision regardless of what any view's\nnumber_format says, and a ,.2 default cannot round anything on disk.\n\nTHE REGRESSION TO WATCH FOR, stated because it is the one way this constraint\ngets violated by someone trying to be helpful: routing a save through the display\nformatter \"so the file matches what the user sees\". That would make the on-disk\nvalue depend on a VIEW's setting -- and a model has many views (gotcha 29), so\nthere is no single right answer and the last save would win. gotcha 8 already\nforbids feeding a rounded value back into eval; this is the persistence twin of\nit and is now written down.\n\nWHAT IS IN SCOPE ON THE PERSISTENCE SIDE, and it is only metadata:\n 1. The per-formula format spelling in the `## Formulas` line -- grammar first\n in improv.pest, then the tree-walker.\n 2. The `format:` line's VALUE for newly created views changes with the default\n (\",.0\" -\u003e the decimal default). That is metadata, not data, and existing\n documents are unaffected because format_md has always written the line\n explicitly (format.rs:121) whenever number_format is non-empty.\n\nNOTHING ELSE IN .improv CHANGES. No data line is rewritten, no number is\nre-rendered, no migration is needed.\nFIXED in PR #125 (merge 5fdce21). 1654 tests, all gates green.\n\nMEASURED, not asserted -- web/demo.improv through project::grid_of, the path both\nfront ends use:\n Revenue Cost Profit Margin\n before 18,400 6,200 12,200 1\n after 18,400.00 6,200.00 12,200.00 0.66\n with FORMAT .4 on a ,.0 view 0.6630\n\nONE CORRECTION TO THIS TICKET AND TO THE BRIEF I WROTE FROM IT: the demo has NO\nMargin formula -- only `Profit = Revenue - Cost`. Verified on main. So the front\npage was never literally showing the reported symptom; it was showing ,.0 on\nevery measure. My brief asserted the demo would \"still show the bug\" after a\ndefault change, which was wrong because I assumed a ratio that is not there.\nThe agent declined to ADD a Margin formula: that is demo content nobody asked for\nand it moves tests/web_demo_model.rs's asserted column list plus harness\nassertions. It is a two-line change if the user wants the ratio on the front page.\n\nTHE GRAMMAR DESIGN IS BETTER THAN THE BRIEF ASKED FOR. improv.pest is UNCHANGED\n-- verified. formula_raw already takes the `## Formulas` line verbatim up to the\noptional [Category] suffix, so the format rides in the FORMULA TEXT:\n format_clause = ${ ^\"FORMAT\" ~ sp+ ~ format_spec }\nin formula.pest, after the optional WHERE, with sp+ rather than a lookahead for\nWHERE's reason (the grammar-walking generator skips predicates). Consequences:\nthe generator has no new spelling to exercise, and the text a user types into the\nTUI minibuffer is the text the file carries -- one namespace, one spelling rule.\nRejected: a `{.2}` line suffix, which puts the format somewhere a user cannot\ntype it.\n\nTHE raw-VS-PARSED TRAP WAS ANSWERED WITH A MEASUREMENT. Injecting a walker that\nDISCARDS the format clause fails exactly ONE test and passes 242 others in\nimprovise-io, including the whole format_md/parse_md characterization suite and\nthe grammar-walking properties. No text-comparing test can see the field go\nmissing -- which is the gotcha-34 shape this ticket warned about, now quantified.\n\nBOTH CODEX FINDINGS WERE REAL, and both are the kind that only review catches:\n 1. FormatPlan used a HashMap keyed by target. A target category need not be\n _Measure, so a key can be claimed twice and HASH ORDER DECIDED THE SPELLING\n while model order decided the value -- gotcha 18's shape in a new place.\n Now: every formula's (cat, target, Option\u003cformat\u003e) in MODEL order, FIRST\n match wins, and a match naming no format falls through to the view.\n 2. A column total took its format from ROW 0. The agent's own context/ prose\n had argued this was safe \"for the case it cannot arise in\" -- and it arises,\n because a formula's target category is any category and can own a ROW-AXIS\n item. New GridLayout::column_key(col) gives the coordinates surviving the\n row aggregation.\n\nTWO FIXTURES PINNED TO ,.0 RATHER THAN RE-BASELINED, and the first is the\nimportant one: improvise_gp::dataset::a_cell_reaches_the_table_unrounded asserted\nits fixture's format WAS ,.0 as a precondition, then checked a stored 5.25\nreaches a fit unrounded. UNDER A ,.2 DEFAULT THAT TEST WOULD HAVE GONE VACUOUS\nWHILE PASSING. The fixture now sets ,.0 explicitly.\n\nNO .improv DATA LINE CHANGED. The user's constraint held.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:11:33Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:16:26Z","closed_at":"2026-09-22T07:16:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-y11g","title":"The : command line must be present in the browser (core feature): a request frame carrying the line","description":"USER RULING 2026-09-22: \"The : command line needs to be present in the browser,\nit's another core feature.\"\n\nThis REVERSES the decline. resolve.rs::stranding() currently refuses\n`enter-mode command-mode` (with SavePrompt and ExportPrompt), and the\nimprovise-h3y7 audit classified it \"host-only unless the ruling is otherwise\n-- shipping it to a browser means shipping the registry\". The ruling is\notherwise, AND the audit's framing of the cost was wrong.\n\nTHE COST IS NOT WHAT IT LOOKED LIKE. The registry does not have to reach the\nclient, because IN BOTH DEPLOYMENTS THE HOST ALREADY HAS ONE:\n\n - socket (?ws=): the host is native improvise-ws-server. Obviously has it.\n - worker (no ?ws=): web/build.sh builds TWO wasm bundles -- web/pkg from\n improvise-wasm-client (thin) and web/worker-pkg from improvise-worker-server,\n which depends on improvise-command and therefore ALREADY CONTAINS THE FULL\n CmdRegistry, in the browser, today.\n\nSo a `:` line is a REQUEST FRAME CARRYING A STRING, executed by the host against\nits own App and CmdContext. Zero new bundle weight on the main thread, and no\nsecond implementation of the command language.\n\nTHIS MATTERS BECAUSE THE ALTERNATIVE IS EXPLICITLY FORBIDDEN. improvise-wasm-client's\nCargo.toml carries a written argument for why improvise-command is deliberately NOT\na dependency: a binding names an intent resolved against a CmdContext -- a Model, a\nGridLayout and an App the client does not have -- so depending on it would drag the\nwhole host into the bundle to reach a lookup that could not be used. Do not do that.\n\nSHAPE, and note it is the SAME shape as the export/download ticket (improvise-2mqz),\nwhich is a reason to land them together or at least to design them once:\n ClientFrame -\u003e the line, as a string\n ServerFrame -\u003e Rejected on a parse or execution failure, else the ordinary\n projection stream carries whatever the command changed\n\nWHY A FRAME AND NOT A Command, for improvise-368h's reason: ExecuteCommand runs an\narbitrary script line and is runtime-fallible, so it must not sit inside a\nClientFrame::Commands batch that promises all-or-nothing.\n\nBLOCKED ON improvise-jz6w: a resolver cannot emit a frame today.\n\nWHAT MUST LAND WITH IT, not after:\n - the minibuffer must actually RENDER. The user reported 2026-09-21 that the\n minibuffer for typing a category name \"doesn't actually show up anywhere so\n you're typing blind\". A `:` line you cannot see is not the feature.\n - remove the CommandMode arm from stranding() and say so in the named list.\n - help page 5 already advertises the whole `:` surface to browser users\n (improvise-uln5). Shipping this closes the advertised-but-unreachable gap for\n `:` specifically; check what uln5 has left afterwards rather than closing it.\n\nSECURITY NOTE, because `:` is a wider door than any other frame: a line can reach\n`:w`, `:e` and the import wizard, i.e. the host's Storage. In the worker deployment\nthat is the user's own OPFS and is fine. In the socket deployment it is SOMEONE\nELSE'S FILESYSTEM, and improvise-kc7n's Origin check answers what page is driving a\nclient, never WHO is connecting -- improvise-kpnr (authentication) is still open.\nDecide explicitly whether the socket deployment accepts `:` lines at all, or whether\nthe frame is refused there until kpnr lands. Do not let this arrive by accident.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:13:57Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:57Z","labels":["browser","protocol"],"dependencies":[{"issue_id":"improvise-y11g","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T05:14:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-y11g","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:34Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} @@ -71,6 +71,7 @@ {"_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-6qet","title":"[bug] z collapses the neighbouring group and can never re-expand one: a GroupHeader is drawn but has no data row to address it from","description":"Found while replacing improvise-ub4o's vacuous involution test, which is why\nnothing said so: the old test compared execute() against an UNCHANGED context,\nso no assertion in the suite had ever run the command twice over applied state.\n\nMEASURED. Fixture: Month with Q1={Jan,Feb}, Q2={Apr,May} on Row, Type on Column,\ncursor at data row 0. Pressing `z` (toggle-group-under-cursor) three times:\n\n 1. names Q1, collapses it\n 2. names Q2, collapses it \u003c- NOT the group just collapsed\n 3. returns no effects at all \u003c- row_count() is 0\n\nMechanism. expand_category skips every item of a collapsed group, so the group\ncontributes zero AxisEntry::DataItem. ToggleGroupAtCursor resolves through\nGridLayout::row_group_for(ctx.selected.0), which is indexed by DATA row and walks\nback to the nearest preceding GroupHeader. A collapsed group therefore has no data\nrow from which its own header is the nearest preceding one -- so the cursor can\nname it only while it is expanded.\n\nTwo consequences, and the second is the one a user hits:\n\n - Pressing `z` twice collapses two groups rather than restoring one. The cursor\n did not move; the grid moved under it.\n - A collapsed group can be re-expanded only by `:toggle-group`, which names the\n group in text. From the grid there is no way back.\n\nThis is the third bead in a family about exactly that way back -- improvise-lhi4\n(prune_empty dropped the header) and improvise-0o6x (a leading header was never\ndrawn) both fixed the header's VISIBILITY. AxisEntry's doc comment says a\nGroupHeader is always visible \"so its label and collapse state show\". Both of\nthose are now true and neither makes the header ADDRESSABLE, which is the half\nleft over.\n\nNot fixed under ub4o, which is a test bead: the three tests landed there pin the\nbehaviour above as it stands (a_second_toggle_at_the_same_cursor_names_the_next_\ngroup_not_the_collapsed_one, a_fully_collapsed_row_axis_leaves_the_command_\nnothing_to_address), so whichever answer is chosen here must edit them, which is\nthe point -- they are where the decision will be visible.\n\nOPTIONS, none obviously right:\n\n (a) Let the cursor address a header. That means a cursor position that is not a\n data row, which row_group_for's whole signature (data_row -\u003e visual) is built\n to avoid, and which every other cursor-addressed command would then have to\n answer for. Expensive and wide.\n (b) Make ToggleGroupAtCursor remember: if the view has a collapsed group whose\n header is the nearest preceding one in the VISUAL entry stream at the\n cursor's visual position, expand that instead. Keeps the data-row cursor and\n reads the entry stream, which is how lhi4 was resolved -- from the entries,\n with no \u0026View.\n (c) A separate expand-all / cycle-collapse binding, leaving `z` alone.\n (d) Decide it is acceptable because `:toggle-group` exists, and record that here\n plus in the gotcha, so the next person stops re-finding it. The family of\n three beads is evidence somebody keeps re-finding it.\n\nWhichever is chosen, the guard must inject a rival that collapses a neighbour:\n\"some group is named\" is satisfied by the current behaviour.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T02:23:31Z","created_by":"agent-cmd2","updated_at":"2026-09-23T02:23:31Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-obtu","title":"The aggregate-addressing seam: a view-naming command resolved against that view's layout","description":"The remaining improvise-gd28 work, now that the blocker is correctly diagnosed (see improvise-gd28 and gotcha 44).\n\nAn AggregateHash is layout-scoped and cannot be otherwise, so App::resolve_aggregate answers only for the host's active view -- which since improvise-jytm is not the client's. A browser holding CellId::Aggregate(h) therefore cannot get it resolved.\n\nTHE SHAPE, and it is not a new mechanism: let the command NAME ITS VIEW, exactly as ModelCommand::SetAxis { view: String, .. } already does, and resolve the surrogate against a layout derived for that name rather than against app.layout. grid_in already derives a layout for a session on a view the host is not on, so the machinery exists.\n\nExplicitly NOT the alternative: projecting the coordinates to the client. That is precisely what ADR 002 exists to prevent -- an opaque surrogate stops being opaque and the client starts holding document coordinates.\n\nExplicitly NOT needing a SessionId: the mutation a drill entry performs is addressed to NO ONE session -- it configures the shared _Drill view exactly as CreateView/SetAxis already do unaddressed. Only the RESOLUTION of its operand is session-scoped. That asymmetry is the whole finding.\n\nAlso not needed: improvise-fdk1. edit_or_drill declines records-mode editing out loud before reaching a cell, so a browser cannot edit inside a drill grid at all, and the user's \"staging scheme should be identical\" ruling is kept by the browser getting no edits rather than by a second scheme.\n\nNote the contention issue filed separately: _Drill is one view in Workbook::views, so two clients drilling contend.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t0ma","title":"records_steps is guarded by one incidental test","description":"records_steps (improvise-core/src/view/layout.rs) is guarded by exactly ONE test in the whole workspace, and that test is incidental: injecting is_index_or_dim() -\u003e is_measure() into records_steps failed only save_with_unfinished_record_is_readable, which is a persistence test that happens to enter records mode. Measured during PR #140.\n\nThat is thin for the spine GridLayout auto-detects records mode by (_Index on Row, _Dim on Column). A rival that got the spine wrong in a way that persistence does not observe would ship.\n\nWanted: a step-equivalence test asserting records_steps(model) produces the spine plus exactly one Axis::None step per non-index/dim category, and drill_steps likewise. It must be a test OF THE STEPS, not of the resulting effects -- the effects are cmd/grid.rs's translation and are already covered; the point of the split is that the steps are the model-side answer a browser will also consume.\n\nInject the is_measure() rival and show it fails the new test. Asked for by the ChatGPT Codex review on PR #140.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:26Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-up8g","title":"Viewport extent is an external input on BOTH front ends, and it changes which record an edit writes","description":"The viewport extent is an EXTERNAL INPUT that reaches the document, and it is not a browser problem -- the TUI has exactly the same hole and always did.\n\nrun_headless_commands reads crossterm::terminal::size() into app.term_{width,height}, and CmdContext::{visible_rows, visible_cols} derive from it. Measured: term 80x24 -\u003e visible_rows=16; term 200x60 -\u003e visible_rows=52.\n\nAND IT REACHES THE DOCUMENT. Probe: a 40-row single-category grid, identical command sequence, one PageDown then clear-cell at the cursor:\n term 80x24 -\u003e selected=(12,0) -\u003e cleared Row=R12\n term 200x60 -\u003e selected=(39,0) -\u003e cleared Row=R39\nSame commands, DIFFERENT RECORD DELETED.\n\nSo Client::set_viewport is not a browser-introduced external input; it is the browser's SPELLING of an input the TUI has had all along. Gotcha 47's \"not derivable\" is true of BOTH front ends. Any ADR that treats extent as a browser problem will produce a log that replays in a browser and not in a terminal.\n\nTHE INTERACTION THAT MAKES THIS URGENT: today this hazard is INVISIBLE in `improvise script`, because the script path discards the cursor entirely (improvise-6uva). Fixing 6uva makes this live. The extent must be in the log -- or pinned by the replay harness -- IN THE SAME CHANGE that restores context-aware script parsing, or the repair will look like it works and then be flaky.\n\nThis is the cheapest of the five external inputs to close: it is one pair of integers and it already has a wire spelling (ClientFrame::Viewport). Make it a logged command (set-extent \u003crows\u003e \u003ccols\u003e) emitted on resize. The TUI has no such command today -- that is the only new vocabulary needed, and gotcha 42 already argues the extent is deployment-independent.\n\nA regression test must drive TWO sizes and assert the edit lands on the same record. One size cannot see this.\n\nFound by the improvise-ilvl replay audit.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:54Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:24:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -90,7 +91,7 @@ {"_type":"issue","id":"improvise-1ohv","title":"add-record-row cannot be named on the wire: a client cannot create a record","description":"Found by the improvise-h3y7 completeness audit.\n\nAddRecordRow (cmd/grid.rs:492) builds a CellKey from the active view's\nfiltering_cats() page selections (gotcha 27: Page union Filter, NOT\ncategories_on(Axis::Page)) and writes SetCell(key, Number(0.0)).\n\nA client cannot compose it, for the same reason the coordinate rename cannot be\ncomposed: ModelCommand::SetCell takes a RecordCell whose RecordHash is an opaque\nsurrogate the server resolves against cells that ALREADY EXIST (App::resolve_record,\nui/app.rs:516). The record this command creates does not exist, so there is nothing\nto name it with.\n\nThe client DOES have the operands -- GridCache::page_coords carries the page\nselections (gotcha 33), and page_coords is framed by nothing and survives a reshape.\nWhat it lacks is a variant that TAKES them.\n\nWHY THIS IS NOT A ONE-LINE ADDITION. A ModelCommand that names coordinates rather\nthan a surrogate is a NEW NAMING MODE in the vocabulary. ADR 002 contemplated two:\n\"names its target by name\" (a category, an item, a view) and \"names a record that\nalready exists by a position-free surrogate\". A third -- \"names a record that does\nnot exist yet, by coordinates\" -- wants the ADR conversation improvise-hkbc's guard\nis designed to force. DO NOT SLIP IT IN AS A VARIANT.\n\nCHEAPEST HONEST INTERIM: leave add-record-row declined and land the coordinate-rename\nticket first, which answers most of the same question and may settle the naming mode\non a case where the record's identity is at least partly known.\n\nTHE CLASS THIS BELONGS TO, worth writing into ADR 002 or context/code/protocol.md\nbecause it will be rediscovered otherwise: a client cannot name a record it is about\nto create. That is ADR 002 working as designed, and it is INVISIBLE from the\nModelCommand list, because every existing variant either names things by name or\nnames a record that already exists. `will_be_created_by_the_frame` (ui/reduce.rs:284)\nexempts such a record from the basis check, which reads as though the naming problem\nwere solved. It is not, and the two are easy to confuse.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:53Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:53Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-1ohv","depends_on_id":"improvise-fdk1","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-fo0w","title":"Three black-hole keys are resolvable today over vocabulary that already exists","description":"Found by the improvise-h3y7 completeness audit. THE HEADLINE OF THAT AUDIT is that the\nvocabulary is far more complete than assumed: of thirteen gaps examined, EIGHT are\nclosed in the vocabulary and open only in the resolver. These three need NO new wire\nsurface at all.\n\nEach sits on the_command_names_that_still_need_the_host_are_these (resolve.rs:2114)\nand is therefore a BLACK HOLE -- gotcha 47: Host::handle answers ClientFrame::Key with\nan explicit empty vec, so these keys do nothing at all in a browser rather than being\nmerely slower.\n\n1. `search` -- EnterSearchMode::execute (cmd/mode.rs:386) emits exactly\n [SetSearchMode(true), SetSearchQuery(\"\")]. Both are Commands. Composable in one\n line; the cheapest single win in the audit.\n\n BUT IT MUST NOT SHIP ALONE. search-navigate scans the model for matching cells and\n the client has only a viewport-windowed GridCache, so it is NOT composable --\n shipping `/` without `n` gives a browser user a search box that finds nothing,\n which is a WORSE failure than the current silence. Either land both (search-navigate\n needs a results projection or a host request frame -- file separately once scoped)\n or decline `/` out loud in the meantime.\n\n2. `enter-advance` -- EnterAdvance::execute (cmd/navigation.rs:144) is pure cursor\n arithmetic returning viewport_effects(...), i.e. SetSelected/SetRowOffset/\n SetColOffset, all on the wire. Its row_count/col_count are GridCache::extent, which\n improvise-bpq2 added for precisely this. The bottom-right AbortChain becomes\n KeyOutcome::Ignored. NOTE improvise-bd9o measures this as the black hole that\n swallows a records-mode commit.\n\n3. `hide-selected-row-item` -- ModelCommand::HideItem { view, category, item } exists.\n The operands are AxisFrame::cats.first() and the selected row's label tuple [0],\n both on GridCache.row_frame (gotcha 44).\n\nRULES THAT APPLY TO ALL THREE:\n- Each arm mirrors its Cmd LINE FOR LINE, including where siblings disagree\n (doctrine(mirror_the_cmd_line_for_line), gotcha 47). Tidying them into one rule is\n what makes the client a second derivation.\n- Each must come OFF the host list in the same commit. That list is where closing a\n gap has to come and say so.\n- tests/lockstep.rs is where the evidence goes.\n\nNOT IN THIS TICKET: force-quit, which is in the same file and is a product question\n(what does Ctrl-Q mean in a browser tab?). It should stop being an upstream black hole\neither way, but which way is a ruling.\n\nCAVEAT FROM THE AUDIT, stated so nobody treats the sizes as measured: the auditor read\neach Cmd::execute and confirmed every effect it emits has a Command, but did not write\nthe arms. Operand availability in GridCache is read off the types, not exercised.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:16Z","labels":["browser","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cfly","title":"The wave ships view history with no key that reaches it: declined() still refuses view-back","description":"Found by the wave's integration agent at preview 30560e0f, and it is a WAVE-LEVEL\nresidue rather than a defect in any branch -- which is why nobody could see it.\n\nPR #115 makes the view history genuinely work for a connected client:\nViewBack/ViewForward join the view half of Command, reduce_view folds all three\nview effects line for line, each client's own view_back_stack is maintained, and\na deleted view is pruned from every client's history via Command::ForgetView.\n\nBUT resolve.rs's composed() has NO view-back or view-forward arm, and declined()\nstill answers \"stepping back to the previous view is not available in the browser\nyet\" -- byte-identical to base.\n\nSo the merged wave contains the CAPABILITY and not the KEY. A browser user\npresses `\u003c` and is told the feature does not exist, over machinery that now\nfully supports it.\n\nWHY NEITHER AGENT COULD SEE IT: #115 was told not to touch resolve.rs (another\nagent owned it) and correctly reported the exact lines instead. #114 owned\nresolve.rs and had no reason to look at declined() at all. Reading #115 alone you\nwould conclude browser view-history works; reading #114 alone you would never\nlook.\n\nTHE EXACT LINES, from #115's report and confirmed by the integration agent as\nstill byte-identical to base:\n\n crates/improvise-protocol/src/resolve.rs\n 239: // NOT \"not built\": it commits a drill first, so composing it would\n 240: // commit another client's typing. Needs addressed commands -- clients.md.\n 241: \"view-back\" =\u003e \"stepping back to the previous view\",\n\nDELETE THE COMMENT WITH THE ARM. Its stated reason is WRONG as of #115: composing\nview-back does not commit another client's typing, because a browser session has\nno drill state to commit. Leaving a false reason behind is worse than leaving the\ndecline.\n\nWHAT TO ADD: a composed() arm returning vec![Command::ViewBack], and the forward\nsibling. Note gotcha 47's rule -- each resolver mirrors its Cmd LINE FOR LINE,\nincluding where siblings disagree -- so check ViewNavigate's own execute() rather\nthan assuming the two directions are symmetric.\n\nPRECONDITION: #115 merged. Nothing else; #115 named this explicitly.\n\nNOT IN SCOPE: line 234, \"drill-into-cell\" =\u003e \"drill\". That one still needs the\n_Drill view session-local and the staging design, and the user has since ruled\nthat browser staging must be identical to the TUI's. See improvise-gd28.\n\nALSO: make sure the wave's PR body does not claim browser view-history works.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:17:26Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:22:26Z","closed_at":"2026-09-22T05:22:26Z","close_reason":"Closed","labels":["browser","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-en4s","title":"No guard that a client's formula panel loses a formula its target item's deletion removed","description":"Found while reviewing improvise-g3no; the reasoning is in\ncontext/code/command.md beside the other Dirty notes.\n\nDirty::of(ModelChange::CellData) reports formulas: true. That was written as\ndeliberate over-reporting in the cheap direction -- a cell write cannot change\na formula, so the entry cost one redundant Projection and bought nothing.\n\nIt is now load-bearing. improvise-g3no made Model::remove_item drop the\nformulas targeting the item, and effect::RemoveItem is CellData, so a delete\nreally can take a formula out of the document. Tightening that arm would leave\nevery connected client's formula panel showing a formula the document no longer\nhas, forever, with nothing in the stream to correct it -- gotcha 35's expensive\ndirection, and exactly the failure that put `formulas` on the LayoutGeometry arm\nin the first place.\n\nNothing guards it. removing_a_category_empties_the_clients_formula_panel covers\nthe LayoutGeometry twin only. The missing test is its item-level sibling: a\nsession whose formula panel names a _Measure formula target, a\nModelCommand::RemoveItem for that target, and an assertion that the client's\nFormulaCache no longer names it. Inject the fault by setting formulas: false on\nthe CellData arm of Dirty::of.\n\nLives in ui/project.rs, which is why it is filed rather than written.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:27:48Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-en4s","title":"No guard that a client's formula panel loses a formula its target item's deletion removed","description":"Found while reviewing improvise-g3no; the reasoning is in\ncontext/code/command.md beside the other Dirty notes.\n\nDirty::of(ModelChange::CellData) reports formulas: true. That was written as\ndeliberate over-reporting in the cheap direction -- a cell write cannot change\na formula, so the entry cost one redundant Projection and bought nothing.\n\nIt is now load-bearing. improvise-g3no made Model::remove_item drop the\nformulas targeting the item, and effect::RemoveItem is CellData, so a delete\nreally can take a formula out of the document. Tightening that arm would leave\nevery connected client's formula panel showing a formula the document no longer\nhas, forever, with nothing in the stream to correct it -- gotcha 35's expensive\ndirection, and exactly the failure that put `formulas` on the LayoutGeometry arm\nin the first place.\n\nNothing guards it. removing_a_category_empties_the_clients_formula_panel covers\nthe LayoutGeometry twin only. The missing test is its item-level sibling: a\nsession whose formula panel names a _Measure formula target, a\nModelCommand::RemoveItem for that target, and an assertion that the client's\nFormulaCache no longer names it. Inject the fault by setting formulas: false on\nthe CellData arm of Dirty::of.\n\nLives in ui/project.rs, which is why it is filed rather than written.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmd2","owner":"el-github@elangley.org","created_at":"2026-09-22T02:27:48Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:29:32Z","started_at":"2026-09-23T02:29:32Z","lease_expires_at":"2026-09-23T02:34:32Z","heartbeat_at":"2026-09-23T02:29:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xolb","title":"The wave brief's gate list omitted the node-test jobs, the only gate that sees an unpainted RenderModel field","description":"Found by the wave's integration agent diffing my wave brief against .github/workflows/ci.yml.\n\nMY GATE LIST OMITTED A REAL CI JOB:\n\n ./scripts/node-test.sh 'web/tests/*.test.js' ci.yml:239 \"Browser JS unit tests (node --test)\"\n web/tests/bundle/*.test.js ci.yml:371 (needs a built bundle)\n\nAt base: 36 tests over 5 files, 0 fail. Neither my brief nor context/repo-map.md's \"Testing -- the short version\" mentions either.\n\nWHY IT MATTERS, and it is not a formality: web/tests/render_surface.test.js is the MECHANICAL guard at the RenderModel -\u003e DOM boundary. It parses the pub fields off crates/improvise-wasm-client/src/render.rs and requires model.\u003cfield\u003e to appear in web/app.js + web/shell.js.\n\nPROVEN BY INJECTION: adding `pub injected_unpainted_field: u64` to RenderModel fails that gate with actual: ['injected_unpainted_field'], expected: []. cargo test --workspace is COMPLETELY INDIFFERENT to it.\n\nSo an agent working from a gate list that omits it can expose a projected field, never paint it, pass every gate it was told to run, and ship the defect. That is precisely the cross-branch shape a wave produces: one agent adds the field, another owns the painting.\n\nTWO BLIND SPOTS THE GUARD STATES ABOUT ITSELF, confirmed by reading, so it should not be over-trusted either:\n - it is a SUBSTRING search (page.includes(\"model.\" + field)), so a field read only inside a function nothing calls reads as painted\n - its field regex requires exactly four-space indentation and a lowercase name\n\nTHE GENERALISABLE PART: a gate list carried in a brief is a COPY of CI, and a copy drifts. The brief is written from memory and the repo-map, and both were stale here. Checking the brief against ci.yml is a once-per-wave action, not a once-per-agent one -- no single agent would find this, because q and s never touch RenderModel and r and t each individually pass cargo test.\n\nWHAT TO DO: add both node jobs to the repo-map's testing section and to the wave brief template, so the next brief inherits CI rather than memory.\n","notes":"WIDENED 2026-09-22 by the improvise-rw6l agent, which hit the gap from the other\nside and lost a CI cycle to it.\n\nTHE GATE LIST OMITS A SECOND NODE SUITE, not just one. web/tests/bundle/ does NOT\nmatch the glob web/tests/*.test.js, so an agent given the standard gate list never\nruns it. wasm_surface.test.js is a ratchet on \"the handful of calls the page\nmakes\" and needs a built web/pkg, so it also has a PRECONDITION the other suite\ndoes not. It ran only in CI and failed there (commit 0a920f1).\n\nSo the standard brief needs BOTH lines, and the second needs its precondition\nstated:\n\n ./scripts/node-test.sh 'web/tests/*.test.js'\n web/build.sh \u0026\u0026 ./scripts/node-test.sh 'web/tests/bundle/*.test.js'\n\nTWO MORE ENVIRONMENT NOTES FROM THE SAME AGENT, both of which cost time and\nneither of which is written anywhere:\n\n 1. harness/node_modules DOES NOT EXIST IN A FRESH WORKTREE, and `npx playwright\n test` then fails with \"Cannot find package '@playwright/test'\" -- an error\n about the package, not about the harness, so it reads as a broken checkout.\n `npm install` in harness/ first. (AGENTS.md says npm ci, never npm install,\n for the repo's own dependency discipline; reconcile the two before writing\n this into a brief.)\n 2. The shell guard refuses a heredoc'd Python script for reasons not\n reproducible from its content: the same `python3 - \u003c\u003c'PY'` shape ran\n repeatedly and was then refused twice on a block containing only Rust test\n text. `python3 /dev/stdin \u003c\u003c'PYEOF'` worked. Worth a line in AGENTS.md's\n environment notes beside the `gh` and `bd` ones.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:49:15Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:42:09Z","labels":["docs","testing","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-piyp","title":"every_bound_command_name_is_registered is blind to prefix sub-bindings and F-keys","description":"PROVEN BY INJECTION 2026-09-21 by the wave's integration agent.\n\nevery_bound_command_name_is_registered (improvise-command/src/command/keymap.rs:417) has two blind spots:\n\n - the walk does `Some(Binding::Prefix(_)) | None =\u003e {}` at line 458 -- it NEVER descends into a prefix sub-keymap\n - its private probe_keys() (line 491) covers 0x20..0x7f plus 14 named keys but NO F-keys, while the neighbouring public probe_keys_pub() (line 905) DOES include F(1)/F(2)\n\nINJECTION: two command names bound that no registry entry provides -- one under the `g` prefix, one on F(5) in Normal.\n - every_bound_command_name_is_registered -\u003e ok\n - cargo test --workspace -\u003e 1595 passed, 0 failed, 7 ignored, 37 binaries, BYTE-IDENTICAL to baseline\n\nPOSITIVE CONTROL, which is what separates \"untested path\" from \"broken test\": the same bogus name on Char('~'), a probed non-prefix key -\u003e the test FAILS. So the guard works; its reach is the defect.\n\nCONSEQUENCE: a command name bound only under g/y/Z, or on a function key, can be unregistered -- OR have args that will not parse, since improvise-s13 moved enter-mode into the args-carrying class -- and the whole suite stays green. That is the exact failure the guard was written for, surviving in the region it does not reach.\n\ndump_every_binding DOES descend into prefixes via which_key, but it is a DUMP gated on IMPROVISE_BINDING_DUMP, not an assertion -- inert in CI, so it does not cover the gap.\n\nTHE CONTRAST THAT MAKES THIS WORTH FIXING RATHER THAN NOTING: nothing_declined_is_also_composed in improvise-protocol/src/resolve.rs is the STRONGER guard -- its bound_commands helper DOES recurse into Binding::Prefix and into parents. So the resolution side is guarded where the registration side is not, and an agent reading either test alone would reasonably conclude its bindings are checked. Only comparing the two shows which half is real.\n\nFIX SHAPE: descend into Binding::Prefix in the walk, and make probe_keys cover what probe_keys_pub already does. Both are small. The acceptance is the injection above failing -- test it with a name behind a prefix AND one on an F-key, because they are two different blind spots and one fix may not close both.\n\nRelevant: gotcha 13 (prefixes are stored as an address; KeymapSet::prefix_keymap walks parents so RecordsNormal reaches Normal's g/y/Z) and gotcha 15 (the guard checks BOTH halves of dispatch -- the name resolves and the args parse).\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:49:14Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:16Z","closed_at":"2026-09-22T02:27:16Z","close_reason":"Fixed in PR #112 (wave/q-keymap, head b4bd6bc), both blind spots closed and each injection fails SEPARATELY -- which was the acceptance, because a fix for one need not close the other.\n\n - PREFIX: reachable_names now recurses into Binding::Prefix. Reported via RecordsNormal, which confirms the INHERITED-prefix path rather than only a locally-bound one.\n - F-KEY: probe_keys now DELEGATES to probe_keys_pub, which covers F(1)..=F(12).\n\nTHE FINDING THE BEAD DID NOT PREDICT, and it is the better half: the F-key gap was not an oversight about F-keys. probe_keys and probe_keys_pub were DUPLICATES differing by two lines, and the gap WAS the drift between them. Adding F-keys to both would have closed the symptom and left the mechanism intact. So the duplication doctrine applies to TEST SCAFFOLDING, not only to production derivations -- two copies of a test's probe key space is the same disease as two keymap spines, and it failed the same way.","labels":["command","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-b8ot","title":"Delegate DOM events: one handler per surface on the stable root, not one per node","description":"USER RULING 2026-09-21: \"I want one handler outside the area that gets redrawn\nand dispatch events there.\"\n\nTODAY: one listener PER NODE, created inside the render loops and reallocated on\nevery repaint. Only `window` keydown (app.js:509) and the transport listeners\nare delegated.\n\n app.js:361 td mousedown ONE PER GRID CELL, inside the cell loop\n app.js:436 li click one per panel row\n app.js:476 span click one per tile\n app.js:493 li click one per category row\n app.js:414 input keydown the editor\n app.js:424 input blur the editor\n\nEight replaceChildren sites rebuild the DOM wholesale, so each closure is\ndiscarded and reallocated per frame, and repaints happen roughly per keystroke.\n\nTHE ROOTS ARE ALREADY STABLE, which is what makes this cheap. replaceChildren\nreplaces an element's CHILDREN, not the element, so every `el.*` registry entry\n(app.js:30-43) survives every repaint:\n\n el.grid \u003c- replaceChildren at :214 delegate td mousedown here\n el.categories \u003c- :218 delegate li click here\n el.tiles \u003c- :479 delegate span click here\n el.formulas / el.views \u003c- via `host` at :439 delegate li click here\n\nSo each surface already has exactly the persistent ancestor the ruling asks for.\nNo new wrapper element is needed. Read indices off data- attributes\n(data-row/data-col on a cell, data-index on a row or tile) with\nevent.target.closest(..).\n\nFOUR THINGS A NAIVE REWRITE BREAKS. All are documented and load-bearing:\n\n1. THE GRID USES mousedown, NOT click, DELIBERATELY (improvise-0x9u). From an\n editing state the input's blur repaints first, so the \u003ctd\u003e is gone before a\n click could fire. Delegation does not change that -- the target is detached\n before the event -- so it must stay mousedown.\n\n2. IT CALLS preventDefault(). Dropping that reintroduces focus/selection\n behaviour the editor depends on.\n\n3. IT CONDITIONALLY SKIPS THE REPAINT. On an editable cell it calls edit(..) and\n does NOT repaint, because a repaint would take the \u003cinput\u003e away. The comment\n at :356-360 says so. A delegated handler must keep that branch.\n\n4. IT CLOSES A LIVE EDITOR FIRST (`if (editing) editing.close(false)`), before\n composing the click. Order matters.\n\nTHE EDITOR'S OWN TWO LISTENERS (keydown, blur at :414/:424) are a different\ncase: one transient node, one at a time, attached at creation and gone with it.\nDelegating those buys nothing and would need the blur/keydown to find the input\nthrough a root that does not own it. Leave them direct unless there is a reason;\nsay so in the change rather than silently keeping them.\n\nWHY THIS IS NOT PRIMARILY A PERFORMANCE TICKET, and should not be argued as one:\nthe cell count is bounded by the VIEWPORT, not the document -- set_viewport\nannounces a real window and GridCache is viewport-indexed for the browser\n(gotcha 47) -- so it is a few hundred closures per repaint, not thousands. And\nit does not leak: replaceChildren drops the nodes and their listeners with them.\nimprovise-i5tk's rule applies -- do not claim a performance win without a\nbenchmark, and \"it is fine\" is exactly as unevidenced as \"this dominates\".\n\nThe real argument is the repo's own: design-principles section 5 wants web/app.js to\ntrend to \"paint and wire\", and one handler per surface is wire where one handler\nper node is bookkeeping that grows with the data.\n\nTESTING: a change to web/*.js belongs in harness/tests/shell.spec.js (gotcha 48),\nnot a new ad-hoc surface. The assertions that matter are the four above, each of\nwhich is a behaviour a rewrite can silently lose: a click from an OPEN EDITOR\nstill lands (that is 0x9u's own case), the editor is not taken away when opening\none, and a click on the panel/tile surfaces still dispatches after a repaint.\nPure decision logic (target -\u003e indices) can go in web/shell.js, which is\nnode --test-able with no DOM.\n","notes":"USER LATITUDE 2026-09-21: \"We can use IDs, classes or data attributes as necessary to disambiguate the action to be taken.\"\n\nThe mechanism is open. One observation that should shape which is used, because the plumbing already exists and a delegated handler that ignores it would be a second derivation.\n\nTHE CELL ALREADY CARRIES ITS OWN IDENTITY ACROSS. web/shell.js gridPlan (lines 88-108) builds each cell with row, col, text, editText, editable, and a classes map of numeric/error/selected/typing/editable. web/app.js line 352 does nothing but toggle those classes onto the node. So row, col and editable are ALREADY decided upstream and already reach the node -- and row deliberately counts DATA rows rather than trusting position, because a group header is not a data row (shell.js lines 72-74).\n\nCONSEQUENCE: a delegated handler should read what gridPlan already decided rather than re-derive it from DOM structure. Writing data-row / data-col / an editable class is carrying an existing answer. Walking up the table counting rows to work out which data row was clicked would be a SECOND derivation of the very thing shell.js 72-74 exists to get right, and it would silently disagree the moment a group header appears.\n\nWHERE THE DECISION BELONGS: gridPlan, and the panel/tile equivalents. It is pure, it has no DOM, and it is already node --test-able -- so \"which action does this node mean\" can be unit-tested without a browser, leaving the delegated handler as a lookup. That is design-principles section 5's paint-and-wire applied to the dispatch as well as to the paint.\n\nPICKING BETWEEN THE THREE, given the latitude:\n - data attributes for the OPERANDS (data-row, data-col, data-index). They are values, and an attribute is where a value goes.\n - classes for the PREDICATES that already exist. editable is already a class; reusing it costs nothing and keeps one answer.\n - IDs only for the surfaces themselves, which they already are. Not for rows or cells -- an id must be unique per document and these are rebuilt every frame.\n\nA node's action is then: surface from the delegation root, operands from data attributes, branch from the class. No structural inference anywhere.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T00:36:44Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:41:44Z","labels":["browser","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -104,9 +105,9 @@ {"_type":"issue","id":"improvise-bok4","title":"A failing effect narrates instead of reporting, so a frame containing it is accepted","description":"Found by the wave's integration/review agent auditing PR #109, and confirmed and\nscoped by that PR's author. ADR-001 clause 4 now rolls back the document, the\nmirrors and the session slice -- but the partial-and-silent SHAPE it exists to\nkill survives one layer down, for every command whose target is not a record.\n\nMECHANISM: reduce::broken_chain asks records_named, which names only SetCell and\nClearCell. Everything else is unguarded. Separately, an effect that FAILS writes\na sentence to view_state.status_msg and does NOT set abort_effects, so the frame\ncontaining it is reported ACCEPTED.\n\nMEASURED AS ACCEPTED, both by the reviewer:\n\n [DeleteView(\"Scratch\"), SetNumberFormat { view: \"Scratch\" }]\n [RemoveCategory(\"Region\"), AddItem { category: \"Region\" }]\n\nThe second is the sharper one: it writes its error to the HOST's status line and\nnever tells the client anything at all. A connected browser sees a frame it\ncomposed reported accepted, with one of its commands having done nothing, and no\nchannel carrying the reason.\n\nWHY THIS IS NOT PART OF improvise-yqpd: closing the class needs effects to\nREPORT failure rather than narrate it -- roughly fifty of them -- which is a\ndifferent change from the snapshot-and-restore clause 4 landed. yqpd closed the\nrecord-addressed INSTANCE. That scope is stated in PR #109's body under \"what\nthis PR does NOT close\" rather than left implied.\n\nTHE SHAPE OF A FIX, not yet decided: an Effect currently returns nothing, so\nfailure has nowhere to go except status_msg. Either effects gain a fallible\nresult that apply_effects can turn into an abort, or the per-command check grows\nbeyond records_named to ask \"does this command's target still resolve\" for the\nview-, category- and item-addressed variants too. The first is larger and\ncloses the class; the second is narrower and closes the reachable cases.\n\nNote the interaction with gotcha 21: an abort is not a rollback, and tidy-up\ngoes at the HEAD of a batch where an abort cannot reach it. Any change here has\nto keep that property, because it is what stops a failed save stranding the user\nin a mode whose minibuffer hides the error.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T22:49:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:49:20Z","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hkbc","title":"Guard the position-free ModelCommand vocabulary, which 6nsd's answer silently depends on","description":"improvise-6nsd was closed as \"not a hole, conditionally\": a stale layout tier\ncannot misdirect a mutation BECAUSE no ModelCommand variant carries a row or\ncolumn index. Verified independently by two agents over all 24 variants.\n\nNothing guards it. A future positional variant reopens the hole SILENTLY, and\nlayout_version under-reports here by construction -- gotcha 35's expensive\ndirection, where under-reporting leaves a client wrong forever.\n\nThe guard: an exhaustive match over ModelCommand in improvise-protocol asserting\nevery variant names its target BY NAME (category, item, group, view, axis,\nformat, path) or by a position-free surrogate (RecordCell/RecordHash). Adding a\npositional variant then fails the build and forces the ADR conversation rather\nthan inheriting an answer that no longer holds.\n\nShape it like reduce.rs::records_named, which is already an exhaustive match\nwith an or-pattern for the variants that name no record -- same file, same\ndiscipline, and the two can be read together.\n\nNOT covered by this guard, and stated so nobody thinks it is: position-free ON\nTHE WIRE is not position-free END TO END. improvise-2wu3 is the live\ncounterexample -- web/app.js captures viewport (row, col) at paint time and\nresolves it against the current cache at commit. That is a client bug the\nprotocol's discipline makes possible to have, not one this test can catch.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:19Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:19Z","labels":["protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-k84e","title":"Clause 4 rollback of a frame containing LoadModel: the rebase-then-undo case is untested","description":"ADR-001 clause 4 landed in improvise-yqpd: Host::commands snapshots the document\nAND clones the whole Sessions before a frame, restoring both on failure.\n\nUNTESTED, and the agent that built it named this as the residual it would most\nwant reviewed: a failing frame that CONTAINS a document-replacing command.\n\nThe sequence: LoadModel -\u003e Applied::Rebased -\u003e every mirror reseeded and fresh\nSnapshots owed -\u003e then a later command in the same frame fails -\u003e undo_frame\nrestores the document and the Sessions clone.\n\nIt SHOULD be sound, because the Sessions clone restores ClientSession::generation\nalong with everything else, so the reseed is undone with it. But no fixture\nexercises it and \"should be sound\" is the phrasing that precedes every entry in\nthis repo's gotcha list.\n\nNote the fixture warning from gotcha 39 applies with force here: when testing a\ndocument swap, the incoming document's active view must have the SAME NAME as\nthe outgoing one, or apply_model broadcasts a SwitchView, the client folds it,\nand the stale cursor is read under a key nothing has written -- right by\naccident, with the bug invisible. Use a_document_shaped_differently_under_the_same_view_name.\n","notes":"PARTLY ANSWERED by PR #109's fix round (2026-09-21), scope narrowed.\n\nWHAT IS NOW DONE: the ghost active_view this bead's sibling finding named is FIXED, and a_rolled_back_load_leaves_no_ghost_view_and_no_false_status covers the rolled-back LoadModel. The fixture was renamed so it cannot pass vacuously -- with both documents named Default the ghost is indistinguishable from a correct value, and the test failed left: \"Loaded\", right: \"Default\" before the fix.\n\nChecked before renaming, and worth recording: gotcha 39 requires a SAME-NAME fixture for the stale-cursor case, but that is a DIFFERENT fixture (a_document_shaped_differently_under_the_same_view_name in project.rs). Neither user of host_over_a_loadable_document depends on the names matching, so gotcha 39's guard is untouched.\n\nWHAT REMAINS, and this bead should stay open scoped to it: the Applied::Rebased path proper -- a frame whose LoadModel SUCCEEDS, emits Snapshots, and is THEN rolled back by a later command. The Sessions clone restores ClientSession::generation so it should be sound, but the Snapshots were already computed and discarded, and nothing tests that.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:19Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:49:21Z","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lm5e","title":"[test] Debug-string tests verify effect names, not correctness","description":"## Summary\n\nUmbrella: many command tests assert `dbg.contains(\"EffectName\")` on debug-formatted effect lists. That checks type-name presence, not field values, correct application, or shadowing by later effects.\n\n## Steps to Reproduce\n\n1. Change an effect's fields to incorrect values while keeping the type name.\n2. `dbg.contains(\"AddCategory\")`-style tests still pass.\n\n## Acceptance Criteria\n\n- High-value tests assert structured effect fields / post-fold state, not only Debug substrings; remaining Debug checks documented as smoke-only.\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\nMultiple files under `crates/improvise-command/src/command/cmd/`\n\n## PR / Attribution\n\nPR #100 F20 umbrella. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmd","owner":"box@local","created_at":"2026-09-21T20:49:06Z","created_by":"external-grok-bot","updated_at":"2026-09-23T01:25:32Z","started_at":"2026-09-23T01:25:32Z","lease_expires_at":"2026-09-23T01:30:32Z","heartbeat_at":"2026-09-23T01:25:32Z","external_ref":"gh-pr-100-F20-debug","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gjat","title":"[test] sequence-associativity test proves a mathematical tautology","description":"## Summary\n\n`law_sequence_associativity` concatenates effect Vecs as `(a+b)+c` vs `a+(b+c)`. List concatenation is trivially associative — the test cannot fail regardless of command behavior (tautology).\n\n## Steps to Reproduce\n\n1. Note the test only compares Vec concatenations of execute results on the same ctx.\n2. It passes for any deterministic executes that return Vecs.\n\n## Acceptance Criteria\n\n- Replace with a property that can fail (ordered fold of effects on state), or remove as non-evidence.\n\n## Doctrine\n\n`doctrine(fold_tests_must_test_order_not_the_axiom)`\n\n## Location\n\n`crates/improvise-command/src/command/cmd/navigation.rs` ~L388-431\n\n## PR / Attribution\n\nPR #100 F16. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmd","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-23T01:18:57Z","started_at":"2026-09-23T01:18:57Z","lease_expires_at":"2026-09-23T01:23:57Z","heartbeat_at":"2026-09-23T01:18:57Z","external_ref":"gh-pr-100-F16-assoc","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ub4o","title":"[test] toggle-group-involution test doesn't apply effects between calls","description":"## Summary\n\n`law_toggle_group_involution` asserts two `cmd.execute(\u0026ctx)` debug outputs are equal without applying the first effects. `CmdContext` is borrowed/immutable — the test only proves deterministic pure execute, not involution (toggle twice = identity).\n\n## Steps to Reproduce\n\n1. Fault-inject `ToggleGroupAtCursor` to return different effects on odd/even calls without applying between.\n2. Test still passes (same unchanged ctx).\n\n## Acceptance Criteria\n\n- Test applies first effects (or folds state) before second execute; can fail when involution is broken.\n\n## Doctrine\n\n`doctrine(fold_tests_must_test_order_not_the_axiom)`\n\n## Location\n\n`crates/improvise-command/src/command/cmd/grid.rs` ~L26-36\n\n## PR / Attribution\n\nPR #100 F15. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"open","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:27:47Z","external_ref":"gh-pr-100-F15-involution","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lm5e","title":"[test] Debug-string tests verify effect names, not correctness","description":"## Summary\n\nUmbrella: many command tests assert `dbg.contains(\"EffectName\")` on debug-formatted effect lists. That checks type-name presence, not field values, correct application, or shadowing by later effects.\n\n## Steps to Reproduce\n\n1. Change an effect's fields to incorrect values while keeping the type name.\n2. `dbg.contains(\"AddCategory\")`-style tests still pass.\n\n## Acceptance Criteria\n\n- High-value tests assert structured effect fields / post-fold state, not only Debug substrings; remaining Debug checks documented as smoke-only.\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\nMultiple files under `crates/improvise-command/src/command/cmd/`\n\n## PR / Attribution\n\nPR #100 F20 umbrella. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.\nPR #148 closes the FIELD-CARRYING-EFFECT SLICE of this umbrella and says so; roughly 60 Debug-substring assertions remain outside the 16, including 9 in cmd/grid.rs. Evidence the step was run rather than argued: one patch made 14 command sites emit the right effect TYPE with a WRONG CONSTANT FIELD. Under the old assertions that was 525 passed / 6 failed; under the new ones 510 / 21. So 14 of the 16 survived the rival, and of the 6 that did fail only one was in the list -- the other five were in ui/app.rs and ui/reduce.rs, meaning THE COMMAND LAYER NEVER CHECKED A FIELD AT ALL. Assertions on FIELDLESS effects (Quit, Save, RebuildLayout, TransposeAxes, AbortChain, TogglePruneEmpty) were deliberately left alone: there the type name IS the whole claim, so naming it is complete rather than smoke.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmd","owner":"box@local","created_at":"2026-09-21T20:49:06Z","created_by":"external-grok-bot","updated_at":"2026-09-23T02:17:29Z","started_at":"2026-09-23T01:25:32Z","lease_expires_at":"2026-09-23T01:30:32Z","heartbeat_at":"2026-09-23T01:25:32Z","external_ref":"gh-pr-100-F20-debug","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gjat","title":"[test] sequence-associativity test proves a mathematical tautology","description":"## Summary\n\n`law_sequence_associativity` concatenates effect Vecs as `(a+b)+c` vs `a+(b+c)`. List concatenation is trivially associative — the test cannot fail regardless of command behavior (tautology).\n\n## Steps to Reproduce\n\n1. Note the test only compares Vec concatenations of execute results on the same ctx.\n2. It passes for any deterministic executes that return Vecs.\n\n## Acceptance Criteria\n\n- Replace with a property that can fail (ordered fold of effects on state), or remove as non-evidence.\n\n## Doctrine\n\n`doctrine(fold_tests_must_test_order_not_the_axiom)`\n\n## Location\n\n`crates/improvise-command/src/command/cmd/navigation.rs` ~L388-431\n\n## PR / Attribution\n\nPR #100 F16. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cmd","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-23T02:17:28Z","started_at":"2026-09-23T01:18:57Z","closed_at":"2026-09-23T02:17:28Z","close_reason":"PR #144. law_sequence_associativity asserted effects_debug((a++b)++c) == effects_debug(a++(b++c)) -- the associativity of Vec::extend, and it never applied an effect. Replaced by a test that folds the batch a Binding::Sequence really emits (one flattened effect vec) and asserts the last write wins across four orderings. Rivals: Move returning Vec::new() when the position does not change, and apply_effects folding the batch in reverse -- the old law passed both. NOT PREDICTED, and it is the transferable part: the first replacement applied each command as its OWN apply_effects batch, and the reversed-fold rival DID NOT FIRE, because each single-Move batch reverses to the same cursor. A fold test that batches per step is testing the wrong fold.","external_ref":"gh-pr-100-F16-assoc","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ub4o","title":"[test] toggle-group-involution test doesn't apply effects between calls","description":"## Summary\n\n`law_toggle_group_involution` asserts two `cmd.execute(\u0026ctx)` debug outputs are equal without applying the first effects. `CmdContext` is borrowed/immutable — the test only proves deterministic pure execute, not involution (toggle twice = identity).\n\n## Steps to Reproduce\n\n1. Fault-inject `ToggleGroupAtCursor` to return different effects on odd/even calls without applying between.\n2. Test still passes (same unchanged ctx).\n\n## Acceptance Criteria\n\n- Test applies first effects (or folds state) before second execute; can fail when involution is broken.\n\n## Doctrine\n\n`doctrine(fold_tests_must_test_order_not_the_axiom)`\n\n## Location\n\n`crates/improvise-command/src/command/cmd/grid.rs` ~L26-36\n\n## PR / Attribution\n\nPR #100 F15. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmd2","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-23T02:18:56Z","started_at":"2026-09-23T02:18:56Z","lease_expires_at":"2026-09-23T02:23:56Z","heartbeat_at":"2026-09-23T02:18:56Z","external_ref":"gh-pr-100-F15-involution","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-y01k","title":"ws-server silently drops ServerFrame/ClientFrame/closed-doc traffic","description":"## Summary\n\nws-server silently loses protocol traffic in three places: (1) `ServerFrame` serde failure dropped with `continue` in writer task; (2) unparseable ClientFrame text ignored with no log; (3) ClientFrame for a closed/unregistered document yields empty response — client gets nothing (no rejection).\n\n## Steps to Reproduce\n\n1. Force ServerFrame that fails `serde_json::to_string` — frame lost, no log.\n2. Send `{\"Unknown\": {}}` text — no response, no log.\n3. Send commands after document close/evict — empty response, client keeps composing.\n\n## Acceptance Criteria\n\n- Serialize/parse failures are logged (and ideally signaled to the peer); closed-doc frames produce an explicit rejection/error frame.\n\n## Location\n\n`crates/improvise-ws-server/src/transport.rs` ~L179-183, L240-248; `registry.rs` `handle` ~L193-197\n\n## PR / Attribution\n\nPR #100 F17+F18+F19 consolidated. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:59Z","closed_at":"2026-09-21T21:58:59Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nAll three drops logged.","external_ref":"gh-pr-100-F17-19-ws","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-r4nf","title":"serde_json failures in wasm exports are silent drops, not errors","description":"## Summary\n\nWasm exports `on_key_event`, `typed_cell`, `clicked` use `.ok()` on `serde_json::to_string()`. Serialization failure returns `None`, which JS treats as \"nothing to send\" (not editable / invalid click) — a valid operation is silently dropped.\n\n## Steps to Reproduce\n\n1. Inject a non-serializable value into the frame.\n2. Observe no error reaches the JS caller; operation vanishes.\n\n## Acceptance Criteria\n\n- Serde failures surface as errors to JS (not conflated with empty/None outcomes).\n\n## Location\n\n`crates/improvise-wasm-client/src/lib.rs` — `on_key_event` ~L54, `typed_cell` ~L97, `clicked` ~L178\n\n## PR / Attribution\n\nPR #100 F13. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:04Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:59:00Z","closed_at":"2026-09-21T21:59:00Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nResult\u003cOption\u003cString\u003e, JsError\u003e; Option still means 'nothing to send', a serde failure throws. JS 'if (frame)' unchanged everywhere.\n\nShape-grep confirmed no second site: serde_json::(to_string|from_str)...ok() across crates/ and src/ gives 3 hits, all in wasm-client/lib.rs, all named by this bead. Zero remain.","external_ref":"gh-pr-100-F13-serde","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xg1z","title":"editing mode with missing buffer renders empty overlay instead of cell content","description":"## Summary\n\nIf `is_editing()` is true but no `\"edit\"` buffer exists, `render_model` sets `editing` to `Some(\"\")`. The UI shows an empty editor overlay over cell content.\n\n## Steps to Reproduce\n\n1. Fold `ChangeMode(Editing)` without preceding `SetBuffer{name:\"edit\",...}`.\n2. Call `render_model()` — empty overlay instead of cell content.\n\n## Acceptance Criteria\n\n- Missing edit buffer falls back to cell content (or refuses Editing mode loudly); never blank overlay over real content.\n\n## Location\n\n`crates/improvise-wasm-client/src/render.rs` — `render_model` ~L268-271\n\n## PR / Attribution\n\nPR #100 F14. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"open","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:04Z","created_by":"external-grok-bot","updated_at":"2026-09-21T20:49:04Z","external_ref":"gh-pr-100-F14-edit","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -314,11 +315,12 @@ {"_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-jgzw","title":"The tile bar offers _Index and _Dim, because every call site passes category_names()","description":"All four tile call sites pass Model::category_names() -- which returns EVERY category, virtual ones included -- to View::tileable_cats:\n\n ui/project.rs:403, cmd/tile.rs:94, cmd/tile.rs:119, src/ui/tile_bar.rs:180\n\ntileable_cats filters only on axis (it drops the coordinates a drill fixed). In a default view _Index and _Dim sit on Axis::None, which IS user-assignable, so they pass the filter. Consequence: TILE CURSOR 0 IS _Index, not the first regular category.\n\nFound while writing improvise-lm5e's field-naming assertions (PR #148): no assertion in the suite had ever named a category, so nothing said so. Behaviour is UNCHANGED by that PR -- this ticket is the question it exposed, not a regression.\n\nTHE NUANCE THAT DECIDES THIS, and it is why the obvious fix is wrong: _Measure MUST stay tileable. Putting _Measure on an axis is not exotic, it is what every shipped document does (`_Measure: column` is in web/demo.improv and in the format example). So this is NOT \"use regular_category_names() for user-facing pickers\" as the repo-map advises for prompts -- that would remove _Measure and break the common case.\n\nThe categories actually in doubt are _Index and _Dim, which are records-mode machinery: GridLayout AUTO-DETECTS records mode from _Index-on-Row plus _Dim-on-Column, so a user assigning them by hand through the tile bar can construct that state by accident, without the mode change and the keymap that ToggleRecordsMode brings. Worth checking what that actually does before deciding it is bad -- it may be harmless, in which case the answer is to leave it and say why here.\n\nSo the options are: (a) leave it, recording the reason; (b) filter _Index/_Dim specifically, keeping _Measure -- which needs a predicate named for the distinction, since CategoryKind::is_index_or_dim() already exists and says exactly this; (c) decide records mode should be reachable that way and leave it deliberately.\n\nWhichever is chosen, ASSERT A CATEGORY NAME in the tile tests -- the gap that hid this is that none of them did. The rival to inject is a filter that also drops _Measure: it must fail a test that puts _Measure on a column.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T02:16:27Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:16:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t219","title":"_Drill is one shared view, so two clients drilling contend","description":"_Drill is a single view in Workbook::views, and Workbook::create_view REPLACES (deliberately -- see core.md and improvise-vw47). So two clients drilling at once contend: the second drill overwrites the first client's _Drill configuration, and since a View is shared document state the first client's grid silently becomes the second client's drill.\n\nPRE-EXISTING and NOT WORSENED by improvise-gd28's decomposition -- filed so it is not discovered as a regression of that work. Same is true of _Records.\n\nIt is currently unreachable because the browser cannot enter a drill at all (see the aggregate-addressing seam ticket). Closing that makes this live, so the two should be considered together -- the same masking relationship improvise-6uva has with improvise-up8g.\n\nNeeds a per-client view namespace, which is a real design question: a virtual view is document state by construction today (it lives in Workbook::views and is what GridLayout reads), but a drill is a UI act belonging to one client -- \"the model is shared, the UI is not\" says it should not be in the workbook at all. Note VIRTUAL_VIEW_NAMES are never persisted, so there is no format cost to moving them.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-q36k","title":"jq is not in flake.nix, so a clean nix develop cannot run the pr-reviews suite","description":"Found by the improvise-f9qf agent while making scripts/checkers.sh run every\ncheck_the_checker by default.\n\nscripts/pr-reviews needs jq, and JQ IS NOT IN flake.nix. It resolves only via the\ndeveloper's own ~/.nix-profile, so A CLEAN `nix develop` WOULD FAIL\n`checkers.sh pr-reviews`. Nobody has hit it because every machine that has run it\nso far happened to have jq installed independently.\n\nThis is the same class as the bug improvise-f9qf just closed: an environment\ndifference that is invisible on the machine that has the thing, and fatal on the\none that does not. checkers.sh now scrubs the environment to an allowlist, which\nmakes the dev shell closer to CI -- and makes THIS gap reachable, because the\nallowlist keeps PATH but the flake does not put jq on it.\n\nTwo smaller siblings worth checking in the same pass, since they are the same\nquestion:\n - kb-test.sh fails SIXTEEN CASES CONFUSINGLY when tpl (trealla) is absent,\n rather than saying \"no trealla\". Pre-existing, and newly VISIBLE because\n checkers.sh runs everything by default rather than the suite you named.\n - What else does a scripts/ suite shell out to that the flake does not provide?\n The answer is a grep, and the fix is either a flake input or an explicit\n \"this suite needs X\" refusal with a distinct exit code -- checkers.sh already\n uses exit 3 for \"no usable bash\", so the precedent exists.\n\nPREFER THE EXPLICIT REFUSAL over silently skipping: a suite that quietly does not\nrun is how a green gate stops meaning anything, which is the failure improvise-f9qf\nwas filed about.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T23:16:24Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:16:24Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8mgz","title":"Row headers scroll out of the grid: not sticky, and the offsets cannot be computed in CSS","description":"Noticed by the improvise-tg27 agent while probing narrow widths, and correctly\nleft out of scope there.\n\n#grid is horizontally scrolled by scrollIntoView({inline:\"nearest\"}), and the\nrow-header columns are NOT sticky -- so at a narrow width the coordinate labels\nscroll off the screen and the user is looking at numbers with nothing naming the\nrows they belong to.\n\nPRE-EXISTING; improvise-tg27 did not introduce it and did not widen it. That fix\nstopped the PAGE scrolling horizontally (the app bar's reserves were the cause);\nthis is the GRID's own horizontal scroll, which is legitimate and remains.\n\nWHY IT IS NOT A CSS ONE-LINER: position:sticky on the row-header cells is the\nobvious move, and the grid's header structure is multi-level -- AxisFrame carries\n`cats` outermost-first with label TUPLES (gotcha 44), so there are N row-header\ncolumns, not one, and each needs its own `left` offset computed from the widths of\nthose to its left. The width rule has one home, improvise_protocol::header_level_widths,\nand CSS cannot read it.\n\nThat is the same shape as the sticky COLUMN headers improvise-tg27's agent\ndeclined for the same reason: getting the offsets wrong is SILENT -- the cell\nhides BEHIND the header while getBoundingClientRect still reports it in view, so\nthe existing scroll assertions stay green.\n\nSO THE TEST HAS TO ASSERT OCCLUSION, not position: that the row-header cell is the\ntopmost element at its own coordinates (elementFromPoint), not merely that its\nrect is inside the viewport. Without that, a wrong offset ships green.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T23:07:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:07:55Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rx8g","title":"#hint is illegible at phone width: 1px at 520, and fixing it reopens n6t8's zero-basis argument","description":"Found and MEASURED by the improvise-tg27 agent, which first claimed its fix made\nthe hint legible, found its own spec had asserted that and PASSED, and corrected\nboth.\n\n#hint is flex:1 1 0 -- the only elastic child of the app bar (improvise-n6t8) --\nso after the header wraps it gets whatever the line leaves. Measured, settled\n(i.e. after #status composes the transport fact, not immediately after paint):\n\n 360px -\u003e 26px 520px -\u003e 1px 600px -\u003e 81px\n 700px -\u003e 317px 768px -\u003e 385px 900px -\u003e 67px 1024px -\u003e 6px\n\nNON-MONOTONIC, because it is the remainder after #view-name + #mode + #status take\ntheir reserves, and #status composes the DOCUMENT'S NAME, so the numbers are\ndocument-dependent too.\n\nWHY THIS IS NOT A ONE-LINE FIX, and the reason it is filed rather than patched:\ngiving #hint a min-width so it forces its own line DIRECTLY CONTRADICTS\nimprovise-n6t8's argument for flex:1 1 0 -- \"zero basis, absorbs the remainder,\ncontributes NOTHING TO OVERFLOW\". That basis is what stops the hint widening the\ndocument, which is exactly what improvise-tg27 then had to fix at the header\nlevel. So this reopens n6t8's reasoning and needs it argued again, not worked\naround.\n\nWORTH CONSIDERING INSTEAD OF A min-width: the hint is REFERENCE, not state (n6t8's\nown framing), so the honest answer at phone width may be to DROP it rather than\nsqueeze it -- a visible nothing beats an illegible something, and the affordance\nit carries is duplicated in the help overlay. That is a product call.\n\nMeasurements are in context/test-evidence/clients.md. Do not re-derive them\nimmediately after paint: the spec that did read 186px where the settled value is\n1px.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T23:07:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:07:55Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-t14d","title":"The TUI title bar drops '?:help :q quit' when the document name is long: fill_line gives the left segment priority","description":"Found by the improvise-p8ls agent while proving that ticket invalid. The\naffordance it was filed about DOES exist -- and it is the first thing sacrificed\nwhen the title bar overflows, which happens at ordinary widths.\n\nsrc/draw.rs:172:\n fn fill_line(left: String, right: \u0026str, width: u16) -\u003e String {\n let pad = \" \".repeat((width as usize).saturating_sub(left.len() + right.len()));\n format!(\"{left}{pad}{right}\")\n }\nVerified by the orchestrator. On overflow it simply concatenates, and ratatui\nclips the RIGHT -- which is where \" ?:help :q quit \" lives.\n\nMEASURED against the real draw() through a TestBackend:\n w= 36 | improvise · Sales (sales.improv)| affordance GONE\n w= 44 ... ?:help| half gone\n w= 52 ... ?:help :q qui| truncated\n w= 60 intact\n\nBUT WIDTH IS NOT THE REAL VARIABLE -- TITLE LENGTH IS, and it is unbounded:\n w=100 | improvise · Quarterly Regional Sales Analysis\n (quarterly-regional-sales-2026.improv) ?:help :q|\nAn ordinary document name plus filename destroys the escape hatch AT 100 COLUMNS,\nin Normal, RecordsNormal and TileSelect.\n\nSO THE PRIORITY IS BACKWARDS: the left segment (decoration) has unconditional\npriority over the escape hatch (the thing a stuck user needs). That is the bug.\n\nTWO SMALLER FAULTS IN THE SAME THREE LINES:\n 1. left.len() is BYTES, and the title contains \"·\" (2 bytes in UTF-8), so the\n padding is always one column short and a non-ASCII document name shifts the\n right segment further left. Note src/ui/text.rs::truncate now exists as the\n one display-width-safe truncator (improvise-8l0c) -- fill_line predates it\n and does not use it.\n 2. There is no truncation strategy at all, so the failure is a MID-TOKEN clip\n (\"?:\") rather than a graceful drop of the whole segment.\n\nNOT A DUPLICATE of improvise-tg27, which is the WEB client's grid columns and\nweb/style.css flex/overflow. Different front end, different file, different\nmechanism. bd search \"narrow\" and \"title bar\" found nothing covering this.\n\nWHEN FIXING: give the right segment priority, truncate the left with\nsrc/ui/text.rs::truncate (display cells, never bytes), and test through\nTestBackend at the widths above -- a test at 80 columns with a short fixture name\ncannot see this.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-22T22:59:23Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:18:16Z","started_at":"2026-09-23T01:18:16Z","lease_expires_at":"2026-09-23T01:23:16Z","heartbeat_at":"2026-09-23T01:18:16Z","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-t14d","title":"The TUI title bar drops '?:help :q quit' when the document name is long: fill_line gives the left segment priority","description":"Found by the improvise-p8ls agent while proving that ticket invalid. The\naffordance it was filed about DOES exist -- and it is the first thing sacrificed\nwhen the title bar overflows, which happens at ordinary widths.\n\nsrc/draw.rs:172:\n fn fill_line(left: String, right: \u0026str, width: u16) -\u003e String {\n let pad = \" \".repeat((width as usize).saturating_sub(left.len() + right.len()));\n format!(\"{left}{pad}{right}\")\n }\nVerified by the orchestrator. On overflow it simply concatenates, and ratatui\nclips the RIGHT -- which is where \" ?:help :q quit \" lives.\n\nMEASURED against the real draw() through a TestBackend:\n w= 36 | improvise · Sales (sales.improv)| affordance GONE\n w= 44 ... ?:help| half gone\n w= 52 ... ?:help :q qui| truncated\n w= 60 intact\n\nBUT WIDTH IS NOT THE REAL VARIABLE -- TITLE LENGTH IS, and it is unbounded:\n w=100 | improvise · Quarterly Regional Sales Analysis\n (quarterly-regional-sales-2026.improv) ?:help :q|\nAn ordinary document name plus filename destroys the escape hatch AT 100 COLUMNS,\nin Normal, RecordsNormal and TileSelect.\n\nSO THE PRIORITY IS BACKWARDS: the left segment (decoration) has unconditional\npriority over the escape hatch (the thing a stuck user needs). That is the bug.\n\nTWO SMALLER FAULTS IN THE SAME THREE LINES:\n 1. left.len() is BYTES, and the title contains \"·\" (2 bytes in UTF-8), so the\n padding is always one column short and a non-ASCII document name shifts the\n right segment further left. Note src/ui/text.rs::truncate now exists as the\n one display-width-safe truncator (improvise-8l0c) -- fill_line predates it\n and does not use it.\n 2. There is no truncation strategy at all, so the failure is a MID-TOKEN clip\n (\"?:\") rather than a graceful drop of the whole segment.\n\nNOT A DUPLICATE of improvise-tg27, which is the WEB client's grid columns and\nweb/style.css flex/overflow. Different front end, different file, different\nmechanism. bd search \"narrow\" and \"title bar\" found nothing covering this.\n\nWHEN FIXING: give the right segment priority, truncate the left with\nsrc/ui/text.rs::truncate (display cells, never bytes), and test through\nTestBackend at the widths above -- a test at 80 columns with a short fixture name\ncannot see this.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-22T22:59:23Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:00:33Z","started_at":"2026-09-23T01:18:16Z","closed_at":"2026-09-23T02:00:33Z","close_reason":"PR #145. draw.rs::fill_line takes a Keeps parameter, because the three rows sharing a line disagree about which side yields: the title bar keeps the RIGHT (the ?:help/:q escape hatch outranks an unbounded document name), while minibuffer_line and draw_status keep the LEFT (the user's own typing outranks a view badge). NOT PREDICTED BY THE TICKET: the byte-padding fault fires on a PURE-ASCII document name, because the separator dot is in draw_title's own format string -- two bytes, one cell -- so every document's right segment sat one cell in from the edge. The ticket framed it as a non-ASCII-name problem. Widths now go through ui::text::truncate, never str::len. (--force: agent-tui handed back.)","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-56qu","title":"site/ is a tracked build artifact that silently lags context/, and nothing compares them","description":"Found by the improvise-xgra agent (PR #133) by accident: it ran\nscripts/build-doc-site.mjs with no argument, which defaults to the TRACKED\nsite/docs, and the regeneration produced 26 MODIFIED FILES plus THREE PAGES THAT\nDO NOT EXIST IN THE TREE AT ALL:\n\n adr/004-faces-and-themes\n code/cli\n test-evidence/cli\n\nAll three are documents added TODAY (ADR 004 in PR #122/#132, the CLI docs in PR\n#131). So the committed site/ lags context/ by at least a day, and NOTHING IN CI\nCOMPARES THEM. The build job assembles into its OWN output directory, so the\ntracked copy is never checked against the source it was generated from.\n\nTWO SEPARATE PROBLEMS, and they want different answers:\n\n1. site/ IS A TRACKED BUILD ARTIFACT THAT SILENTLY LAGS. Either CI regenerates\n and compares (failing when they differ), or site/ stops being tracked, or it is\n explicitly declared a periodic snapshot with the staleness accepted. The third\n is a real option -- improvise-oujb's agent noted site/docs has been touched by\n two commits ever -- but it should be a DECISION rather than the current\n accident, because a reader cannot tell a snapshot from a stale artifact.\n\n2. RUNNING THE DOC-SITE BUILD DIRTIES THE TREE, which is a trap for any agent\n that runs it to verify the SECTIONS gate. The xgra agent reverted its 26 files;\n the next one may not notice. Note the gate itself is worth keeping: running\n build-doc-site.mjs exits 0 and prints no \"neither published nor a named\n omission\", which is a cheap POSITIVE check that a touched context/ file is\n published -- not only a trap to avoid.\n\nRELATED, ALREADY KNOWN: improvise-oujb's agent reported that site/ is partially\ntracked (27 files under site/docs/) and is NOT in .gitignore, so following the\nREADME's own instructions dirties the tree. Same root, reported twice by two\nagents on different days, which is itself the argument for deciding it.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T21:04:54Z","created_by":"Edward Langley","updated_at":"2026-09-22T21:04:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1jay","title":"context/code/protocol.md:470 says no client composes CellId::Create; gcvb closed that and bd9o closed the route","description":"Reported by the improvise-bd9o agent (PR #128) and deliberately not fixed there --\nnot its file, and stale independently of that branch.\n\ncontext/code/protocol.md:470 says \"No client composes it yet\" about\nCellId::Create, and cites improvise-gcvb as an open gap. That predates gcvb\nlanding: a client DOES compose it, in commit.\n\nAnd improvise-bd9o has since changed the picture again from the other side: it\ntook away the DOOR. edit_or_drill now declines on a records grid before reaching\nthe CellId arms at all, so CellId::Create is no longer reachable from a key --\nwhich is why improvise-x0xd exists (create_column is now explicitly dead rather\nthan accidentally so).\n\nSO ONE SENTENCE IS OWED, AND IT HAS TWO CLAUSES: gcvb closed the compose gap in\ncommit, and bd9o has since closed the key route. Writing only the first would\nleave a reader believing a key reaches it.\n\nThis is the same class as the two prose passages Codex caught on PR #128 -- a\nfact stated in more than one place, where the place that moved was not the place\nsomeone was editing. Cheap to fix, and worth doing while the two commits that\nfalsified it are still fresh enough to cite.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T08:16:51Z","created_by":"Edward Langley","updated_at":"2026-09-22T08:16:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-x0xd","title":"The browser records-mode creating columns are explicitly dead and could be live","description":"improvise-gcvb built `create_column` — the one records-mode commit a thin client\ncan compose, since a `CellId::Create` commits the typed NAME as one existing\nModelCommand (AddItem into _Measure, or AddCategory), reads no record_col and\nwrites no cell. It has never been reachable.\n\nThe only door into RecordsEditing is `edit-or-drill`, whose `CellId::Create` arm\nreturned None; `o` is declined at its `add-record-row` step. So `commit`'s\n`CellId::Create` branch was dead on arrival — an accident of two unrelated arms\nrather than a decision.\n\nimprovise-bd9o made that deadness EXPLICIT rather than closing it: the records\nguard now sits at the entrance of `edit_or_drill`, so every records column\ndeclines, and the `CellId::Create` arm declines too (\"creating a column is not\navailable in the browser yet\") instead of returning None. That was the ticket's\nown stated acceptance criterion and the right scope for a P1 about silence.\n\nWHAT THIS TICKET IS: open the editor on `CellId::Create` while still refusing\nthe coordinate and measure columns. That means the entrance guard becomes a\nper-column decision again, which is exactly the shape bd9o argued against for\nthe silence case — so it needs its own evidence rather than a revert.\n\nTwo things to answer first, both already argued in gcvb and clients.md:\n\n - The +cat half MUST NOT ADVANCE. An inserted column renumbers every index to\n its right and the advance was computed from the pre-commit layout (gotcha\n 23), so a client that advanced anyway lands one column off, silently.\n `create_column` already does this; the test does not exist end to end.\n - AddCategory can be refused by the 12-category limit and sets abort_effects,\n so the mode change goes FIRST, where an abort cannot reach it (gotcha 21).\n Also already in `create_column`.\n\nSo the code is written and argued; what is missing is a door and the evidence\nthat the door is safe. Worth doing because it is the ONE thing a browser could\ndo in records mode and currently cannot.\n\nACCEPTANCE CRITERIA\n\n - Pressing `i`/`a` on a records `+` or `+cat` column opens the editor in the\n browser, and Enter commits through the existing `create_column`.\n - Every OTHER records column still declines out loud (the bd9o property must\n not regress) — assert it by walking all columns, as\n `the_tile_bar_route_into_records_mode_does_not_strand_the_user` does.\n - An end-to-end test through a real Host proves +cat does not advance, by\n injecting the advancing rival and watching it land one column off.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T07:57:32Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:57:39Z","dependency_count":0,"dependent_count":1,"comment_count":0} @@ -330,10 +332,10 @@ {"_type":"issue","id":"improvise-wiew","title":"[ and ] should probably be in the global keymap: decide whether 'global' means every mode or every grid mode","description":"USER 2026-09-22: \"Also [/] probably should be in the global keymap.\"\n\nNote the \"probably\" -- judgement is invited, and there is a real question below.\n\nWHERE THEY ARE NOW, verified: keymap.rs:615-616 binds ']' -\u003e page-next and\n'[' -\u003e page-prev on NORMAL. global_keymap() (:392) currently holds '?', ':' and\nthe panel toggles.\n\nMECHANICALLY THIS IS SAFE AND CHEAP. Both are unmodified Char keys bound as\nBinding::Cmd, so they satisfy the_global_binds_only_unmodified_char_keys, the\nguard PR #112 added. And TEXT ENTRY IS NOT AFFECTED: the lookup order is\nexact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any -\u003e parent, so a text-entry mode's\nAnyChar shadows the global BEFORE the fallthrough is reached. Typing '[' into a\ncategory name still types '['. That shadowing is already covered by #112's tests.\n\nNO EXACT CONFLICT EXISTS. The Help overlay pages with Right / l / n, not with\nbrackets, so nothing is shadowed by accident.\n\nTHE ACTUAL QUESTION, WHICH THE TICKET MUST ANSWER RATHER THAN ASSUME: is the\nglobal \"every mode\", or \"every mode whose subject is the grid\"? Falling through\nmeans [ and ] would change the DOCUMENT'S PAGE AXIS while the user is reading the\nHELP OVERLAY -- a real mutation, invisible because the overlay is covering the\ngrid it changed. The same question applies to TileSelect and the three panels,\nthough those are at least about the document.\n\nThree defensible answers:\n a. Global, unshadowed -- paging is always available, and the help overlay\n changing the page underneath is harmless because it is visible on exit.\n b. Global, shadowed to a no-op in Help -- costs a wart, but says plainly that\n an overlay is not a place to mutate the document.\n c. Not global; add it to the specific maps that want it (RecordsNormal already\n inherits Normal, so the real gain is the panels and TileSelect).\n\nPick one, say why in the PR body, and expect the user to have an opinion --\nthis is the kind of thing that is cheap now and annoying to reverse once muscle\nmemory forms.\n\nWHATEVER IS CHOSEN, the browser must follow: improvise-protocol/src/keymap.rs is\nthe shared table both front ends look up, so this is one change, not two -- but\ncheck resolve.rs still composes page-next/page-prev from whatever modes newly\nreach them, and that lockstep still agrees. improvise-p1ym closed the projection\ngap that used to make [ and ] inert in a browser, so they do work there now.","notes":"IMPLEMENTED in PR #123 (merge 7470b61), to the user's ruling of an INTERMEDIATE\nkeymap rather than any of the three options this ticket listed.\n\ndocument_keymap sits between global and Normal / the three panels / TileSelect.\nRecordsNormal reaches it THROUGH Normal. Help, ImportWizard and the text-entry\nmodes take global directly.\n\nTHE ARGUMENT IS NEW RATHER THAN THE GLOBAL'S AGAIN, and it is better than the\none I wrote on this ticket. I framed the question as \"is the document the subject\nright now\", which is a judgement. The agent found a STRUCTURAL line: every key\nthe global binds is a UI action, whereas page-next resolves to SetPageSelection,\nwhich is EffectKind::Model(LayoutGeometry) -- THE FIRST SHARED KEYS THAT MUTATE.\nUnder the help overlay that is an invisible change to the grid it is covering.\nSo the split is not taste; it is \"may a fallthrough key mutate the document\".\n\nThe three asks were met: the name is the guard (document_keymap, not\ncommon/shared, because nothing in the code stops a mode parenting it);\nRecordsNormal inherits ONCE, asserted on CHAIN IDENTITY (exactly four deep)\nrather than behaviour; and the layer gets its OWN Char-only guard, owed for the\nidentical fallthrough reason as the global's.\n\nTHE TEST-SHAPE LESSON: parenting RecordsNormal to the layer directly is a fault\nthe BEHAVIOURAL test does not catch -- it passes, because the key still works.\nOnly records_normal_inherits_the_document_layer_once_through_normal, which\nasserts the chain, sees it. A duplicated inheritance edge is invisible to any\ntest that only presses the key.\n\nprefix_keymaps_are_flat is about the sub-keymaps a PREFIX opens and is unaffected\nby depth. resolve.rs dispatches on command NAME, so the browser followed with no\nsecond table and no resolve.rs change was needed. lockstep.rs unchanged.\nIt did not grow: one binding moved, one layer added.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:47Z","closed_at":"2026-09-22T06:41:47Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-771e","title":"The browser tile bar says 'Pag': short_label is a TUI column-width device, not a browser one","description":"Spotted by the orchestrator in PR #120's screenshots: the browser tile bar renders\n\"_Measure Col\", \"Product Row\", \"Quarter Pag\".\n\n\"Pag\" is not a CSS clip and not a bug in #120. Axis::short_label() (improvise-core\n/src/view/axis.rs) returns THREE CHARACTERS on purpose -- repo-map gotcha 26 says\nit is \"three columns for the tile bar\", so the TUI's tile bar aligns in a\nfixed-width terminal. Note `None` is \".\" there and is deliberately NOT a\ntruncation.\n\nIn a browser there is no column-alignment constraint, so the abbreviation buys\nnothing and reads to a user as a rendering defect. Axis::plain_label() already\nreturns \"Row\"/\"Col\"/\"Page\"/\"None\".\n\nTHE FIX IS NOT TO CHANGE short_label(). It is shared with the TUI, where the\nthree-column width is load-bearing. Pick the other EXISTING method on the browser\nside. That is not a second derivation -- both are already on Axis and the decision\nstays in the shared layer; only the choice of which vocabulary a front end uses is\nlocal, which is exactly the shape design-principles section 5 blesses for\nAxisColor -\u003e ratatui Color.\n\nCHECK BEFORE CHANGING: tests/grid_frame_agreement.rs asserts the two front ends\ndraw the same grid FRAME. Confirm it does not also pin the tile bar's label\nvocabulary, or this will fail for a reason that is not a bug.","notes":"FIXED in PR #123 (merge 7470b61). The browser tile bar asks plain_label();\nshort_label() now has exactly one caller, src/ui/tile_bar.rs.\n\nTHE FIND THAT MATTERS MORE THAN THE FIX, and it corrects this ticket's own\nguidance. I told the agent to check tests/grid_frame_agreement.rs before\nchanging the label, on the theory that it might pin the tile bar's vocabulary.\nIT DOES NOT -- it does not mention the tile bar at all.\n\nBut harness/tests/drill.spec.js DID: it asserted the tile text exactly, twice,\nwith \"Quarter Pag\" AS THE EXPECTED VALUE, plus \"Quarter ·\" after a cycle. A\nPlaywright spec in a directory this ticket never names, asserting the bug as its\nexpectation. Verified fixed on the merged branch: it now expects \"Quarter Page\".\n\nSO THE RULE IS: A RENDERED STRING IS PINNED WHEREVER ANYTHING READS THE SCREEN.\nThe search is for the STRING, not for the function that produces it -- I pointed\nat the function and named the wrong file. A now-false comment in\ncontract.spec.js naming short_label was fixed in the same PR.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:11Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:46Z","closed_at":"2026-09-22T06:41:46Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ycxi","title":"The release preflight's dist plan arm is unchecked by its check_the_checker","description":"Proposed by the improvise-gx5d agent (PR #119) and DECLINED IN THAT PR on stated\ngrounds, which is why it is a bead rather than a fix.\n\nscripts/release-preflight.sh runs `dist plan --tag=\u003ctag\u003e` as a SECOND OPINION after\nits own textual tag-vs-version check, and matches on the refusal text. That arm can\nbe rewritten so it NEVER MATCHES and scripts/release-preflight-test.sh stays green,\nbecause every input the test feeds the script has ALREADY been refused by the\ntextual check -- so control never reaches the dist arm under test.\n\nWHY IT WAS DECLINED RATHER THAN FIXED, and the reasoning is sound: the arm is\nOPTIONAL BY DESIGN. An absent dist must not read as a pass, and a locally-installed\ndist whose version differs from dist-workspace.toml's cargo-dist-version stops\nbefore planning, which the script deliberately tolerates. Pinning the arm in a test\nwould mean installing a dist matching cargo-dist-version -- the exact mismatch the\nscript exists to tolerate.\n\nSO THE VALUE HERE IS NOT COVERAGE, IT IS THAT THE SILENCE IS RECORDED. It is stated\nin context/code/tooling.md so nobody reads the check_the_checker's green as evidence\nabout that arm. This bead exists so the statement does not become the permanent\nanswer by default.\n\nWHAT WOULD ACTUALLY CLOSE IT, if anyone takes it up: feed the dist arm an input the\ntextual check ACCEPTS but dist refuses. That requires a tree whose root version\nmatches the tag while dist still objects -- e.g. a dist-workspace.toml naming a\nmember that does not exist. That is a fixture, not an installed dist, and it may be\ncheap. Worth ten minutes before accepting the decline as permanent.\n\nDO NOT close this by deleting the dist arm. It is the thing that actually refused in\nCI, and running it is worth more than any restatement of its rules; the textual\ncheck is the one that catches the failure the script was written for, but it is not\nthe authority.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:40:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:40:52Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-htl6","title":"view-forward is bound in no keymap, in either front end","description":"Found by the improvise-cfly agent while adding the browser's view-back arm, and\nNOT predicted by that ticket. Verified by the orchestrator: grep of\nKeymaps::default_keymaps() finds `\u003c` bound to view-back and NOTHING bound to\nview-forward.\n\nSo the FORWARD half of the view history has never had a key, in either front end:\n\n - TUI: reachable only as `:view-forward` through the command line.\n - BROWSER: not reachable at all -- declined() refuses to enter CommandMode at\n the door (improvise-y11g is the ruling that this must change).\n\nTHIS IS A TUI GAP FIRST, which is the surprising part. The browser was the thing\nunder investigation and the browser is the second victim, not the first.\n\nBoth ends COMPOSE the command correctly since improvise-cfly / PR #117; only the\nbinding is missing. So this is one keymap line plus its consequences, not a\nfeature.\n\nWHAT IT NEEDS:\n - a key. `\u003e` is taken by drill-into-cell, so this needs a choice rather than\n the obvious symmetry -- say what you picked and why.\n - a help.rs hint, or `every_hint_key_resolves_in_its_mode` and\n `only_quit_has_no_hint` will have something to say.\n - it WILL fail resolve::tests::the_forward_step_mirrors_its_cmd_and_no_key_reaches_it,\n which PR #117 added precisely so this gap cannot be closed silently. That\n test asserts the arm exists AND that no keymap binds view-forward, with\n view-back's presence as the control so it is not vacuous over a misspelling.\n Deleting the absence assertion is the correct move when adding the binding --\n coming there and saying so is the point.\n\nWHY IT SURVIVED: pressing `\u003c` twice is the only way to observe the forward stack,\nso nothing a user does by accident reaches it, and until PR #115 the stack was\nnot maintained for any client at all (gotcha 38's correction).","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:21:26Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:21:26Z","labels":["browser","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-htl6","title":"view-forward is bound in no keymap, in either front end","description":"Found by the improvise-cfly agent while adding the browser's view-back arm, and\nNOT predicted by that ticket. Verified by the orchestrator: grep of\nKeymaps::default_keymaps() finds `\u003c` bound to view-back and NOTHING bound to\nview-forward.\n\nSo the FORWARD half of the view history has never had a key, in either front end:\n\n - TUI: reachable only as `:view-forward` through the command line.\n - BROWSER: not reachable at all -- declined() refuses to enter CommandMode at\n the door (improvise-y11g is the ruling that this must change).\n\nTHIS IS A TUI GAP FIRST, which is the surprising part. The browser was the thing\nunder investigation and the browser is the second victim, not the first.\n\nBoth ends COMPOSE the command correctly since improvise-cfly / PR #117; only the\nbinding is missing. So this is one keymap line plus its consequences, not a\nfeature.\n\nWHAT IT NEEDS:\n - a key. `\u003e` is taken by drill-into-cell, so this needs a choice rather than\n the obvious symmetry -- say what you picked and why.\n - a help.rs hint, or `every_hint_key_resolves_in_its_mode` and\n `only_quit_has_no_hint` will have something to say.\n - it WILL fail resolve::tests::the_forward_step_mirrors_its_cmd_and_no_key_reaches_it,\n which PR #117 added precisely so this gap cannot be closed silently. That\n test asserts the arm exists AND that no keymap binds view-forward, with\n view-back's presence as the control so it is not vacuous over a misspelling.\n Deleting the absence assertion is the correct move when adding the binding --\n coming there and saying so is the point.\n\nWHY IT SURVIVED: pressing `\u003c` twice is the only way to observe the forward stack,\nso nothing a user does by accident reaches it, and until PR #115 the stack was\nnot maintained for any client at all (gotcha 38's correction).","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-tui2","owner":"el-github@elangley.org","created_at":"2026-09-22T05:21:26Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:17:59Z","started_at":"2026-09-23T02:17:59Z","lease_expires_at":"2026-09-23T02:22:59Z","heartbeat_at":"2026-09-23T02:17:59Z","labels":["browser","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-z24n","title":"Ctrl-Q should not be claimed by the browser at all: force-quit resolves Ignored, not Upstream","description":"USER RULING 2026-09-22: \"What should Ctrl-Q do in a browser tab? this shortcut\nonly really makes sense in an application context.\"\n\nSo: the browser must NOT claim it. `force-quit` should stop resolving to\nKeyOutcome::Upstream and resolve to Ignored -- no frame composed, nothing sent.\n\nWHAT IS ACTUALLY WRONG TODAY is narrower and stranger than \"a black hole\", and the\ndifference is the reason this is worth a ticket rather than a one-liner. web/app.js\n(~:599) reads:\n\n if (!e.ctrlKey \u0026\u0026 !e.altKey) e.preventDefault();\n\nwith the comment \"Ctrl/Alt combinations are left alone: they are the browser's.\"\nSo the page ALREADY declines to consume Ctrl chords. But `client.on_key_event` is\ncalled FIRST and force-quit resolves to Upstream, so the page composes and sends a\nClientFrame::Key that Host::handle answers with an empty vec (gotcha 47) -- AND the\nbrowser also acts on Ctrl-Q, which on Linux Firefox and Chrome QUITS THE BROWSER.\n\nSo the present behaviour is: send a useless frame, and let the browser quit. The\nfix aligns the resolver with the policy app.js already states rather than inventing\none -- which is why the answer is Ignored and NOT \"decline with a status message\":\na message would imply improvise had claimed the key, and it deliberately has not.\n\nCHECK BEFORE ASSUMING THE ABOVE: I read the handler and did not run it. Confirm by\ninjection that Ctrl-Q currently produces a frame, rather than trusting this note --\nthat is the whole discipline this repo runs on.\n\nDO IT IN THE SAME COMMIT: remove `force-quit` from\nthe_command_names_that_still_need_the_host_are_these, because gotcha 47 says closing\na gap has to come to that list and say so. This one closes by DECLINING rather than\nby composing, which the list should be able to express -- if it cannot, that is the\nfinding.\n\nRELATED: improvise-uln5 (help page 5 advertises keys a browser cannot resolve). If\nCtrl-Q is genuinely not a browser feature, the browser's help should not list it,\nand that is uln5's business rather than this ticket's -- but note it there.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:13:58Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:58Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-dsn2","title":"Browser back/forward buttons and real URLs: deferred, not foreclosed","description":"USER RULING 2026-09-22: \"I want to ignore browser history for now, without\nforeclosing the question: GitHub Pages is going to make actual client-side\nnavigation annoying.\" Clarified on the same day: \"Only actual browser history,\n\u003c/\u003e is a core feature but I don't much care about how it interacts with the\nback/forward buttons at the moment.\"\n\nSO THE SPLIT IS EXPLICIT, and the two halves must not be confused again:\n\n IN SCOPE AND SHIPPING -- the IN-APP view history. `\u003c` and `\u003e` over\n ViewState::view_back_stack, a CORE FEATURE per the user. PR #115 landed the\n mechanism (Command::ViewBack/ViewForward/ForgetView, per-client stacks,\n ForgetView pruning a deleted view from every client's history); improvise-cfly\n / PR #117 gives the browser a key that reaches it. Nothing here is deferred.\n\n DEFERRED, NOT FORECLOSED -- binding any of that to the BROWSER's own history:\n history.pushState, real URLs per document or per view, and the back/forward\n buttons doing anything at all.\n\nWHY DEFERRED, in the user's own reasoning: GitHub Pages is the deployment target\nand has NO server-side rewrite. A client-side router therefore needs either hash\nrouting or the 404.html-copies-index.html trick, and a deep link to a document or\na view is awkward under both. The cost lands on the deployment, not on the code,\nwhich is exactly the kind of cost that is cheap to defer and expensive to\ndiscover late.\n\nWHAT IT WOULD TOUCH IF TAKEN UP, recorded so the shape is not re-derived:\nweb/app.js (the only file that knows about the page), web/assemble.sh and the\npages-deploy job in .github/workflows/ci.yml. It does NOT touch the protocol:\nCommand::SwitchView, ViewBack and ViewForward already exist, so this is a\nquestion about the SHELL, not the vocabulary -- which is the main reason it is\nsafe to leave open.\n\nONE THING TO CHECK BEFORE BUILDING IT, because it decides the design: a URL that\nnames a view is a URL that names ONE CLIENT'S UI state, and gotcha 29 says the\nmodel is shared and the UI is not. A shared link that moves the recipient's view\nis fine; one that moves the SENDER's is the bug. Decide which a URL means before\nwriting a router.\n\nDO NOT close this as done when \u003c/\u003e ships. They are different features and the\ntracker should not let the second stand in for the first.","status":"blocked","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:12:00Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:12:04Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-swzk","title":"A failed :view corrupts the TUI's own view history","description":"Found by the improvise-h3y7 completeness audit.\n\neffect::SwitchView (ui/effect.rs:336) pushes the current view onto view_back_stack\nBEFORE calling App::switch_to_view, which is guarded (ui/app.rs:565) and writes\nactive_view only if the view exists.\n\nSo `:view NoSuchView` leaves the user where they were WITH THEIR OWN CURRENT VIEW\nPUSHED ONTO THEIR OWN BACK STACK. `\u003c` then \"goes back\" to where they already are, and\none real history frame is buried under it.\n\nTHE WIRE PATH IS ALREADY CORRECT and this is not a regression of it: reduce_view's\n`switched` has no guard (the client holds no Workbook, so it cannot check), but\nSessions::apply_switch detects the refusal and relays the exact inverse\n([ViewBack, ForgetView], improvise-6c6q). Only the HOST'S OWN KEY PATH is unguarded.\nTUI-only, small, real.\n\nFIX: move the push after the switch, or make it conditional on the switch having\nhappened. Prefer whichever makes the ordering unrepresentable-otherwise rather than\nconventional.\n\nDEMONSTRATE WITH A FAILING TEST FIRST (AGENTS.md): switch to a real view, then to a\nnonexistent one, then `\u003c`, and assert you land on the real one. Note the test must use\na real second view -- a bystander sitting on the view it would land on anyway cannot\nsee this, which is the vacuity review demonstrated twice on PR #48.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:55Z","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-swzk","title":"A failed :view corrupts the TUI's own view history","description":"Found by the improvise-h3y7 completeness audit.\n\neffect::SwitchView (ui/effect.rs:336) pushes the current view onto view_back_stack\nBEFORE calling App::switch_to_view, which is guarded (ui/app.rs:565) and writes\nactive_view only if the view exists.\n\nSo `:view NoSuchView` leaves the user where they were WITH THEIR OWN CURRENT VIEW\nPUSHED ONTO THEIR OWN BACK STACK. `\u003c` then \"goes back\" to where they already are, and\none real history frame is buried under it.\n\nTHE WIRE PATH IS ALREADY CORRECT and this is not a regression of it: reduce_view's\n`switched` has no guard (the client holds no Workbook, so it cannot check), but\nSessions::apply_switch detects the refusal and relays the exact inverse\n([ViewBack, ForgetView], improvise-6c6q). Only the HOST'S OWN KEY PATH is unguarded.\nTUI-only, small, real.\n\nFIX: move the push after the switch, or make it conditional on the switch having\nhappened. Prefer whichever makes the ordering unrepresentable-otherwise rather than\nconventional.\n\nDEMONSTRATE WITH A FAILING TEST FIRST (AGENTS.md): switch to a real view, then to a\nnonexistent one, then `\u003c`, and assert you land on the real one. Note the test must use\na real second view -- a bystander sitting on the view it would land on anyway cannot\nsee this, which is the vacuity review demonstrated twice on PR #48.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-tui2","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:55Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:03:03Z","started_at":"2026-09-23T02:03:03Z","lease_expires_at":"2026-09-23T02:08:03Z","heartbeat_at":"2026-09-23T02:03:03Z","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4n4i","title":"effect::HelpPageSet is unclamped where its wire mirror clamps","description":"Found by the improvise-h3y7 completeness audit (direction B: vocabulary -\u003e effects).\n\neffect::HelpPageSet(n) (ui/effect.rs:1174) writes `help_page = self.0` with NO bound.\nreduce_view's arm clamps: `HelpPage::Set(n) =\u003e (*n).min(HELP_PAGE_COUNT - 1)`.\n\nreduce.rs's own comment above that arm says \"these mirror HelpPageNext/HelpPagePrev,\nwhich saturate. The two used to disagree\" -- Next/Prev were fixed and Set WAS LEFT\nBEHIND, so the comment is now false of the arm it sits above. A stale correction: the\nsame failure mode as gotcha 38's prose, and the reason this is filed rather than\nquietly patched.\n\nLATENT, NOT LIVE, and the ticket should say so rather than overstate it:\neffect::help_page_set has exactly one call site (cmd/effect_cmds.rs:404) passing the\nliteral 0, and no client composes HelpPage::Set (resolve.rs has only Next/Prev arms).\nSo it is an asymmetry waiting for its first real caller.\n\nFIX: clamp the effect, and correct the comment in the same commit -- the comment is\nthe thing that made this survive.\n\nTHE REGRESSION GUARD: a test that the two agree for n \u003e HELP_PAGE_COUNT. Nothing tests\nthis in EITHER direction today, which is why a one-sided fix would look complete.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:54Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:54Z","labels":["protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t3oh","title":"The cache-parity guard's whole-cache escape hatch proves consumption, not coverage","description":"Stated as a residual by the agent that WROTE the guard, in PR #113's evidence,\nrather than found later by someone else.\n\n`web/tests/render_surface.test.js` gained a second check one level up: every\n`pub` field of every widget cache reaches a front end, or is named as debt. It\nhas an escape hatch for the case where a whole cache is handed to a shared\nderivation, e.g. `flatten_cat_tree(\u0026state.categories, ..)` -- in which case\nevery field of that cache counts as reached.\n\nTHE HOLE: a second field added to `CategoryTreeCache` and ignored by\n`flatten_cat_tree` passes. The hand-off proves the cache is CONSUMED, never that\nevery field of it is.\n\nClosing it needs the CONSUMER checked rather than the call site -- i.e. asking\nwhat `flatten_cat_tree` actually reads, not that it was handed the struct.\n\nWHY THIS BEAD EXISTS RATHER THAN A TODO: the same guard's two OTHER escape\nhatches were both vacuous on first writing and both were found only by\ninjection -- `\u0026state.grid` is a substring of `\u0026state.grid.row_frame`, so every\nGridCache field read as covered (vacuous for precisely the cache p7lh was\nabout), and rustfmt splits `state.grid.totals` over three lines so a substring\nsearch calls a read field unread. Two of three hatches were wrong. The third\nbeing merely incomplete rather than wrong is not a reason to trust it.\n\nSee doctrine(an_escape_hatch_is_where_a_new_check_goes_vacuous).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:28:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:28:50Z","labels":["browser","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ozxr","title":"The browser's page bar is display-only; clicking an entry cannot cycle it","description":"PR #113 (improvise-p7lh) made the page bar VISIBLE. It is display-only.\n\nIn the TUI, `[` and `]` cycle the page selection and the page bar is where a\npaged category is both shown and navigated. In the browser the bar now paints,\nbut clicking an entry does nothing -- cycling needs a `click_page` on the\nwasm-bindgen surface (`improvise-wasm-client/src/lib.rs`), which was outside\nthat agent's ground.\n\nWORTH KNOWING BEFORE SCOPING IT: `[` and `]` ALREADY WORKED in the browser\nbefore #113. The feature was never missing -- the FEEDBACK was. That is a worse\nfailure than a dead key, because the user sees the document change and cannot\nattribute it. So this bead is the mouse affordance for something the keyboard\nalready does, not new capability.\n\nPattern to follow: the other five surfaces got delegated handlers in\nimprovise-b8ot, with operands carried on the node as data attributes and read\nback by a pure `cellAction`/`indexAction` in `web/shell.js`. A page entry should\ndo the same -- `data-index` on the entry, a pure `pageAction`, one handler on\nthe `#pages` root. Do NOT derive the index from DOM position.\n\nNote gotcha 27: Page is the NAVIGABLE axis and Filter is not, and `PageAxis::cycle`\nis where that question is already answered -- ask it rather than respelling it.\n\nRelated: improvise-z336 (a page selection is never cleared, so cycling a\ncategory's axis hides a filter and cycling back restores it) is the other half\nof the page-axis story and is core-side, not browser-side.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:28:49Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:28:49Z","labels":["browser","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -355,7 +357,7 @@ {"_type":"issue","id":"improvise-yae4","title":"Column width overflows before clamping a 65535-column text value","description":"Report-only hunt at 70025e2. No implementation changes.\n\nLocation: crates/improvise-command/src/ui/viewport.rs:66,85\n\nA stored 65535-character ASCII text value causes compute_col_widths/grid_body to panic at w+1 in debug builds. Width is narrowed to u16 before adding the cell gap and clamping to MAX_COL_WIDTH. In release the sum wraps and chooses a minimum-width column instead of the configured maximum. Longer widths can truncate during the earlier as u16 cast even when addition does not overflow. Clamp while still usize or use bounded/saturating conversion before arithmetic. Test widths around 65535 and 65536 as well as ordinary text. Duplicate checks: width, overflow, full existing descriptions.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn wide_cell_clamps_without_overflow() {\n use improvise::{ui::app::App,model::cell::{CellKey,CellValue}};\n let mut w=revenue_book();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Text(\"x\".repeat(65535)));\n let app=App::new(w,None);\n let widths=improvise::ui::viewport::compute_col_widths(\u0026app.model_state.workbook.model,\u0026app.layout,false,0);\n assert!(widths.iter().all(|w| *w\u003c=32));\n}\n```\nRun cargo test wide_cell_clamps_without_overflow -- --nocapture. Current source failed this test.\n\n## Acceptance Criteria\nThe focused regression passes without weakening the expected behavior; ordinary cases still agree, with both debug and release arithmetic considered.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:37:41Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:44Z","closed_at":"2026-09-21T21:58:44Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nCapped while still usize in the one place every measurement enters (widen). Behaviour identical for every non-overflowing input.\n\nTHE SECOND INSTANCE, which the bead did not name and which was the more dangerous: improvise_protocol::header_level_widths had byte-for-byte the same defect, on the axis gotcha 44 calls the one home for the width rule -- and it is in the PROTOCOL, so it is shared by both front ends. Fixing only the site the bead named would have left the row header and the column widths with two different answers to the same arithmetic.\n\nThe release symptom is the one to lead with, not the panic: the WIDEST value in the document silently rendering at the NARROWEST column width, with no failing test anywhere. Faults injected: debug -\u003e 'attempt to add with overflow'; release -\u003e left [5], right [32], the wrap reported as the minimum width.\n\n'as u16 followed by + 1 and .clamp' is a greppable shape and the grep finds both.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-l3hm","title":"[bug] i on CellId::Create is a silent black hole; Aggregate/Derived decline out loud","description":"## Summary\n`i` / `a` on a `CellId::Create` (records-mode `+` column) is a silent black hole (`KeyOutcome` upstream/none), while Aggregate and Derived decline out loud with `SetStatus`. Same doctrine (`an_upstream_key_is_a_black_hole`) that drove loud declines for the other non-editable ids.\n\n## Repro (reasoning / unit-shaped)\n1. Records mode with a creating column under the cursor (`CellId::Create`).\n2. Press `i`.\n3. Observe: no status, no mode change — key appears dead.\n\n## Expected\nA local `SetStatus` naming that creating/naming a column is not available in the browser yet (same family as Aggregate’s drill message and Derived’s formula message), per clients.md loud-decline doctrine.\n\n## Actual\n`edit_or_drill` match arm `CellId::Create(_) =\u003e None` in `crates/improvise-protocol/src/resolve.rs`, which becomes Upstream/Ignored rather than Local SetStatus. Comment cites improvise-poia (projection identity; closed) — the loud-decline gap remains.\n\n## Evidence\n- Code hunt by external Grok Bot agent for Ed Langley, 2026-09-21 PT.\n- Contrast: `i_on_an_aggregate_declines_out_loud` / `i_on_a_derived_cell_names_the_formula` in the same file.\n- Closed improvise-poia / improvise-gcvb covered identity/commit, not this decline wording.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Not an upstream black-hole key that is documented as intentionally silent for host features like `[`/`]`/drill wire — this is the client’s own edit-or-drill door answering None.\n","status":"open","priority":3,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-21T20:32:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pj8a","title":"The browser paints the host's hints and help over a smaller capability set, and ten keys are silent","description":"Found by the UX audit. ASSERTED (read: keymap + declined() + Upstream list + help pages).\n\nThe browser paints the HOST'S hint line and the HOST'S help pages over a smaller capability set. Three tiers.\n\n## (a) Loud — honest, but reads as broken\nSix commands are declined() with \"... is not available in the browser yet\": drill-into-cell, toggle-prune-empty, toggle-records-mode, add-record-row, delete-formula-at-cursor, view-back.\n\nFOUR OF THEM ARE ADVERTISED BY THE HINT LINE THE BROWSER ITSELF PAINTS: R:records, P:prune, \u003e:drill, and Del/d:delete in the FORMULAS line. The footer offers a key and the key answers no. Note you can ADD a formula in the browser but not DELETE one.\n\n## (b) Silent — the worse tier\nThe Upstream list resolves to ClientFrame::Key, which Host::handle answers with Vec::new() (ui/host.rs:82). Nothing happens, nothing is said: / n N, Enter (enter-advance), Ctrl-S, Ctrl-Q, z, H, and the g/y/Z prefixes.\n\nThe browser paints the SHARED help overlay, so HELP PAGE 2'S ENTIRE \"SEARCH\" SECTION, and page 3's \"Enter — Edit current cell (same as i)\", ARE TAUGHT TO A BROWSER USER AND DO NOTHING. Enter is the sharpest: on the same help page, `i` works and its documented synonym is inert.\n\nThere is also no which-key popup in the browser, since prefixes go Upstream — so g, y, Z are dead AND unexplained, where the TUI pops a menu.\n\n## (c) Dropped entirely\nCovered by the sibling ticket on page_coords and totals.\n\n## Acceptance Criteria\n- Extend declined() to cover the Upstream names with a status, exactly as (a) does. That turns \"broken\" into \"not here yet\" for ten keys and costs one match arm each. THIS IS THE HIGH-VALUE HALF.\n- Decide what the browser's help overlay should show: either mark the unavailable sections, or derive the pages per surface. The pages are already derived from the keymap (help.rs), so a capability filter is the natural shape.\n- The hint line should not advertise a key the surface declines.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rr8n","title":"The tile bar's permanent hint is false, and the status bar contradicts the wizard on four of eight steps","description":"Found by the UX audit. Two findings about on-screen text that is wrong or contradictory. Both ASSERTED (read against the keymap), both cheap to confirm in a terminal.\n\n## 1 — the tile bar's permanent hint is FALSE\nsrc/ui/tile_bar.rs:140: let hint = \" Ctrl+^v\u003c-\u003e to move tiles\";\n\nThose four keys are bound to enter-tile-select, not to moving anything (keymap.rs:632):\n normal.bind(KeyCode::Left, ctrl, \"enter-tile-select\"); // and Right/Up/Down\n\nPressing Ctrl-Right ENTERS TILES MODE; nothing moves. This is the only persistent on-screen documentation of those keys and it describes a different command. It also spells `Ctrl+` where keymap::key_label — and therefore every derived hint and help row — spells `Ctrl-`.\n\nFix: \" Ctrl-^v\u003c-\u003e or T to pick a tile\".\n\n## 2 — four hint dialects, two on screen at once, disagreeing\ndraw.rs::draw() stacks the tile bar (row 3) above the status bar (row 4), so in tile-select mode the user sees on adjacent lines:\n\n tile bar: [Enter] cycle axis [r/c/p] set axis [\u003c-\u003e] select [Esc] cancel\n status bar: hl\u003c-\u003e:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back\n\nDifferent notation, different content — the tile bar omits Space, h/l and Tab.\n\nThe import wizard has the same overlap, and there the status bar is WRONG ON FOUR OF EIGHT STEPS:\n\n NameModel overlay: Enter build, \u003c- back, Esc cancel | status: Space:toggle c:cycle | TRUTH: Space and c TYPE INTO THE NAME\n DefineFormulas overlay: n new, d delete, Enter next | status: Space:toggle c:cycle | TRUTH: n/d; Space and c do nothing\n Done overlay: Enter accept, \u003c-/Esc back | status: Enter:next Esc:cancel| TRUTH: Esc goes BACK, does not cancel\n Preview overlay: Press Enter to continue | status: Space:toggle c:cycle | TRUTH: Enter/Space advance\n\nThe `Done` row is an outright contradiction: the status bar tells the user Esc cancels when it goes back.\n\n## Acceptance Criteria\n- Fix the tile-bar string and its Ctrl+ / Ctrl- spelling.\n- SUPPRESS the status-bar hint when an overlay that draws its own is up — make hint_tail(ImportWizard) empty and skip draw_status's hint fallback in TileSelect. The tile bar and wizard already draw correct, local, per-state hints, so the cheap repair is to stop drawing the second table rather than to maintain it.\n- Longer term the wizard's per-step keys belong in advertised() keyed by step, which makes them derived like everything else.\n\nNote the wizard's back path itself is SOUND: Left steps back from every step (every_step_binds_left_as_back proves it), Done's Esc goes back rather than discarding. The only hole is Left being dead while a formula is typed, survivable because Esc cancels the edit first.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:37:05Z","started_at":"2026-09-23T01:37:05Z","lease_expires_at":"2026-09-23T01:42:05Z","heartbeat_at":"2026-09-23T01:37:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rr8n","title":"The tile bar's permanent hint is false, and the status bar contradicts the wizard on four of eight steps","description":"Found by the UX audit. Two findings about on-screen text that is wrong or contradictory. Both ASSERTED (read against the keymap), both cheap to confirm in a terminal.\n\n## 1 — the tile bar's permanent hint is FALSE\nsrc/ui/tile_bar.rs:140: let hint = \" Ctrl+^v\u003c-\u003e to move tiles\";\n\nThose four keys are bound to enter-tile-select, not to moving anything (keymap.rs:632):\n normal.bind(KeyCode::Left, ctrl, \"enter-tile-select\"); // and Right/Up/Down\n\nPressing Ctrl-Right ENTERS TILES MODE; nothing moves. This is the only persistent on-screen documentation of those keys and it describes a different command. It also spells `Ctrl+` where keymap::key_label — and therefore every derived hint and help row — spells `Ctrl-`.\n\nFix: \" Ctrl-^v\u003c-\u003e or T to pick a tile\".\n\n## 2 — four hint dialects, two on screen at once, disagreeing\ndraw.rs::draw() stacks the tile bar (row 3) above the status bar (row 4), so in tile-select mode the user sees on adjacent lines:\n\n tile bar: [Enter] cycle axis [r/c/p] set axis [\u003c-\u003e] select [Esc] cancel\n status bar: hl\u003c-\u003e:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back\n\nDifferent notation, different content — the tile bar omits Space, h/l and Tab.\n\nThe import wizard has the same overlap, and there the status bar is WRONG ON FOUR OF EIGHT STEPS:\n\n NameModel overlay: Enter build, \u003c- back, Esc cancel | status: Space:toggle c:cycle | TRUTH: Space and c TYPE INTO THE NAME\n DefineFormulas overlay: n new, d delete, Enter next | status: Space:toggle c:cycle | TRUTH: n/d; Space and c do nothing\n Done overlay: Enter accept, \u003c-/Esc back | status: Enter:next Esc:cancel| TRUTH: Esc goes BACK, does not cancel\n Preview overlay: Press Enter to continue | status: Space:toggle c:cycle | TRUTH: Enter/Space advance\n\nThe `Done` row is an outright contradiction: the status bar tells the user Esc cancels when it goes back.\n\n## Acceptance Criteria\n- Fix the tile-bar string and its Ctrl+ / Ctrl- spelling.\n- SUPPRESS the status-bar hint when an overlay that draws its own is up — make hint_tail(ImportWizard) empty and skip draw_status's hint fallback in TileSelect. The tile bar and wizard already draw correct, local, per-state hints, so the cheap repair is to stop drawing the second table rather than to maintain it.\n- Longer term the wizard's per-step keys belong in advertised() keyed by step, which makes them derived like everything else.\n\nNote the wizard's back path itself is SOUND: Left steps back from every step (every_step_binds_left_as_back proves it), Done's Esc goes back rather than discarding. The only hole is Left being dead while a formula is typed, survivable because Esc cancels the edit first.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:00:33Z","started_at":"2026-09-23T01:37:05Z","closed_at":"2026-09-23T02:00:33Z","close_reason":"PR #149, with a DELIBERATE INVERSION of the acceptance criteria for tile select, accepted by the orchestrator. The ticket asked to rewrite the tile-bar literal and suppress the derived status hint; the agent deleted BOTH tile-bar literals instead, on the ticket's own finding 2 -- the derived status line is a strict superset (Space, h/l, Tab appear only there) and cannot go stale, so suppressing it would delete information and keep the table that had just rotted. Verified before accepting: the literal read 'Ctrl+arrows to move tiles' while those keys are bound to enter-tile-select, so it was already false. This follows design-principles section 5 directly (a derivation done twice is the defect; sharing the table beats testing two copies). The import wizard keeps the ticket's ORIGINAL answer, because its derived line comes from the mode keymap while the real keys are per-step inside HandleWizardKey. NOT PREDICTED: the shipped characterization test renders at 80 columns, where the hint does not fit and is never drawn -- the one test pinning that widget could not see either hint. (--force: agent-tui handed back.)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ppru","title":"Nondefault measure aggregation is lost by the .improv roundtrip","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/format.rs; crates/improvise-core/src/model/types.rs:234,473\n\nSet model.measure_agg[Price] to AggFunc::Avg; after parse_md(format_md(wb)) measure_agg is empty and lookup falls back to Sum. This is API/legacy-JSON reachable: no current production setter for measure_agg was found, so priority is lower than ordinary UI persistence defects. Add a grammar-backed representation or explicitly resolve this unsupported state. Duplicate checks: measure_agg, aggregation, average, persist.\n\n## Steps to Reproduce\nA standalone Rust 2024 test package depends on improvise-io by path. Imports:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn aggregation_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_item(\"_Measure\",\"Price\").unwrap();\n w.model.measure_agg.insert(\"Price\".into(),AggFunc::Avg);\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert!(matches!(r.model.measure_agg.get(\"Price\"),Some(AggFunc::Avg)), \"loaded aggregation: {:?}\",r.model.measure_agg);\n}\n```\nRun cargo test aggregation_survives_save. This test failed against the unchanged source.\n\n## Acceptance Criteria\nThe focused test passes after correction, with a control covering the unchanged ordinary case. Preserve the behavior described above without silent data loss.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:45Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:16Z","closed_at":"2026-09-21T21:58:16Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nNew ## Aggregation section, written SORTED because measure_agg is a HashMap (gotcha 18). The ticket's 'no current production setter' hid a second defect: writing it naively is a gotcha-18 bug the moment anything sets more than one entry. The low priority was about reachability, not about the fix being trivial.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-el9h","title":"The server's mirror settles its mode once and never again, so it diverges from the client it mirrors","description":"Found by the command-layer bug hunt. ASSERTED, with the settling test specified.\n\nClientSession::fold (project.rs:711) settles self.view.mode against self.grid.records — the grid LAST PROJECTED to that client. project_diff (project.rs:758) updates self.grid AFTER the fold and never re-settles.\n\nThe client side does not have this shape: ClientState::fold calls settle_mode() on EVERY fold including Command::Project, so when the new view's grid projection arrives the client re-settles and is correct.\n\nSo for [SwitchView(records-shaped view), ChangeMode(Normal)] — precisely the case PR #98 exists to fix — THE MIRROR KEEPS Normal WHILE THE CLIENT ENDS ON RecordsNormal. That commit's own message says the mirror was given the settle \"so the two cannot diverge in a field nobody currently reads\"; the mechanism as written does not achieve it.\n\nSecond, smaller instance of the same shape: apply_switch (project.rs:930), adopt_deleted_views and adopt_cursor_epoch all write the mirror with bare reduce_view, bypassing fold and therefore the settle — three doors past the one documented as \"the same function the client runs\".\n\n## Why P3\nClientSession::view() has no production caller (grepped across improvise-command, improvise-ws-server, improvise-worker-server), and reseed/connect rebuild the view from fresh_view(app) rather than from the folded mirror, so no Snapshot currently carries the wrong value. It is a correctness hole waiting for its first reader — which is exactly the shape improvise-e5pf was before someone read it.\n\n## Acceptance Criteria\n- The settling test, which is specified and cheap: drive a Host with a pivot-shaped default view and a second records-shaped view, Sessions::connect, send ClientFrame::Commands([SwitchView(\"records view\"), ChangeMode(Normal)]), and compare session.view().mode against a ClientState fed the resulting ServerFrames in arrival order. ClientSession::view is pub so this is reachable from a test in project.rs.\n- Then the one-line fix: re-settle at the end of project_diff after self.grid = desired. Or make fold the only writer of the mirror's ViewState and settle there plus after each projection — which also closes the three bypass doors.\n- Do NOT fix before the test exists: the current behaviour is unobservable, so a fix without a test is unfalsifiable.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:25:15Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:44Z","closed_at":"2026-09-21T21:58:44Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nsettle_mode now called from fold AND at the end of project_diff, which also covers the three bare-reduce_view doors. The test was written first and failed exactly as the bead predicted (client RecordsNormal, mirror Normal).\n\nNote the fixture trap found on the way: a fresh View::new carries no axis entry for _Index/_Dim, so is_records_mode can never fire on one. Without the precondition assertion the test would have compared two Normals and PASSED against the unfixed code.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i1wi","title":"scrolled_to drops viewport_effects' max(1) guard, so click_cell underflows on a zero window","description":"Found by the protocol bug hunt. DEMONSTRATED (debug build).\n\nresolve.rs:525 and :531 —\n\n row_offset = nr.saturating_sub(window_rows - 1);\n col_offset = nc.saturating_sub(window_cols - 1);\n\nIts original, improvise-command/src/command/cmd/navigation.rs:48, has\n\n let vr = visible_rows.max(1);\n let vc = visible_cols.max(1);\n\nTHE MIRROR DROPPED THE GUARD. grid_move (resolve.rs:497) checks window_rows == 0 || window_cols == 0 and returns early — but click_cell (:167) does NOT, and it calls scrolled_to directly.\n\n ClientState::default() with grid.extent = {rows:5, cols:5}, window left at (0,0)\n click(\u0026state, Click::Cell{row:1, col:1})\n -\u003e panicked at panic_const_sub_overflow\n resolve::scrolled_to resolve.rs:525\n resolve::click_cell resolve.rs:176\n\nIn a release wasm build (overflow-checks off) it does not panic: 0usize - 1 wraps and both offsets silently collapse to 0.\n\n## Reachability\nLow from the shipped page. shell.js::extentOf clamps to Math.max(1, ..) and app.js sends set_viewport before any grid arrives, so window == (0,0) needs a click before the handshake. The frame-only Client::viewport twin is deleted (improvise-seik). So this is a latent panic in a pub API and a real mirror divergence, not a live user bug.\n\n## Why it matters anyway\ndoctrine(mirror_the_cmd_line_for_line) is the rule these resolvers live under, and the whole point is that a resolver reproduces its Cmd INCLUDING where siblings disagree. A dropped .max(1) is the mirror silently disagreeing, and tests/lockstep.rs did not catch it because no fixture has a zero window.\n\n## Acceptance Criteria\n- One line at resolve.rs:517: let (window_rows, window_cols) = (state.window.0.max(1), state.window.1.max(1));\n- A lockstep case with a zero window, or an explicit note in the evidence that no fixture has one and why.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:49Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/AGENTS.md b/AGENTS.md index 62f9e693..f2ddccac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,7 +149,7 @@ where four unmerged commits — including another PR's whole diff — were pulle into a branch based on `gh/main` and had to be rebuilt by cherry-picking. ChatGPT Codex caught it; nothing in CI could, because the result compiles and passes. The tell is a PR diff naming files the ticket has no reason to touch, -so **read your own PR's file list before asking for review**. Note the window +so **read your own PR's file list before asking for review** — and diff against `gh/main...`, never the local equivalent. **The second vector is the worktree base**: a branch cut from the orchestrator's *local* `main` inherits whatever that checkout has merged but not yet pushed, and nothing in the branch says so. That hit PR #148 with another PR's whole diff — including `.beads/`, `kb.pl` and `context/repo-map.md`, the three files agents are forbidden to touch, so the review read as a rule violation rather than a base problem. Note the window is transient — the two remotes are equal immediately after the orchestrator syncs — which makes it worse, not better: the hazard is absent exactly when you would think to check for it. diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index 2f631af1..3e0e88e3 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -370,6 +370,11 @@ doctrine(an_injected_fault_is_not_reverted_until_the_full_suite_is_rerun,'An age doctrine(a_fixture_that_names_its_own_precondition_must_read_it_back,'A test asserting that a session was shown something must READ THE VIEW OFF THE SESSION, not hard-code the name. A first version derived its grid from grid_in(&app,\"Other\",..), so the session, the switch and apply_checked could ALL BE DELETED and every assertion still passed -- it proved a grid built for a NAME while its name claimed a grid a SESSION WAS SHOWN. The repair reads sessions.get(id).view().active_view, asserts it MOVED, and asserts it DIFFERS from the host: the assert_ne! is what pins the premise, since assert_eq!(seen,\"Other\") alone passes if the host moved too. Found by review inside a PR whose whole subject was a scoping distinction -- the characteristic defect appearing in the change that documents it.'). doctrine(a_mirror_remote_named_main_is_not_the_pr_base,'AGENTS.md makes origin and gitea mirrors that take main directly while GitHub gets branches and PRs, so ORIGIN/MAIN RUNS AHEAD OF GH/MAIN. An agent running the habitual git merge origin/main therefore pulls UNMERGED work into a branch based on gh/main -- on PR #145 that was four commits including another PR whole diff. The two remotes are both spelled main and nothing about the command looks wrong; nothing in CI can see it, because the result compiles and passes. Caught by ChatGPT Codex; repaired by cherry-picking onto gh/main. THE RULE: a branch destined for a GitHub PR merges gh/main, never origin/main, and you read your own PR file list before asking for review -- a diff naming files the ticket has no reason to touch is the tell. THE WINDOW IS TRANSIENT, equal immediately after an orchestrator sync, which makes it worse rather than better: the hazard is absent exactly when you would think to check.'). doctrine(a_fixture_width_is_part_of_what_a_test_can_see,'renders_each_tile_with_its_axis_label renders at 80 COLUMNS, where the tile bar hint does not fit and is NEVER DRAWN -- so the one test pinning that widget output was structurally incapable of seeing either the false hint or its removal (improvise-rr8n). A false key literal survived there for that reason alone. The same shape as a one-category fixture or a helper that fabricates the items the code under test needs: the fixture, not the assertion, is what decided the question. When a test pins RENDERED output, the width, height and terminal size are inputs to be chosen deliberately and stated, and a claim about something that only appears at a width the fixture never uses is not tested at all.'). +doctrine(a_fold_test_must_fold_the_batch_the_producer_really_emits,'A Binding::Sequence FLATTENS into one effect vec (gotcha 21), so a test that applies each command as its OWN apply_effects batch cannot see intra-batch order at all: a reversed-fold rival passes against it, because each single-Move batch reverses to the same cursor. The first replacement for improvise-gjat had exactly that shape and the rival did not fire. So the batching in the test must match the batching the PRODUCER emits, not whatever is convenient to write -- otherwise the test is folding a different fold and its name is wrong. Generalises doctrine(fold_tests_must_test_order_not_the_axiom): that one says do not assert the left-fold axiom, this one says make sure you are folding the right batch.'). +doctrine(a_type_name_is_not_a_field,'An assertion naming a field-carrying effect type and nothing else survives a WRONG CONSTANT FIELD. Measured on improvise-lm5e: one patch made 14 command sites emit the right effect type with a wrong field; under the old assertions that was 525 passed / 6 failed, under the new ones 510 / 21 -- so 14 of 16 such tests passed the rival, and of the 6 that failed only ONE was in the list, the other five living in ui/app.rs and ui/reduce.rs. The command layer never checked a field at all. THE CONVERSE IS ALSO TRUE AND MUST NOT BE OVER-CORRECTED: an assertion on a FIELDLESS effect (Quit, Save, RebuildLayout, TransposeAxes, AbortChain, TogglePruneEmpty) is COMPLETE, not smoke -- there the type name IS the whole claim.'). +doctrine(a_clean_text_merge_can_delete_an_evidence_entry_that_no_gate_checks,'Two agents appending disjoint sections to context/test-evidence/*.md at the same point conflict, and the resolution LOOKS right whichever side is dropped. --check-evidence CANNOT CATCH IT: it asserts every heading names a running test and that no test is named twice, never that every entry SURVIVED. So a resolution must be verified line-by-line against BOTH parents -- every non-marker line of :2 and :3 present in the result -- and not eyeballed. Same family as a-clean-merge-can-delete-your-tests, one level up: there the loss was a test, here it is the recorded reason the test exists, and a lost reason is invisible forever because nothing regenerates it.'). +doctrine(an_additive_gate_read_through_tail_is_not_a_gate,'scripts/check-comment-runs.py --check-evidence is ADDITIVE since improvise-ijql -- every invocation that can exit 0 also answers the comment cap -- and it prints TWO lines, the cap FIRST and the evidence summary second. So reading it through tail -1 keeps the half you added the flag for and silently drops the half that was always there. Measured: an agent ran it that way, its local run HAD already found a three-line comment, and CI caught it instead. The general shape is worse than this one instance: a gate that grows a second responsibility keeps the same command line, so every pipeline written against the old single-line output degrades SILENTLY into checking less. Never pipe a gate through head or tail, and when adding an output line to a checker, consider that callers may be filtering.'). +doctrine(a_compound_command_refused_by_the_shell_guard_runs_none_of_its_parts,'In a worktree-isolated agent the shell guard refuses a whole compound command, so \"cp -f && cat >> \" ran NEITHER part -- leaving an injected rival in the working tree, which was then carried through a git merge autostash into a branch. The full suite caught it at 1713 passed / 1 failed. Two things follow. A refusal is not a partial execution and must not be read as one, so re-check the state a refused command was meant to produce rather than assuming the first half landed. And this is the concrete argument for running EVERY gate after EVERY change rather than once after the last one: the window between injecting a rival and reverting it is exactly where a refused restore hides.'). work_done('improvise-8tk',bug_fix,'ZZ/:wq quit even when the save failed, silently discarding the model. Save now aborts the effect batch on failure; save_to_current_path/1 returns Result; three tests in ui/effect.rs.'). work_done('improvise-907',bug_fix,'Import wizard step machine redesign: validation_error() gate at ReviewProposals (no-category and over-limit), history-stack previous() bound to Left on every step, and Done made a real confirmation step holding the built workbook. Superseded improvise-gfq, improvise-o1c, improvise-c6j.'). work_done('improvise-6sq',wont_do,'Merging EditOrDrill and EnterEditAtCursorCmd into one EnterEdit{mode} is not safe: they differ in WHEN the cell value is read, not in a mode parameter. Added ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid as executable evidence.'). @@ -523,6 +528,7 @@ decision('improvise-6uva',script_replay_lost_its_context_at_35946af,'improvise s decision('improvise-gd28',browser_drill_blocked_by_surrogate_scope_not_layer_2,'The ticket recorded drill as blocked on ADR 001 layer 2 because ApplyAndClearDrill is session-local and unaddressable. That reasoning is about the COMMIT path. Entering a drill needs no commit, and CreateView/SetAxis/SetPageSelection are already unaddressed ModelCommands that NAME THEIR VIEW. What a browser cannot supply is the OPERAND -- CellId::Aggregate(AggregateHash), an opaque u128, which is ADR 002 working as designed. The real gap is one level down from where it was looked for: THE MUTATION AND ITS OPERAND HAVE DIFFERENT SCOPES. The mutation configures the shared _Drill and is addressed to nobody; the operand exists only inside one session layout. So the fix is not a new mechanism but the view:String convention every other view-scoped variant already follows, resolved against a layout derived for that name -- no SessionId needed. fdk1 is also not needed: edit_or_drill declines records-mode editing OUT LOUD before reaching a cell, so a browser cannot edit inside a drill grid at all and no divergent staging scheme is reachable.'). decision('improvise-rr8n',keep_the_table_derived_from_what_it_describes,'The ticket asked to rewrite the tile-bar hint literal and SUPPRESS the derived status line in TileSelect. The implementing agent did the opposite and the orchestrator ACCEPTED it: the derived status line is a STRICT SUPERSET of the literal (Space, h/l, Tab appear only there) and cannot go stale, so suppressing it would delete information and keep the table that had just rotted. Verified before accepting -- the literal read \"Ctrl+arrows to move tiles\" while those keys are bound to enter-tile-select, so it was already false. Both tile-bar literals deleted; Normal derived line already says T:tiles. The import wizard keeps the ticket ORIGINAL answer, and the asymmetry is the point: ITS status line is derived from the MODE keymap while the real keys are per-step inside HandleWizardKey, so there the derived table is the one that lies. RULE: keep whichever table is derived from the thing it describes. COROLLARY: every_hint_key_resolves_in_its_mode CANNOT SEE A STRING LITERAL IN A WIDGET, the same blind spot improvise-wk18 names for help.rs, so a hand-written key literal is invisible to every hint guard in the repo.'). decision('improvise-t14d',an_affordance_outranks_a_decoration,'draw.rs::fill_line takes a Keeps parameter rather than a global rule, because the three rows sharing a composed line genuinely disagree about which side yields. The title bar keeps the RIGHT -- the ?:help/:q escape hatch outranks an unbounded document name -- while minibuffer_line and draw_status keep the LEFT, which is what they already did, since right-priority there would let a long status message eat the text the user is editing. So the ticket \"give the right segment priority\" was correct ONLY for the title bar. Second fault, and the ticket framed it wrongly: padding by str::len mis-padded EVERY document including pure-ASCII ones, because the separator dot is in draw_title own format string at two bytes and one cell -- not a non-ASCII-NAME problem as filed. Widths now go through ui::text::truncate.'). +decision('improvise-6uva',a_registry_with_two_arms_demotes_the_weaker_caller,'CmdRegistry has three registration arms and NOTHING TYPE-CHECKS WHICH ARM A CALLER DESERVES. Three instances surfaced in one fix, and they do not point the same way, which is the part to carry. (1) The pure arm demoted every Move: parse built CursorState::default() so a script cursor reset instead of moving. (2) register_pure two arms DISAGREED WITH EACH OTHER -- interactive refused empty args, pure accepted them -- so \"w\" parsed one way and refused the other. (3) Found by ChatGPT Codex: the interactive arms of clear-cell/yank/paste/drill-into-cell took |_args, ctx| and DISCARDED EXPLICIT COORDINATES, so after the fix \"clear-cell Region/East\" cleared the origin. HERE THE WEAKER ARM WAS THE ONE THAT HAD THE CONTEXT, which is why \"always prefer the interactive arm\" is not the lesson. Make the arms agree, or make the difference the point; resolved for the cell-key commands by key_from_args_or_cursor -- explicit coordinates win, cursor is the fallback.'). wire_carries(command,both_directions,'user intent up, projection commands down'). wire_carries(snapshot,on_connect_only,'improvise-q08: full ViewState + render cache filled from current viewport'). wire_carries(command,both_directions,'user intent client->server, projections server->client').