From 5c3c79630842c2d34749068295b2920dd7fc63cb Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 16:40:32 -0700 Subject: [PATCH 1/6] docs: a wave skill, so the brief stops being retyped Four agents were dispatched without worktree isolation and all edited the same working tree: one git status held one agent's effect.rs, another's harness/build.sh and a third's scripts/node-test.sh. Any commit would have swept up three units' half-finished work. The brief had carried that rule before and lost it, which is what a retyped brief does. It is now a skill: worktree isolation first, partition by crate rather than ticket count, the base commit by name, the baseline, the never-write list, the injection discipline, pr-watch --expect, and attribution. Two doctrines: every_wave_agent_gets_its_own_worktree records the tell and the per-agent recovery (copy only what an agent can PROVE it touched -- two units can legitimately both be in improvise-protocol); the_orchestrator_delegates_substantial_work records the user's rule and the non-obvious half of it, that orchestrator work lands in the shared primary tree and collides with agents who own that ground. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH --- .claude/skills/wave/SKILL.md | 83 ++++++++++++++++++++++++++++++++++++ prolog-sessions/kb.pl | 2 + 2 files changed, 85 insertions(+) create mode 100644 .claude/skills/wave/SKILL.md diff --git a/.claude/skills/wave/SKILL.md b/.claude/skills/wave/SKILL.md new file mode 100644 index 00000000..91b1ef74 --- /dev/null +++ b/.claude/skills/wave/SKILL.md @@ -0,0 +1,83 @@ +--- +name: wave +description: Dispatch parallel agents to work through open beads and shepherd each to a merged PR. Use when an orchestrator is running a wave, partitioning the backlog, or writing a brief for a work-unit agent. +--- + +# Running a wave + +An orchestrator chunks the open backlog, fans agents out, reviews what comes +back, and merges. `AGENTS.md` has the history; this is the operating procedure. + +## Every agent gets its own worktree. No exceptions. + +Dispatch with `isolation: "worktree"`. Without it every agent edits the **same** +working tree: one `git status` shows four agents' half-finished files, any commit +sweeps up the others' work, and any `git checkout` destroys it. This has happened +here. If you discover it mid-wave, stop every agent, have each create a worktree +and copy across **only files it can prove it touched**, then `git checkout --` +those files in the primary tree. + +In the worktree: `direnv allow`, or `bd` reports a version *mismatch* rather than +failing cleanly. `direnv status` prints `Found RC allowed 0`, and `0` means +allowed — it reads like the opposite. + +## Partitioning + +By **crate or directory**, never by ticket count. **Tickets that share a file go +to one agent**, to be done sequentially — four widget migrations all touching +`src/draw.rs` are a guaranteed conflict split four ways. Group first, then count. + +Tell each agent what ground is *not* theirs, and to report rather than guess when +it must cross. Two agents adding a workspace member will still collide in +`Cargo.toml`/`Cargo.lock`; keep both sides. + +## What every brief must carry + +1. **The expected base commit, by name.** Worktrees do not reliably branch from + HEAD — one was created three merges behind and only noticed because it was + told to check. +2. **The baseline**, so "N passing" is checkable rather than asserted: Rust test + count, harness counts, node counts. +3. **Its ground, and the ground that is not.** +4. **Never write** `.beads/**`, `prolog-sessions/kb.pl`, `context/repo-map.md`. + Agents read them freely and report proposed text; the orchestrator applies it. +5. **The commit trailers and PR footer**, spelled out. +6. **Do not push to `main`, do not merge.** The orchestrator merges. +7. **What to report**: branch and head SHA, PR number, what changed per ticket, + *what could not be verified and why*, proposed repo-map/kb text, and **what it + learned that the ticket did not predict** — consistently the most valuable + part, and omitted unless asked for explicitly. + +## Method to require, because this repo's defect is a test that passes + +- Failing test first, then the fix. +- Every guard proved by **injecting the fault it names** and watching it fail + **by name**. An injection that does not compile reads as a clean run, so + require a positive signal the build ran. +- `cargo test --workspace` is **fail-fast**; `--no-fail-fast` for any claim about + blast radius. +- A textually clean merge can **delete tests** with no conflict marker, and + `cargo check` will not notice. After any merge that moves a test module, diff + test **names** — a count is masked by the other side's additions. + +## Shepherding + +- Wait on checks with `scripts/pr-watch --expect `. Never hand-roll a + poll; see the `pr-watch` skill for the three ways they report green while wrong. +- Review before merging. Copilot's quota may be exhausted and its request is a + **silent no-op**; an absent review and an unrequested one both read as + "pending". Dispatch an adversarial reviewer and give it the head SHA, the base, + and a test-merge, so it reviews the tree that will exist. +- A re-review is of the **delta**, at a named SHA. A review of a superseded + commit reads as a pass. +- Merge on GitHub, then `git fetch gh && git merge --ff-only gh/main`. +- Anything posted to GitHub goes out under the owner's account: open every + comment with a line saying a Claude agent wrote it. + +## Between waves + +Run the code-review skill on the merged range. The characteristic find here is +not a wrong line, it is **a test that passes while the thing it names is +broken** — a vacuous fold test, a one-category fixture, a helper that fabricates +what the code under test needs. Ask the reviewer to inject the fault and watch +the check fail. diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index 3fe7a5e1..10d3b028 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -218,6 +218,8 @@ doctrine(a_write_must_not_be_able_to_disarm_its_own_gate,'scripts/kb validates a doctrine(a_read_validate_write_sequence_is_a_lost_update,'scripts/kb read the knowledge base, merged, validated and moved the result into place, with nothing between the read and the move. Two agents doing that concurrently -- which is ordinary here, waves run agents in parallel -- both read the same file, both validate, and the later move silently discards the earlier addition. Exactly the class this tool was built to remove, reintroduced one level up in the tool itself. Closed by stamping the file content at read time and refusing to commit if it changed, plus an mkdir lock (flock is absent on macOS) so the collision is rare rather than merely loud. The stamp is the part that matters: a lock prevents the common case, a compare-and-swap is what makes an uncommon one impossible to lose silently.'). doctrine(design_against_the_model_before_implementing,'AGENTS.md has always said Prolog is a design tool for reasoning about tickets, and it was the least-used line in the file: kb.pl was written retrospectively. \`scripts/kb check --with \` closes that -- proposed facts are merged into a throwaway candidate and kb_consistent runs over the union, so a design can be refused before code exists. The model already DERIVES rather than merely records: illegal_dependency, may_use, depends_transitively, portable, snapshot_safe, view_state_admits, record_obligation, needs_runtime_check, and eleven kb_error classes including layering_inverted and field_misplaced_in_view_state. Demonstrated on three proposals in one sitting: one added edge (improvise_core depending on improvise_command) produced SIX derived layering violations through the transitive closure, which no author would have enumerated; a proposal to declare decision/3 functional was refused with conflicting_values naming improvise-907, correctly, because one ticket carries several decisions; and a mis-ordered layer/2 argument was named as layer_names_unknown_crate rather than silently accepted. What makes this worth doing rather than decorative is the same rule as everywhere else -- the model must be able to REFUSE, and you must watch it refuse. A design language nothing can reject is documentation with parentheses.'). doctrine(a_key_rule_must_ask_whether_the_relation_is_multi_valued,'scripts/kb keyed every clause on the functor plus all arguments but the last, which is right for doctrine(Name,Text) and wrong for any RELATION: crate_depends_on(A,B) then keyed on A, so a crate depending on two crates read as one key twice and was unaddable. Found by trying to model a proposed crate, not by any test -- the tool had only ever been pointed at single-valued facts. The fix cannot read the KB own functional/1, because that check keys an arity-3 predicate on its FIRST argument, which is right for crate/3 and wrong for decision/3 where one ticket holds several decisions under different names. So key_arity/2 is a hand-maintained table and anything unlisted keys on the WHOLE head, which is the safe direction: an unlisted relation accepts many clauses and only an exact repeat is a duplicate.'). +doctrine(every_wave_agent_gets_its_own_worktree,'Dispatching parallel agents without worktree isolation puts all of them in the SAME working tree. Observed 2026-09-20 with four agents: one git status showed one agent effect.rs beside another harness/build.sh and a third scripts/node-test.sh, so any commit would have swept up half-finished work from three units and any checkout would have destroyed it. The tell is cheap and worth checking early -- run git status in the primary tree after dispatch and see whether it holds files from more than one unit. Recovery is per-agent: create a worktree, copy across only the files that agent can PROVE it touched, then git checkout -- those files in the primary tree; an agent that guesses which files are its own makes it worse. Ambiguity is real -- two units can legitimately both be in crates/improvise-protocol -- so the instruction is to check an edit history, not to infer from the ticket.'). +doctrine(the_orchestrator_delegates_substantial_work,'User instruction 2026-09-20: any substantial work goes to an agent in a worktree rather than being done in the orchestrator, and repeated tasks become a script or a skill rather than being retyped. Two reasons, one obvious and one not. The obvious one is that heavy orchestrator work blocks every sub-agent waiting on it. The other is that work done in the orchestrator lands in the SHARED primary tree, so it collides with whatever the agents are doing there -- an orchestrator fixing a bug in scripts/ while an agent owns scripts/ is a conflict the agent did not agree to. Corollary for briefs: a brief retyped by hand drifts between dispatches, which is what the wave skill exists to stop.'). 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.'). From 67ce2df4212ec1754a57a56129697a842129940f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 16:43:35 -0700 Subject: [PATCH 2/6] docs: the Copilot gate is gone, and AGENTS.md told agents to wait for it Measured: PRs #12, #21 and #27 carry 2, 8 and 4 Copilot reviews; #61, #66, #68 and #70 carry none. Both request paths report success and register nothing -- the GraphQL mutation returns clientMutationId null, the REST call returns 200 with requested_reviewers empty, and no review_requested event appears. So "merge only once Copilot passes" was an instruction to wait forever on a review that was never requested. An absent review and an unrequested one look identical from outside, which is review_is_requested_not_inherited seen from the requester's side. Review is now an adversarial agent the orchestrator dispatches, naming the head SHA it reviewed; the Copilot mechanics are kept for the day it returns. Found by the PR #70 agent, which noticed its own review request had produced nothing rather than assuming the review was queued. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH --- AGENTS.md | 16 ++++++++++++++-- prolog-sessions/kb.pl | 1 + 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f6a459dc..4c479d23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,6 +153,16 @@ worth it on a repo with other collaborators. One PR per wave, built by a **dedicated PR agent in its own worktree**, so waves pipeline against it: the next wave runs while the PR absorbs review fixes. +**Copilot is not reviewing this repository, and the request is a silent no-op.** +Measured 2026-09-20: PRs #12, #21 and #27 carry 2, 8 and 4 Copilot reviews; #61, +#66, #68 and #70 carry none. Both request paths report success and produce zero +`review_requested` events — the GraphQL mutation returns `clientMutationId: null` +and the REST call returns 200 with `requested_reviewers: []`. So **do not wait on +it**: an absent review and an unrequested one look identical from outside, which +is `doctrine(review_is_requested_not_inherited)` seen from the requester's side. +Review is an adversarial agent the orchestrator dispatches, given the head SHA. +The mechanics below are kept for the day it comes back. + Copilot review must be **re-requested after every fix push**; it does not re-review on its own. The mutation takes a **`botIds`** field — `userIds` fails with *"Could not resolve to User node"*: @@ -207,8 +217,10 @@ is to branch from `gh/main`, cherry-pick, open a follow-up PR, and delete the stray re-creation. Check `gh pr view --json state` *before* pushing a fix to a PR you have not looked at in a few minutes, rather than after. -Merge only once Copilot passes. Prefer a **merge commit** — `AGENTS.md` says -merges over rebases, and a wave's commits are individually meaningful. +Merge only once a review has passed — an adversarial agent's while Copilot is +absent, and it must name the head SHA it reviewed. Prefer a **merge commit** — +`AGENTS.md` says merges over rebases, and a wave's commits are individually +meaningful. ### Never hand-edit `.beads/issues.jsonl` diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index 10d3b028..341b2225 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -452,6 +452,7 @@ kb_issue_found(static_str_blocks_deserialize,'a borrowed &\'static str field mak kb_issue_found(ticket_premise_false_8uz,'improvise-8uz asserted selected/row_offset/col_offset were \"PERSISTED in .improv\". They were not: improv.pest has no cursor production and format_md emitted none. The claim shaped the ticket\'s whole option set, so it was checked with a test before any of the options was weighed.'). kb_issue_found(ticket_premise_false_rml,'improvise-rml called category_panel.rs an \"exact copy\" of tile_bar.rs. It was not: the colors were identical but the labels differed (\"Row ↕\" vs \"Row\", \"None ∅\" vs \"·\"). Only the color table was true duplication; the four label sets are four deliberate vocabularies and were preserved as four methods.'). kb_issue_found(duplicated_active_view,'adding ViewState::active_view created a second copy of \"which view is active\". The improvise-8uz characterization test caught the desync immediately because it called workbook.switch_view directly. Held in lockstep by two writers until improvise-jytm, which made the divergence deliberate: the guard test the_two_active_view_names_never_diverge was REPLACED, not deleted, by the_documents_default_view_outlives_this_clients_switches and a_fresh_session_opens_the_documents_default_view -- a deliberate split with no guard is how one silently regresses into the other.'). +kb_issue_found(copilot_stopped_reviewing_this_repo,'Measured 2026-09-20: PRs #12, #21 and #27 carry 2, 8 and 4 copilot-pull-request-reviewer[bot] reviews; #61, #66, #68 and #70 carry none. Both request paths report success and register nothing -- the GraphQL requestReviews mutation with botIds BOT_kgDOCnlnWA returns clientMutationId null, and POST /pulls//requested_reviewers with copilot-pull-request-reviewer[bot] returns 200 with requested_reviewers empty -- and no review_requested timeline event appears. Cause unknown; something changed between #27 and #61. Consequence for the process: AGENTS.md said \"merge only once Copilot passes\", which is an instruction to wait forever on a review that was never requested, since an ABSENT review and an UNREQUESTED one look identical from outside. That is doctrine(review_is_requested_not_inherited) seen from the requesters side, and doctrine(an_advisory_gate_is_not_a_gate) with the gate removed rather than never added. AGENTS.md now says review is an adversarial agent the orchestrator dispatches, naming the head SHA, and keeps the Copilot mechanics for the day it returns.'). functional(invariant/2). functional(location/3). functional(doctrine/2). From 5fa554d712d878eb54856044476ad404e0f6f2c7 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 17:11:44 -0700 Subject: [PATCH 3/6] docs: the help evidence heading names a test that exists again PR #61 deleted the help spec; PR #69 demoted its evidence heading to prose as Retired; PR #72 restored the coverage under a DIFFERENT name. So main briefly carried a heading whose claim was false in both directions -- the prose said retired while the test was back, and restoring the heading verbatim would have named a test that does not exist. Renamed to #72's actual test. check-evidence: 535 entries, all resolve; 162 tests cite the file and all have one. Worth noting the sequencing, because it is the interesting part: #69 is the PR that TAUGHT the checker to see #### headings and JS test names at all, so it is simultaneously the thing that would catch this and the thing whose demotion kept the merged tree green. The demotion was correct when written and stale by the time it merged. Plus doctrine(a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary): asserting WHICH refusal fired is only as strong as that message being unique across every refusal the code emits, so enumerate the whole vocabulary rather than the line you are looking at -- and where no message can discriminate, assert the outcome, and guard the backstop separately. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH --- context/test-evidence/clients.md | 7 ++++++- prolog-sessions/kb.pl | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 36e43b6d..326571c7 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -857,7 +857,12 @@ command. It failed on the first press past the end — client 0, host 4 — and wire was changed to match the effect. Whether both should wrap instead is improvise-trd3. -#### Retired — the help spec, `? opens help, and its rows are the shared ones` +#### "? opens help, and its rows carry the keys and the words, not empty frames" + +This entry was demoted to prose as Retired when PR #61 deleted the spec. PR #72 +restored the coverage under a new name, so the claim is live again and the +heading now names the test that exists. What follows is the original evidence; +improvise-igxp's stronger assertions are recorded separately below. improvise-ktv1's acceptance in one place: **a browser shows help.** `?` had entered `AppMode::Help` since the keymap was shared, and nothing appeared — diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index 24ff75bb..73d5141d 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -222,6 +222,7 @@ doctrine(a_key_rule_must_ask_whether_the_relation_is_multi_valued,'scripts/kb ke doctrine(a_local_shell_is_not_the_ci_shell,'zsh ABORTS on an unmatched glob and bash leaves it as a literal, so a CI step written and probed in zsh can fail in a way no developer shell can reproduce. node --test web/tests/*.test.js under bash therefore globbed the literal itself, matched nothing, reported 0 tests and exited 0 -- both node steps gated nothing the moment their directory moved (improvise-vz54). Local shell vs CI shell is a fault-injection axis in its own right: probe a CI line under the shell the workflow names, not the one you are typing in. The fix moves the glob into scripts/node-test.sh so the expansion is the script\'s and can be refused.'). doctrine(a_convention_that_keeps_a_checker_quiet_is_the_one_that_blinds_it,'context/test-evidence used #### for browser tests precisely BECAUSE --check-evidence resolved names against Rust fn names only, so a ### heading naming a Playwright test would be reported as an orphan. The workaround made every browser entry invisible, and a deleted test left its heading promising coverage for a whole release (improvise-5ray). Cure: collect ### and #### alike and decide by SHAPE -- a bare identifier is a Rust test, a quoted string is a JS test, anything else is prose naming nothing. When a checker forces a convention on the thing it checks, ask what that convention hides.'). doctrine(a_refusal_is_evidence_only_when_it_is_the_one_you_named,'Two kb-test cases passed against a tree with the fix they guarded reverted, because a DIFFERENT guard refused the same delta and the assertion was only \"fails, and the kb is unchanged\" (review of PR #69). A tool with several guards will usually refuse a broken delta somehow, so \"it was refused\" is nearly free and says nothing about the fix under test. Assert the REFUSAL MESSAGE, or construct a case only the fix can catch -- and prove it by reverting that one fix and watching the case fail. The same trap in reverse: a case asserting an OUTCOME (the add lands and every sibling survives) can discriminate where a message cannot, because what a wrong key destroys is the outcome.'). +doctrine(a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary,'A test that asserts WHICH refusal fired -- rather than merely that something refused -- is only as strong as that message being unique across every refusal the code can emit. The moment two refusals share a phrase, the assertion silently degrades back to \"something refused\", which is the exact defect it was written to repair, wearing a more convincing sentence. So when an assertion moves onto a message, enumerate the whole error vocabulary rather than the one line you are looking at: on PR #69 the reviewer listed all seven REFUSED strings in kb-merge.pl and confirmed each asserted phrase appears exactly once, in the refusal its case names. Where no message can discriminate -- a wrong has_key is always caught by check_total, the backstop -- assert the OUTCOME instead: what a wrong key destroys is the clause count, so the case asserts the count rose by exactly one. And guard the backstop separately, or a case can lean on it while claiming to test something else.'). 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.'). From debad215568fa1d873f9a7b7d5215e4bc37f4008 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 17:24:17 -0700 Subject: [PATCH 4/6] docs: run the gate CI runs, and bindings.rs is run Two one-line corrections the wave's last review surfaced, both about a claim being stated in more than one place. .claude/skills/code-review/SKILL.md told every reviewer and every wave agent to run `cargo clippy --workspace --all-targets` with no `-D warnings`. Clippy exits 0 on warnings, so the committed local gate was green on exactly the input CI is red on. This is not one person's shell mistake, it is the instruction: a reviewer following the skill as written would report clean and be wrong, which happened on PR #71. Also adds --no-fail-fast, since fail-fast hides blast radius. README.md was a sixth copy of "bindings.rs is never run", which PR #70 corrected in five places -- its grep missed README, and PR #72 then rewrote the sentence four lines above and carried the wrong half forward. Neither PR is at fault individually; it is an interaction between two branches, and it is the exact failure the corrected fact is a monument to. Plus doctrine(a_weak_check_and_a_strong_one_agree_until_the_defect): five instances this wave, one mistake -- a check written against the failure you imagined rather than the outcome the user sees. The weak and strong checks agree on every input anyone tried and diverge only on the input that is the defect. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH --- .beads/issues.jsonl | 43 +++++++++++++++++------------ .claude/skills/code-review/SKILL.md | 8 ++++-- README.md | 7 +++-- prolog-sessions/kb.pl | 1 + 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1fd34737..d4cedf56 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ -{"_type":"issue","id":"improvise-vz54","title":"node --test with an unmatched glob passes with zero tests","description":"Both CI node steps use a shell glob: `node --test web/tests/bundle/*.test.js` (browser job) and `node --test web/tests/*.test.js` (js job). CI runs them under `shell: /usr/bin/bash -e {0}`. Bash leaves an unmatched glob as a literal; node 22 treats it as a glob, matches nothing, and reports success with 0 tests and exit 0. Demonstrated. Rename or move either directory and the gate goes green having asserted nothing.\n\nPre-existing, not introduced by PR #63 - but #63 is the moment web/tests/bundle/ stopped being co-located with the job that builds its inputs, which makes a future move more likely.\n\nNote the local-shell trap that hid it: zsh ABORTS on an unmatched glob, so the probe only reproduces under bash. Local shell != CI shell is a fault-injection axis in its own right.\n\nFix: guard the step so an empty match fails loudly - a non-empty check on the expansion, or a minimum-test-count assertion. Prove it by renaming the directory and watching the step fail.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T21:05:55Z","created_by":"orchestrator","updated_at":"2026-09-20T21:05:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-dj1l","title":"kb-merge loses and corrupts clauses: keys are matched by unification","description":"Found by review of PR #68 after it merged. Two faults with one root: scripts/kb-merge.pl matches clause keys by UNIFICATION rather than variant equality.\n\n1. Intra-delta loss. check_self_consistent compares keys with ==, but findall hands back fresh variables for every collected key, so two clauses with variant heads are never == and the duplicate gate passes. foldl_merge then matches by unification, so the second clause removes the first. Two clauses in, one out, reported as success. Reproduced: a delta of two rules over one predicate went 536 before, 2 in delta, 537 after.\n\n2. Destruction and MUTATION. Adding 'probe_fact(X) :- doctrine(X,_).' to a kb holding probe_fact(a). and probe_fact(b). deleted probe_fact(a) AND wrote the rule as 'probe_fact(a):-doctrine(a,A)' -- X was bound by the match, so the clause written is not the clause supplied. The round-trip verify cannot see this: it compares the file against an intended list already corrupted by the same unification.\n\nFix: same_key/2 (copy_term + numbervars) in has_key, conflicts and absent; canonicalize before duplicate detection; a RULE keys on the whole clause, since two clauses with one head shape and different bodies is ordinary Prolog; and check_total/2 asserts the arithmetic the tool already PRINTS -- 'N in delta' beside a total that did not account for N was visible in its own output twice and nothing compared them.","status":"closed","priority":1,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T23:26:47Z","created_by":"claude-agent-scripts","updated_at":"2026-09-20T23:35:13Z","started_at":"2026-09-20T23:26:51Z","closed_at":"2026-09-20T23:35:13Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-vz54","title":"node --test with an unmatched glob passes with zero tests","description":"Both CI node steps use a shell glob: `node --test web/tests/bundle/*.test.js` (browser job) and `node --test web/tests/*.test.js` (js job). CI runs them under `shell: /usr/bin/bash -e {0}`. Bash leaves an unmatched glob as a literal; node 22 treats it as a glob, matches nothing, and reports success with 0 tests and exit 0. Demonstrated. Rename or move either directory and the gate goes green having asserted nothing.\n\nPre-existing, not introduced by PR #63 - but #63 is the moment web/tests/bundle/ stopped being co-located with the job that builds its inputs, which makes a future move more likely.\n\nNote the local-shell trap that hid it: zsh ABORTS on an unmatched glob, so the probe only reproduces under bash. Local shell != CI shell is a fault-injection axis in its own right.\n\nFix: guard the step so an empty match fails loudly - a non-empty check on the expansion, or a minimum-test-count assertion. Prove it by renaming the directory and watching the step fail.","status":"closed","priority":1,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T21:05:55Z","created_by":"orchestrator","updated_at":"2026-09-20T23:35:10Z","started_at":"2026-09-20T23:22:05Z","closed_at":"2026-09-20T23:35:10Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9c8c","title":"Move the reasoning out of code comments into context/ prose, cap comments at two lines","description":"USER INSTRUCTION 2026-09-20: \"cleanup all the comments in code and put the important stuff as prose files in context/. If code comments are needed, at most two consecutive lines of comments and possibly a context/ doc reference for more details (but don't overdo this).\"\n\nSCOPE RULED BY THE USER, both maximal:\n * The two-line cap applies to `///` and `//!` DOC COMMENTS as well as `//` implementation comments.\n * It applies to TEST doc-comments too. The bug narratives this repo's doctrine puts on a regression test move to prose keyed by test name.\n\n## The measurement\n\n103 Rust files, 12,533 comment lines, 2,961 comment runs of which 1,306 are longer than two lines, and 12 doctest fences inside doc comments. Worst single runs: worker.rs 121 consecutive lines, ui/reduce.rs 119, improvise-gp/lib.rs 84, gp-tree/examples/fit_csv.rs 79, wasm-client/keys.rs 78, cmd/fit.rs 59, dataset.rs 57, registry.rs 56, basis.rs 56, vfs.rs 55.\n\n## What makes this hard, and why it is not a formatting change\n\nTHE REASONING IN THOSE COMMENTS IS THE MOST EXPENSIVE ARTEFACT IN THE REPOSITORY. The post-wave documentation review concluded that the code's doc comments are now BETTER than context/repo-map.md, naming src/ui/grid.rs, worker-server/src/lib.rs, ui/reduce.rs and protocol/render.rs as carrying the full argument correctly. Several of them were written in response to a bug that cost a review round to find, and they exist because someone could not re-derive the rule from the code.\n\nSO THIS IS A MOVE, NOT A DELETION. Every argument either lands in context/ prose or is dropped DELIBERATELY as a restatement of what the code plainly says. The failure mode to avoid is a tidy diff that quietly loses the one sentence explaining why a check exists — which is the shape that reintroduces the bug the check guards.\n\n## The convention\n\n * A comment run is AT MOST TWO CONSECUTIVE LINES.\n * It may carry a `context/` reference for the rest -- but SPARINGLY, per the user (\"don't overdo this\"). A pointer on every function is noise; a pointer where a reader would otherwise re-derive a rule wrongly is the point.\n * Prose lands in context/ files owned by ONE area each, so parallel work does not collide.\n * Test evidence -- what broke, what was injected, why the test can fail -- lands in a test-evidence file keyed by TEST NAME.\n\n## Known hazards\n\n * TWELVE DOCTEST FENCES live inside doc comments. Capping a `///` block that contains a ``` example DELETES A RUNNING TEST. Find them first and decide each: promote to a real test, or keep the fence and cap around it.\n * A `//!` module doc is often the only orientation a file has. Losing it entirely costs more than it saves; the cap applies, so what survives must be the two lines that matter.\n * `#[allow(...)]`, `#[cfg(...)]` and clippy directives are ATTRIBUTES, not comments -- do not touch them.\n * A test name is the key into the evidence file, so RENAMING A TEST ORPHANS ITS ENTRY. Whatever convention is chosen must make that detectable.\n","acceptance_criteria":"No comment run in any .rs file exceeds two consecutive lines, verified by a script that is kept in the repo so the rule is checkable rather than remembered. Every argument that was in a capped comment is findable in context/ -- spot-checked by sampling the worst ten files and confirming their reasoning survived. cargo test --workspace --no-fail-fast stays at 1309 passing (or higher, if a doctest fence was promoted to a real test), and git diff filtered for non-comment lines is EMPTY -- no behaviour changed. The twelve doctest fences are each accounted for explicitly. rustdoc still builds.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T16:10:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:10:53Z","labels":["area:docs"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0yq8","title":"Move ClientState and keys.rs::step into improvise-protocol","description":"THE SINGLE HIGHEST-VALUE CONSOLIDATION LEFT, named by the agent that did the keymap move and measured rather than guessed.\n\ndesign-principles 5 (user ruling 2026-09-20): minimise the code a new UI framework needs; centralise logic in a redux-like store so a new front end DISPLAYS STATE rather than reimplementing behaviours. The test is: would a third front end have to write this again?\n\nkeys.rs::step is 99 LINES OF \"WHAT THIS COMMAND DOES\" and the answer is YES. It depends on NOTHING but ViewState + the protocol widget caches + the announced window -- every one of which is ALREADY A PROTOCOL TYPE. So it is in the wrong crate, and the only reason it is there is history.\n\nAFTER THIS MOVE A NEW FRONT END NEEDS EXACTLY TWO THINGS: browser-event (or terminal-event, or whatever) -\u003e KeyEvent, and painting. That is the whole of design-principles 5 made true rather than aspirational.\n\nWHY IT WAS NOT DONE IN THE SAME PASS: it is a crate move on top of a crate move (the keymap had just moved), and half-doing it would leave two homes. Deliberate, not forgotten.\n\nMEASURED CONTEXT from that pass: keys.rs grew 264 -\u003e 401 non-comment lines while GAINING the whole editing path, search typing, panels, transpose and tile select -- and the key TABLE left the crate entirely. web/app.js, the only genuinely framework-bound file, grew 344 -\u003e 359 while gaining a live editor and a hint line. So the trend is right; this ticket is what finishes it.\n\nNOTE THE SECOND-CONSUMER ARGUMENT: the more of the front end is SHARED, the more of it a second consumer can AUDIT. CellDisplay::edit_text exists because the browser asked a question the TUI had answered privately and the two answers differed -- see doctrine(being_the_second_consumer_audits_the_abstraction).","acceptance_criteria":"ClientState and keys.rs::step live in improvise-protocol, and a new front end needs exactly two things: an event -\u003e KeyEvent mapping, and painting. Both depend on nothing but ViewState, the protocol widget caches and the announced window — all already protocol types — so the move should cost no new dependency. tests/lockstep.rs still drives the client and the real reduce_key over the same keypresses; if the move makes that test trivially true by type identity, say so and delete it (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)).","status":"closed","priority":1,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:08Z","started_at":"2026-09-20T18:05:07Z","closed_at":"2026-09-20T18:05:08Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-oai9","title":"[bug] The worker's errors reach the console with their cause stripped","description":"FOUND BY THE FIRST REAL BROWSER RUN of the deployed page (2026-09-20), and it is the reason that run produced no actionable information.\n\nThe console said:\n\n improvise worker: failed to start\n Error: Cannot open demo.improv\n\n\"Cannot open demo.improv\" is the CONTEXT vfs::open attaches with with_context. anyhow Display prints ONLY THE OUTERMOST CONTEXT, so bindings::start mapping through e.to_string() kept the label and threw away the cause -- the Storage failure or the parse failure that actually happened.\n\nWHY THIS IS THE WORST POSSIBLE PLACE FOR IT: bindings.rs is cfg(target_arch = wasm32) and is the one module in the crate that NO TEST IN THE REPOSITORY CAN REACH. A devtools console is the only instrument pointed at it. So the single code path with no test coverage was also the one discarding its own diagnostics.\n\nFIXED: a report() helper rendering with the alternate form {:#}, which walks the chain joining with \": \". It lives in lib.rs rather than bindings.rs SO IT CAN BE TESTED AT ALL -- bindings is wasm-gated. Five call sites (start, on_message, flush, documents, refresh).\n\nTEST WRITTEN AGAINST THE OLD RENDERING FIRST: a_reported_error_carries_its_cause_not_just_its_label asserts that anyhow to_string() IS just the label (pinning the trap, so the helper stops being needed if that ever changes) and that report() contains BOTH the label and the cause.\n\nTHIS DOES NOT FIX THE UNDERLYING FAILURE -- it makes the next reload name it. Redeploy and read the console again.","status":"closed","priority":1,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:55:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:07Z","started_at":"2026-09-20T05:32:34Z","closed_at":"2026-09-20T06:22:07Z","close_reason":"Closed by PR #21 (WU-I) ON EVIDENCE, with NO CODE WRITTEN -- the fix described in this ticket was already in the tree.\n\nVerified rather than assumed: report() renders with the alternate form {:#}, which walks the anyhow chain joining with \": \"; it lives in lib.rs rather than the wasm-gated bindings.rs so it can be tested at all; five call sites (start, on_message, flush, documents, refresh); and a_reported_error_carries_its_cause_not_just_its_label pins both halves -- that anyhow's to_string() IS just the outermost label, and that report() carries the cause too.\n\nFAULT INJECTED BEFORE CLOSING: changed {e:#} back to {e} and watched the test fail on contains(\"no such document\"). So the test is discriminating and the claim is evidence rather than description.\n\nThe ticket's own closing line still stands and is not discharged by this: it does not fix the underlying failure, it makes the next reload name it.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -38,34 +39,36 @@ {"_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-349m","title":"format_md/parse_md is not idempotent when a .improv file has two Category sections with the same name","description":"format -\u003e parse -\u003e format differs, the two sides differing only in the order of '\u003e' group lines. DETERMINISTIC -- 8 failures in 8 runs on unmodified main (00f7678) -- so it is NOT gotcha 18's HashSet shape.\n\nCause: the file carries two '## Category: ||' sections with the SAME name. The second merges into the first and its group lines are appended in a position the second parse does not reproduce.\n\nReproducer, from the proptest's own minimal input:\n\n let file = \"v2025-04-09\\n# |\\\\a|\\n## View: a\\n_-: row\\ncollapsed: ||/A--\\n\\\n ## Category: ||\\n- |\\\\a\\\\a\\\\a|,|\\\\a\\\\a|,|\\\\a|,|\\\\a|\\n\u003e _---[|\\\\a|]\\n\u003e _[|\\\\a|]\\n\\\n ## Category: ||\\n- ||[||]\\n\u003e ||[||]\\n\";\n let m1 = parse_md(file).unwrap();\n let printed = format_md(\u0026m1);\n assert_eq!(printed, format_md(\u0026parse_md(\u0026printed).unwrap())); // fails\n\nWHY IT LOOKS FLAKY AND IS NOT: persistence::parse::grammar_prop_tests::generated_file_roundtrips goes red only when the grammar-walking generator happens to emit a duplicate section header, which is rare. It was observed once during PR #70's CI, the rerun passed, and the honest move was to take proptest's minimal input and try it deterministically rather than rerun and move on. That is what turned a 'flake' into a filed bug.\n\nFound by the improvise-pdvm/75ld agent on PR #70; not caused by that PR. bd search for idempotent, roundtrip, duplicate category, format_md and group order returned nothing, so it was not already filed.\n\nWorth deciding as part of the fix: is a duplicate '## Category:' header legal input at all? If it is not, the parser should refuse it and the generator should stop emitting it; if it is, the merge must produce a canonical group order. The round-trip property cannot hold without one of those answers.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:42:59Z","created_by":"orchestrator","updated_at":"2026-09-20T23:42:59Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-v5pc","title":"restore the browser assertion that ? opens help","description":"36ade35 moved the shell suite to node and deleted harness/tests/widgets.spec.js, taking the Playwright test '? opens help, and its rows are the shared ones' with it. Nothing replaced it: no node test and no spec asserts that the help overlay is drawn, that its active tab is the shared module's page title, or that l pages it. The evidence entry in context/test-evidence/clients.md survived the deletion and went on promising the coverage for a whole release -- the improvise-5ray cost, now caught by --check-evidence, and the heading is demoted to prose rather than deleted so its two injections (dropping the paintHelp call; removing the mode != Help guard in help_rows) survive for whoever restores it. improvise-ktv1's acceptance -- a browser shows help -- is currently unasserted end to end.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:25:28Z","created_by":"claude-agent-scripts","updated_at":"2026-09-21T00:06:02Z","closed_at":"2026-09-21T00:06:02Z","close_reason":"Duplicate of improvise-78v6, already fixed and merged in PR #72. harness/tests/help.spec.js on main carries test(\"? opens help, and its rows carry the keys and the words, not empty frames\") plus a second test that reads an advertised key out of the DOM, presses it, and asserts the selection moved.\n\nWorth noting HOW this duplicate happened, because the filing agent did nothing wrong: it searched bd before filing, as AGENTS.md requires, and found nothing -- because the fix was in a sibling agent's UNMERGED PR at the time. A parallel wave makes in-flight work invisible to bd search. The cheap addition to the wave brief is to check open PRs, not only the tracker, before filing something a sibling unit might own.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xfsb","title":"Agents should post to GitHub under their own identity, not the owner's","description":"Today every agent comment, review and PR goes out as @fiddlerwoaroof, because gh uses his credentials. The interim is a convention -- open each comment with a line saying a model wrote it (doctrine(an_agent_posting_to_github_says_so), AGENTS.md) -- and a convention is something an agent can forget. A separate identity makes attribution structural instead.\n\nTwo routes:\n\n1. GitHub App installed on the repo. Agents mint an installation token and export GH_TOKEN. Comments render as \u003cname\u003e[bot], the same shape as copilot-pull-request-reviewer[bot]. Scoped permissions, no seat consumed, revocable independently of the owner's account, and the bot suffix is not spoofable by a human account.\n\n2. Machine user with its own PAT as GH_TOKEN. Simpler to set up, consumes a seat on some plans, and is indistinguishable from a person at a glance -- which is most of what we are trying to fix.\n\nPrefer 1. Needs the user: create the App, grant contents+pull_requests+issues write, install it, and put the app id and private key where agents can reach them.\n\nWorth checking while doing it: whether the bot can be requested as a PR reviewer, which would let review agents post as reviews rather than as comments.\n\nOnce it lands, the attribution line becomes belt-and-braces rather than the mechanism, and the doctrine should be amended to say so rather than deleted -- a reader seeing an unattributed comment from the owner's account still needs to know it might be an agent.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T23:04:19Z","created_by":"orchestrator","updated_at":"2026-09-20T23:04:19Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-84wp","title":"Doctrine: a check that looks like the answer, answering a neighbouring question","description":"Ready-to-apply prose for `context/design-principles.md` §7. Filed rather than committed because PR #60 head `ec03f95` is byte-identical (tree `ec5d531...`) to a tree the reviewer fully tested, and a further commit would destroy that property and require a fresh harness run while `main` was moving several times an hour. The artefact is worth more preserved than rushed.\n\n---\n\n### A check that looks like the answer, answering a neighbouring question\n\nThe characteristic defect in this repo is not a wrong line; it is a check that is *adjacent* to the thing it names. Three instances landed in one campaign (PRs #59/#60), in three different layers, which is why it is worth stating as a shape rather than three gotchas:\n\n- **A vacuous assertion.** `set_panel_open_and_cursor` asserted `category_panel_open` was true after an effect set it true -- once the launch default supplied `true`, the effect could be replaced by a **no-op** and 1391 tests passed. It answered *is the flag true* where it meant *did the effect set it*.\n- **A vacuous repair of it.** The first fix folded `[open:false, open:true]` and asserted the final state -- which a reducer that folds nothing also satisfies. It answered *does the text mention the other direction* where it meant *does the writer move the flag both ways*. **Reading the diff is what failed**, and it failed because the vacuous version contained `open: false`.\n- **A clean auto-merge.** `git merge-tree` reporting no conflict answers *can git combine these texts*, where the question is *does the combination do what both sides meant*. Two PRs edited the same four files with no conflict and neither side CI-tested together.\n\nEach has the same remedy shape: **name the question you actually mean, then find an input that separates it from its neighbour.**\n\n- For an assertion: **assert the direction the default cannot supply.** Closing the panel discriminates; opening it proves nothing.\n- For a repair: inject a **one-directional** fault (`if open { set true }` -- can raise, never lower). A vacuous repair cannot see it, because a test that only asserts the `true` direction is satisfied by a writer that only ever writes `true`.\n- For a merge: **materialise it and run the gates**, and check that a count which should MOVE actually moved. Playwright going 16 -\u003e 17 is what showed the other side survived; \"no conflict\" and an unchanged total would both have been consistent with losing it.\n\nThis generalises the existing `doctrine(fold_tests_must_test_order_not_the_axiom)` (asserting `fold(prefix)+fold(rest) == fold(whole)` is the definition of a left fold, so it answers a neighbouring question too). What the campaign adds is that the pattern recurs in **test repairs** and in **merge hygiene**, not only in production code and not only in fold tests.\n\n---\n\nAcceptance: the section is in §7; `doctrine(fold_tests_must_test_order_not_the_axiom)` cross-references it or is subsumed by it. Worth considering a `kb.pl` doctrine clause, which is orchestrator-only.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:38:23Z","created_by":"wave3-webui","updated_at":"2026-09-20T21:38:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qsmp","title":"App::cmd_context's panel-open propagator is untested: C cannot open a closed panel and nothing fails","description":"`App::cmd_context` (crates/improvise-command/src/ui/app.rs) propagates `self.view_state.category_panel_open` into the `CmdContext`. **Hardcode it to `true` and the entire workspace passes** — 1394 tests, nothing fails.\n\nThat matters because `registry.rs` reads it in three places for `toggle-panel-and-focus`s open-vs-close decision and the panel focus/cycle commands. With it stuck true, pressing `C` on a CLOSED category panel decides to *close* it rather than open it — the panel becomes unopenable by its own key — and no test notices.\n\n**Why it is invisible:** `cmd/mod.rs`s `test_helpers` sets `category_panel_open` explicitly on the context it builds, so no command test ever exercises the real propagator. The tests construct the value they then assert on.\n\n**This is PRE-EXISTING and was not caused by the launch-default ruling (PR #60).** That distinction was established by control rather than assumed: with the default inverted, this mutation fails *exactly the same two tests as inverting the default alone* and nothing more. So it was unguarded before the ruling too. The remedy is therefore \"a test at all\", not the precondition-stating fixture that fixed the two genuinely-disarmed tests (`set_panel_open_and_cursor`, `panel_commands_address_the_named_panel_only`).\n\n**The method that found it is worth more than the finding** and is written up in `context/test-evidence/clients.md` under \"How to know a launch default has stopped disarming things\": mutate every site that writes, propagates or reads the value; sites where nothing fails are suspects; then re-run each suspect with the default inverted to separate *vacuous* (fix the fixture) from *unguarded* (file it). Grep cannot do this — it finds sites that NAME the flag, not tests that merely start from a default-built state.\n\nAcceptance: a command-layer test that drives the real `App::cmd_context` rather than a hand-built context, such that `toggle-panel-and-focus` on a closed panel opens it; proved by hardcoding the propagator and watching it fail.\n\nSibling note: `improvise-wasm-client/src/render.rs` has the same propagator shape for `RenderModel`. Hardcoding THAT one true is improvise-wsaw reintroduced one layer up, and Rust sees nothing — only the Chromium harness catches it, at `contract.spec.js`s `toBeHidden`. Consequence worth keeping: a Rust-only run of the `test` CI job cannot see a reintroduced wsaw, which is the sharpest argument on file for the browser job being able to fail the build.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T21:23:53Z","created_by":"wave3-webui","updated_at":"2026-09-20T21:23:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-5ray","title":"check-evidence ignores #### headings and every browser-test entry uses them","description":"Found by review of PR #65, 2026-09-20 (finding 4). Sibling of improvise-lyyc\nand improvise-r0qa, and the sharpest of the three because this one has already\nlet a false claim stand for a release.\n\nscripts/check-comment-runs.py --check-evidence collects headings with\n`s.startswith(\"### \")` and builds its `defined` set from rust_files(roots) only.\nTwo consequences compound:\n\n 1. A `####` heading is invisible to it — `\"### \"` does not match `\"#### \"`.\n 2. Even if it matched, `defined` holds only Rust `fn` names, so a JavaScript\n test name could never resolve.\n\ncontext/test-evidence/clients.md uses `#### \"quoted name\"` for every browser\ntest precisely BECAUSE of (2) — a `###` heading naming a Playwright test would\nbe reported as an orphan. So the convention that keeps the checker quiet is the\nsame convention that makes it blind.\n\nDEMONSTRATED: appending\n\n #### \"a test that does not exist anywhere\"\n\nto context/test-evidence/clients.md leaves `--check-evidence` exiting 0.\n\nWHAT IT COST: PR #61 deleted `a socket that opened and then dropped reads as\ndisconnected, and stays that way`. Its `####` heading stayed. The gate passed\nfor the whole release while the file promised a guard that was not there —\nexactly the \"dangling explanation\" design-principles §7 says the checker exists\nto prevent.\n\nFIX SHAPE: collect `###`/`####` alike, and extend `defined` with JS test names\n(`test(\"…\"` in harness/tests/*.spec.js and web/tests/*.test.js). Note the\nchecker's own --self-test must grow a case, or this fix is unguarded in exactly\nthe way the thing it fixes was.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T21:22:55Z","created_by":"wave3-jstests","updated_at":"2026-09-20T21:22:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-78v6","title":"PR #61 deleted the help-overlay spec and nothing replaced it","description":"Found by review of PR #65, 2026-09-20 (finding 3).\n\nPR #61 deleted `? opens help, and its rows are the shared ones` from\nharness/tests/shell.spec.js. It was added by #47 (commit 9838fc0) for\nimprovise-ktv1, whose whole point is that the help overlay draws the SHARED\ncontent — the rows, their keys and the page titles are\nimprovise_protocol::help's, the same ones the TUI draws — rather than the page\nspelling keys of its own.\n\nNothing replaced it. `grep -rn help web/tests/` is empty and no spec touches\n`#help`. So today:\n\n - paintHelp can stop being called and no test notices;\n - `help`, `help_page` and `help_titles` reach no assertion at all;\n - `?` entering AppMode::Help in the browser is unguarded.\n\ncontract.spec.js asserts the help overlay's tabs and that a `dt` is visible,\nwhich covers the first two bullets partially — but not the content, which is\nimprovise-igxp.\n\nWHAT THIS TICKET IS REALLY ABOUT, and why it is worth more than the test: a PR\nwhose subject was \"#61 dropped a guard\" did not diff #61's test names. That is\none command:\n\n git show 692aa0b~1:harness/tests/shell.spec.js | grep -o 'test(\"[^\"]*\"'\n\nagainst the same for the parent of #61's merge. It would have found this in the\nsame pass that found the socket-routing loss. Whatever replaces the help test\nshould come with that diff run over #61's whole range, in case there is a\nfourth.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T21:22:41Z","created_by":"wave3-jstests","updated_at":"2026-09-20T21:22:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-56ek","title":"SetPanelOpen maps a Panel to a field twice, in two crates","description":"`SetPanelOpen` is matched to a panel twice, in two crates, and a guard on either says nothing about the other.\n\n- `crates/improvise-command/src/ui/effect.rs` — `effect::SetPanelOpen::apply`, a three-way match on `Panel` writing `app.view_state.*_panel_open`. This is what the TUI runs.\n- `crates/improvise-protocol/src/reduce.rs` — `reduce_view`'s `Command::SetPanelOpen` arm, the same three-way match writing the same three fields on a client's own `ViewState`. This is what the browser folds.\n\nFound while repairing the fallout of the launch-default ruling (PR #60). Both copies' Category arm had gone vacuous for the same reason, and **each had to be fixed independently** — the Chromium harness passed 3/3 against a no-op'd `effect::SetPanelOpen`, correctly, because the browser never executes it. That is the tell: a fault in one copy is invisible to the other's tests by construction.\n\nThis is design-principles §5's named disease (\"a derivation done twice is the defect, even when both copies are correct today\"), and it is the same shape as improvise-edp (the category tree flattened twice) and gotcha 44 (the grid frame derived twice). Both of those were cured by moving the derivation to one home both ends call.\n\nWhy it is filed rather than fixed in #60: the fix is a shared `fn set_panel(view: \u0026mut ViewState, panel: Panel, open: bool)` in `improvise-protocol` that both arms call, and that is a change to the effect layer's shape rather than to the pointer work #60 is about. It is also not urgent — both copies are correct today, and now both are guarded.\n\nTwo things to preserve when fixing it:\n\n1. **The cure is one home, not a test holding two copies in step.** `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` — if the fix leaves a \"the two agree\" test alive, it is not finished.\n2. **`Panel::ALL` already exists** in `ui/app.rs` and drives the toggle keys, so a fourth panel reaches every keymap from one entry. The field-per-panel layout on `ViewState` is what forces the three-way match; whether that becomes a map keyed by `Panel` is the real question, and it touches serde (gotcha 25) and every reader.\n\nAcceptance: one derivation; injecting a wrong-panel write fails a test in whichever crate hosts it; no compatibility test between the two.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:10:47Z","created_by":"wave3-webui","updated_at":"2026-09-20T21:10:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-5ray","title":"check-evidence ignores #### headings and every browser-test entry uses them","description":"Found by review of PR #65, 2026-09-20 (finding 4). Sibling of improvise-lyyc\nand improvise-r0qa, and the sharpest of the three because this one has already\nlet a false claim stand for a release.\n\nscripts/check-comment-runs.py --check-evidence collects headings with\n`s.startswith(\"### \")` and builds its `defined` set from rust_files(roots) only.\nTwo consequences compound:\n\n 1. A `####` heading is invisible to it — `\"### \"` does not match `\"#### \"`.\n 2. Even if it matched, `defined` holds only Rust `fn` names, so a JavaScript\n test name could never resolve.\n\ncontext/test-evidence/clients.md uses `#### \"quoted name\"` for every browser\ntest precisely BECAUSE of (2) — a `###` heading naming a Playwright test would\nbe reported as an orphan. So the convention that keeps the checker quiet is the\nsame convention that makes it blind.\n\nDEMONSTRATED: appending\n\n #### \"a test that does not exist anywhere\"\n\nto context/test-evidence/clients.md leaves `--check-evidence` exiting 0.\n\nWHAT IT COST: PR #61 deleted `a socket that opened and then dropped reads as\ndisconnected, and stays that way`. Its `####` heading stayed. The gate passed\nfor the whole release while the file promised a guard that was not there —\nexactly the \"dangling explanation\" design-principles §7 says the checker exists\nto prevent.\n\nFIX SHAPE: collect `###`/`####` alike, and extend `defined` with JS test names\n(`test(\"…\"` in harness/tests/*.spec.js and web/tests/*.test.js). Note the\nchecker's own --self-test must grow a case, or this fix is unguarded in exactly\nthe way the thing it fixes was.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T21:22:55Z","created_by":"wave3-jstests","updated_at":"2026-09-20T23:35:11Z","started_at":"2026-09-20T23:22:05Z","closed_at":"2026-09-20T23:35:11Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-78v6","title":"PR #61 deleted the help-overlay spec and nothing replaced it","description":"Found by review of PR #65, 2026-09-20 (finding 3).\n\nPR #61 deleted `? opens help, and its rows are the shared ones` from\nharness/tests/shell.spec.js. It was added by #47 (commit 9838fc0) for\nimprovise-ktv1, whose whole point is that the help overlay draws the SHARED\ncontent — the rows, their keys and the page titles are\nimprovise_protocol::help's, the same ones the TUI draws — rather than the page\nspelling keys of its own.\n\nNothing replaced it. `grep -rn help web/tests/` is empty and no spec touches\n`#help`. So today:\n\n - paintHelp can stop being called and no test notices;\n - `help`, `help_page` and `help_titles` reach no assertion at all;\n - `?` entering AppMode::Help in the browser is unguarded.\n\ncontract.spec.js asserts the help overlay's tabs and that a `dt` is visible,\nwhich covers the first two bullets partially — but not the content, which is\nimprovise-igxp.\n\nWHAT THIS TICKET IS REALLY ABOUT, and why it is worth more than the test: a PR\nwhose subject was \"#61 dropped a guard\" did not diff #61's test names. That is\none command:\n\n git show 692aa0b~1:harness/tests/shell.spec.js | grep -o 'test(\"[^\"]*\"'\n\nagainst the same for the parent of #61's merge. It would have found this in the\nsame pass that found the socket-routing loss. Whatever replaces the help test\nshould come with that diff run over #61's whole range, in case there is a\nfourth.\n","notes":"PR #72 (branch wave/harness-coverage). harness/tests/help.spec.js replaces the deleted test; the test-name diff over #61's range found a FOURTH loss -- 'a click on an item row filters, as Enter does' -- now restored in contract.spec.js.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T21:22:41Z","created_by":"wave3-jstests","updated_at":"2026-09-21T00:24:04Z","started_at":"2026-09-20T23:21:55Z","closed_at":"2026-09-21T00:24:04Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-56ek","title":"SetPanelOpen maps a Panel to a field twice, in two crates","description":"`SetPanelOpen` is matched to a panel twice, in two crates, and a guard on either says nothing about the other.\n\n- `crates/improvise-command/src/ui/effect.rs` — `effect::SetPanelOpen::apply`, a three-way match on `Panel` writing `app.view_state.*_panel_open`. This is what the TUI runs.\n- `crates/improvise-protocol/src/reduce.rs` — `reduce_view`'s `Command::SetPanelOpen` arm, the same three-way match writing the same three fields on a client's own `ViewState`. This is what the browser folds.\n\nFound while repairing the fallout of the launch-default ruling (PR #60). Both copies' Category arm had gone vacuous for the same reason, and **each had to be fixed independently** — the Chromium harness passed 3/3 against a no-op'd `effect::SetPanelOpen`, correctly, because the browser never executes it. That is the tell: a fault in one copy is invisible to the other's tests by construction.\n\nThis is design-principles §5's named disease (\"a derivation done twice is the defect, even when both copies are correct today\"), and it is the same shape as improvise-edp (the category tree flattened twice) and gotcha 44 (the grid frame derived twice). Both of those were cured by moving the derivation to one home both ends call.\n\nWhy it is filed rather than fixed in #60: the fix is a shared `fn set_panel(view: \u0026mut ViewState, panel: Panel, open: bool)` in `improvise-protocol` that both arms call, and that is a change to the effect layer's shape rather than to the pointer work #60 is about. It is also not urgent — both copies are correct today, and now both are guarded.\n\nTwo things to preserve when fixing it:\n\n1. **The cure is one home, not a test holding two copies in step.** `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` — if the fix leaves a \"the two agree\" test alive, it is not finished.\n2. **`Panel::ALL` already exists** in `ui/app.rs` and drives the toggle keys, so a fourth panel reaches every keymap from one entry. The field-per-panel layout on `ViewState` is what forces the three-way match; whether that becomes a map keyed by `Panel` is the real question, and it touches serde (gotcha 25) and every reader.\n\nAcceptance: one derivation; injecting a wrong-panel write fails a test in whichever crate hosts it; no compatibility test between the two.\n","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:10:47Z","created_by":"wave3-webui","updated_at":"2026-09-20T23:59:39Z","closed_at":"2026-09-20T23:59:39Z","close_reason":"Fixed in PR #70 (14757a7), reviewed and merged. layout_version and dirty moved from declared EffectKind to observed change; adopt_cursor_epoch narrowed to the reshaped views with an is-empty fail-safe; Panel-\u003efield consolidated onto ViewState accessors.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4ibz","title":"The TUI launch frame is untested, and it now carries a decision","description":"PR #60 changed `ViewState::default()` to `category_panel_open: true` on a user ruling (\"the categories panel should be visible at launch\"), implemented as the SHARED default rather than a browser special case — a browser-only default is \"the page overrides the flag\", which is the same defect as the \"the page ignores the flag\" bug improvise-wsaw fixed.\n\nThe consequence, accepted deliberately and flagged at the top of that PR's body: **`improvise model.improv` now opens with the category sidebar showing**, because `App::new` builds `..ViewState::default()` and `src/draw.rs` draws the panel off that same field.\n\nTwo gaps that PR deliberately did not grow to cover.\n\n**1. No TUI test covers the launch frame at all.** The browser half is asserted end to end in `harness/tests/contract.spec.js` (visible with no keypress, and `C` still hides it — two states, because either alone passes against the wsaw bug). The terminal half is asserted nowhere: nothing renders a first frame and looks at it. So the sidebar could vanish from the TUI, or appear when it should not, with the suite green. That is not a regression this PR introduced — the launch frame was never covered — but the PR is what made the launch frame carry a decision.\n\n**2. The panel-focus cycle with all three panels open is now untested, and the uncovered case MOVED rather than shrank.** `panel_focus_cycles_from_the_focused_panel_not_the_first` used to inherit \"category panel closed\" from the default; it now states that precondition explicitly, which is the right fix and preserves what the test is for (skipping a *closed* panel). But the state nothing covers is now the launch-adjacent one: all three open. Worth a case.\n\nNote what should NOT be done here: do not re-derive the launch state from anywhere else. It is stated in exactly one place (`ViewState::default()`), asserted by exactly one Rust test (`a_fresh_session_opens_with_the_category_panel`, which also asserts the other two panels stay shut so it cannot pass against a default that opens everything), and reverting it fails exactly that one test under `--no-fail-fast`. That shape is correct and a second statement of the fact would be the disease.\n\n**This ticket is also the place to record a refinement if the user scopes the ruling back to the browser.** If they do, the change is NOT \"special-case the browser\" — it is to give the two front ends different *initial* values through something that is not a shared default, and the argument for why that is not a divergence would have to be made first.\n\nAcceptance: a TUI test that renders the first frame and asserts the category panel is drawn; the all-three-open cycle case; both proved by injection.\n","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:01:46Z","created_by":"wave3-webui","updated_at":"2026-09-20T21:10:59Z","closed_at":"2026-09-20T21:10:59Z","close_reason":"User ruled BOTH (TUI + browser); TUI launch frame now tested in tests/tui_launch_state.rs","comments":[{"id":"547f7ff6-32fa-556f-8479-9c6be120a2d9","issue_id":"improvise-4ibz","author":"wave3-webui","text":"ANSWERED by the user, 2026-09-20, relayed through the orchestrator: **BOTH**. The category sidebar is the launch default in the TUI as well as the browser. `improvise model.improv` opening with the sidebar showing is intended, not collateral. The behaviour PR #60 already had is the behaviour wanted; no narrowing to the web.\n\nBoth gaps this ticket named are now closed in #60, so it closes rather than staying open as a question.\n\n**1. The TUI launch frame is tested.** `tests/tui_launch_state.rs` (root crate — the TUI's own crate), three tests:\n- `the_terminal_app_launches_with_the_category_panel_open`\n- `the_other_two_panels_are_shut_at_launch` — so \"open everything at launch\" cannot pass the first\n- `the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it` — walks `Panel::ALL` comparing `App::new` against `ViewState::default()`, which is what would catch a TUI-only launch state being reintroduced (the divergence improvise-wsaw closed)\n\nNot a rendered-frame test: design-principles §7 says not to test ratatui `Widget::render`, and there is no `TestBackend` anywhere in the repo. The honest target is the TUI's own constructor; `src/draw.rs`'s one-line read of the flag stays argued-not-tested per gotcha 48. Proved by injection — putting the default back to `false`, and separately making `App::new` override it, each fail the expected tests and only those.\n\n**2. The all-three-panels-open cycle: deliberately NOT added, and the reviewer's reasoning is better than the diff-size one I gave.** `panel_focus_cycles_from_the_focused_panel_not_the_first` is about *skipping a closed panel*; that is its property and it is correctly pinned by its now-explicit precondition. A three-open case tests plain rotation, which the two-open assertions already establish structurally. Not a gap.\n\n**What this ticket did not predict, and is the most valuable thing it produced.** A launch default **disarms every test that asserted the thing the default now supplies**. Three tests asserted `category_panel_open` was *true* after doing something to make it true, and all three then passed without the code under them working:\n\n- `set_panel_open_and_cursor` (`ui/effect.rs`) — the serious one. With it vacuous, `effect::SetPanelOpen`'s Category arm could be replaced by a **no-op** — `C` doing nothing in the terminal — and the entire workspace stayed green at 1391 tests.\n- `panel_commands_address_the_named_panel_only` (`reduce.rs`) — the browser's copy of the same mapping, vacuous the same way. **Its first repair was ALSO vacuous**, which is the subtle part: folding `[open:false, open:true]` and asserting the final state is *true* still passes against a reducer that folds nothing, because the default already supplies `true`. Only asserting the **`false`** direction discriminates.\n- `panel_focus_cycles_from_the_focused_panel_not_the_first` — inherited its precondition from the default; now states it.\n\n**The rule to carry: assert the direction the default cannot supply.** Closing the panel is the discriminating move; opening it proves nothing. Every one of these was caught by injection, not by reading — including the failed first repair, which reported NONE failing and would otherwise have shipped looking fixed.\n\nOne thing filed rather than fixed: **improvise-56ek** — `effect::SetPanelOpen` and `reduce_view`'s `Command::SetPanelOpen` are two three-way matches in two crates, so a guard on either says nothing about the other, and both had to be repaired independently here.\n","created_at":"2026-09-20T21:10:58Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-igxp","title":"The browser help overlay's rows are asserted as present, never as content","description":"Found by adversarial review of PR #61, 2026-09-20 (finding 5), and inherited\nverbatim from the test it replaced — the deleted shell.spec.js help test had\nthe same gap, so this is older than the PR that carries it now.\n\nharness/tests/contract.spec.js asserts the help overlay's rows exist and are\nvisible:\n\n await expect(page.locator(\".help-body dt\").first()).toBeVisible();\n\nIt never asserts their TEXT. Blanking `keys.textContent` in web/app.js's\npaintHelp leaves all three contract tests passing: an empty \u003cdt\u003e is a visible\n\u003cdt\u003e. The rows and their keys are improvise_protocol::help's — the whole point\nof improvise-ktv1 was that the page draws the SHARED content rather than\nspelling keys itself — so a page that draws the right NUMBER of empty rows is\nexactly the failure the test exists to catch.\n\nThis is the \"a class is a flag, and a flag is not a pixel\" shape. The same PR\ncaught one instance of it (the typed buffer was asserted by its `typing` class\nand not its caret, and the assertion was strengthened until the injection\nfailed) and did not sweep the file for siblings.\n\nFIX: assert a key row's text against something the keymap really produces —\nnot a literal the page could drift from. The tab titles already are asserted\nby text (\"Welcome\", \"Navigation\"), so the model is there.\n\nINJECTION THAT MUST FAIL AFTER THE FIX: blank `keys.textContent` in paintHelp\n(or set it to \"\"), rebuild harness/serve, run contract.spec.js. Today: 3\npassed.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:53:45Z","created_by":"wave3-jstests","updated_at":"2026-09-20T20:53:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lyyc","title":"check-evidence is one-directional: a test citing the evidence file can have no entry","description":"`scripts/check-comment-runs.py --check-evidence` validates one direction only: every `### name` heading in `context/test-evidence/` must name a test that exists. It never asks the reverse — that a test whose doc comment *points at* the evidence file has an entry there.\n\nThat blind spot has now been walked into for the first time. PR #59 added `the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons`, whose doc comment directs the reader to `context/test-evidence/clients.md`, and that file had no entry for it. The checker was green throughout. Worse, the same file still described the behaviour #59 *ships* as one of the two injected faults that had proved the old test — so a reader following the pointer arrived at prose that contradicted the code. Both were found by human review, which is the thing the checker exists to avoid needing.\n\nThe asymmetry in the original design is deliberate and correct as far as it goes: most tests have no evidence and should not be forced to. What is missing is the narrower obligation — **a test that names the evidence file must appear in it**. That is checkable: grep test doc comments for `test-evidence`, take the enclosing `fn` name, require a matching heading.\n\nNote the second half of the incident is not covered by that check either, and probably cannot be: an entry can exist and still be *stale*. The drill entry existed and described its own subject backwards. The only defence there is the rule that a fact stated twice is changed by a grep rather than an edit (gotcha 48), which is discipline rather than a gate. Worth saying so in the ticket so nobody implements the cheap half and believes the class is closed.\n\nAcceptance: `--check-evidence` gains the second direction; it fails on a test that cites the evidence file and has no heading; proved by deleting one heading and watching it fail.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:40:48Z","created_by":"wave3-webui","updated_at":"2026-09-20T20:40:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-igxp","title":"The browser help overlay's rows are asserted as present, never as content","description":"Found by adversarial review of PR #61, 2026-09-20 (finding 5), and inherited\nverbatim from the test it replaced — the deleted shell.spec.js help test had\nthe same gap, so this is older than the PR that carries it now.\n\nharness/tests/contract.spec.js asserts the help overlay's rows exist and are\nvisible:\n\n await expect(page.locator(\".help-body dt\").first()).toBeVisible();\n\nIt never asserts their TEXT. Blanking `keys.textContent` in web/app.js's\npaintHelp leaves all three contract tests passing: an empty \u003cdt\u003e is a visible\n\u003cdt\u003e. The rows and their keys are improvise_protocol::help's — the whole point\nof improvise-ktv1 was that the page draws the SHARED content rather than\nspelling keys itself — so a page that draws the right NUMBER of empty rows is\nexactly the failure the test exists to catch.\n\nThis is the \"a class is a flag, and a flag is not a pixel\" shape. The same PR\ncaught one instance of it (the typed buffer was asserted by its `typing` class\nand not its caret, and the assertion was strengthened until the injection\nfailed) and did not sweep the file for siblings.\n\nFIX: assert a key row's text against something the keymap really produces —\nnot a literal the page could drift from. The tab titles already are asserted\nby text (\"Welcome\", \"Navigation\"), so the model is there.\n\nINJECTION THAT MUST FAIL AFTER THE FIX: blank `keys.textContent` in paintHelp\n(or set it to \"\"), rebuild harness/serve, run contract.spec.js. Today: 3\npassed.\n","notes":"PR #72. help.spec.js asserts text, and contract.spec.js's two help lines went from toBeVisible to text. The named injection (blank keys.textContent in paintHelp) now fails contract.spec.js and both tests in help.spec.js.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T20:53:45Z","created_by":"wave3-jstests","updated_at":"2026-09-21T00:24:04Z","started_at":"2026-09-20T23:21:56Z","closed_at":"2026-09-21T00:24:04Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lyyc","title":"check-evidence is one-directional: a test citing the evidence file can have no entry","description":"`scripts/check-comment-runs.py --check-evidence` validates one direction only: every `### name` heading in `context/test-evidence/` must name a test that exists. It never asks the reverse — that a test whose doc comment *points at* the evidence file has an entry there.\n\nThat blind spot has now been walked into for the first time. PR #59 added `the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons`, whose doc comment directs the reader to `context/test-evidence/clients.md`, and that file had no entry for it. The checker was green throughout. Worse, the same file still described the behaviour #59 *ships* as one of the two injected faults that had proved the old test — so a reader following the pointer arrived at prose that contradicted the code. Both were found by human review, which is the thing the checker exists to avoid needing.\n\nThe asymmetry in the original design is deliberate and correct as far as it goes: most tests have no evidence and should not be forced to. What is missing is the narrower obligation — **a test that names the evidence file must appear in it**. That is checkable: grep test doc comments for `test-evidence`, take the enclosing `fn` name, require a matching heading.\n\nNote the second half of the incident is not covered by that check either, and probably cannot be: an entry can exist and still be *stale*. The drill entry existed and described its own subject backwards. The only defence there is the rule that a fact stated twice is changed by a grep rather than an edit (gotcha 48), which is discipline rather than a gate. Worth saying so in the ticket so nobody implements the cheap half and believes the class is closed.\n\nAcceptance: `--check-evidence` gains the second direction; it fails on a test that cites the evidence file and has no heading; proved by deleting one heading and watching it fail.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T20:40:48Z","created_by":"wave3-webui","updated_at":"2026-09-20T23:35:12Z","started_at":"2026-09-20T23:22:06Z","closed_at":"2026-09-20T23:35:12Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-djoo","title":"Publish the context/ prose as a documentation site folded into GitHub Pages","description":"Generate a static documentation site from context/ (design-principles, repo-map, code/*, adr/*, test-evidence/*) plus docs/design-notes.md, assembled by web/assemble.sh under /docs/ alongside the landing page at the root and the app at /app/. No framework, no build step beyond one zero-dependency node script. Ticket ids resolve to a generated index built from .beads/issues.jsonl; source paths link to GitHub at the assembled ref. The generator link-checks its own output and exits non-zero on a break, so a dead cross-reference fails the assembly. context/plan.md and context/cloud-agents.md are deliberately not published.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T20:34:53Z","created_by":"wave3-docsite","updated_at":"2026-09-20T20:34:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rmvx","title":"Six advertised keys do nothing in the browser, and the guard is scoped to one mode","description":"Found by generalising improvise-gd28's browser finding. `\u003e` was not an instance;\nit is a class. Gotcha 47 says every KeyOutcome::Upstream is a black hole —\nHost::handle answers ClientFrame::Key with an explicit empty vec — so any key\nthe app ADVERTISES and then resolves upstream does nothing at all in a browser\nand reads as broken rather than as unavailable.\n\nTHE LIST, advertised in a hint line AND unhandled by resolve::step:\n\n \u003e drill-into-cell Normal (fixed in PR #59)\n P toggle-prune-empty Normal\n R toggle-records-mode Normal\n o add-record-row RecordsNormal\n d/Del delete-formula-at-cursor FormulaPanel\n : enter-mode command Normal, RecordsNormal, FormulaPanel,\n CategoryPanel, ViewPanel, TileSelect\n\nThe `:` row is a DIFFERENT case and must not be lumped in. Its name IS handled;\nit is refused on purpose by strands_the_user, because CommandMode is a prompt\nthis client can enter but cannot commit — a dead room is worse than a locked\ndoor. So for `:` the refusal is RIGHT and only the silence is wrong. Fixing it\nmeans saying so, not implementing it.\n\nHOW THE CROSS-REFERENCE WAS MADE, because the method is reusable and the naive\nversion is wrong. help::advertised() is a table of (command, args, caption) —\nCOMMAND NAMES, not text — so the hint line can be crossed against what\nresolve::step handles exactly, with no tokenisation of the rendered string and\nno guessing which character in \"hjkl arrows :nav\" is a key. Anything that\ntokenises the hint TEXT is solving a harder problem than exists.\n\nMY FIRST PASS OVER-REPORTED AND I ALMOST BELIEVED IT. Resolving every bound key\nagainst an EMPTY ClientState flags edit-or-drill, commit-cell-edit,\ncreate-and-switch-view, page-prev/page-next and delete-formula-at-cursor as\nupstream — but most of those are handled names refused by STATE (no cell under\nthe cursor, no published basis), which is correct behaviour and not this bug.\n\"Upstream in an empty fixture\" and \"the name is unhandled\" are different\nquestions, and the first is not evidence for the second. The list above is the\nsecond, separated by checking each name against step's arms directly.\n\nAND THE NAMED LIST IS HONEST FOR ONE MODE ONLY — this is the point with reach.\nresolve.rs's `the_command_names_that_still_need_the_host_are_these` is the\nguard gotcha 47 says must be kept accurate, and it IS accurate — but it is\nbuilt with in_mode(AppMode::Normal), so it only ever asks about Normal-mode\nbindings. add-record-row (RecordsNormal) and delete-formula-at-cursor\n(FormulaPanel) are outside its reach entirely: they could never have failed it.\nA named list that silently scopes itself to one mode is a guard that LOOKS\nbroader than it is, and that is the same shape as every other defect this repo\nkeeps finding — a check passing while the thing it names is unexamined.\n\nTHE DECISION THIS TICKET WANTS, which is why nothing beyond `\u003e` was touched:\n\n (a) Do the same interim for the remaining five: each says what is true, in\n the client's existing status mechanism. Cheap, honest, reversible, and\n each needs its own TRUE wording — the `\u003e` fix was nearly routed through a\n message about aggregates, which would have been true-sounding about the\n wrong thing and worse than silence.\n (b) Implement some of them client-side instead. toggle-prune-empty and\n toggle-records-mode are view-shape changes and may be composable from\n existing ModelCommands; add-record-row and delete-formula-at-cursor\n touch the document and need checking against gotcha 38 first.\n (c) Make the class unrepresentable: a test asserting that every command named\n by help::advertised() either resolves locally or appears on a per-mode\n exemption list. That is the \"make the wrong thing impossible\" move and it\n would have caught all six at once. It is also the only option that stops\n the SEVENTH appearing.\n\nI did not pick. (c) is the one I would argue for, and it is independent of\nwhether (a) or (b) happens to the five.\n\nNot chased: whether any key is advertised in a hint for a mode whose keymap\ndoes not bind it at all. every_hint_key_resolves_in_its_mode covers that from\nthe keymap side, so it should be empty — but I did not verify it, and it is the\nmirror-image question.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:23:03Z","created_by":"wave3-webui","updated_at":"2026-09-20T20:23:03Z","comments":[{"id":"e5a7ccf4-843d-504a-818b-283c47115c96","issue_id":"improvise-rmvx","author":"wave3-webui","text":"Precedent note (orchestrator's instruction; do NOT build this yet).\n\nThis ticket asks for a check that every key a hint line advertises actually resolves to something in a browser, so that \"advertised but Upstream\" — a black hole (gotcha 47) — fails the build instead of being discovered by a user.\n\nThat is **the same shape as two checks this repo already has**, which makes it precedent rather than novelty, and the two are worth reading before designing it:\n\n- `every_bound_command_name_is_registered` (improvise-command/src/command/keymap.rs) walks the keymaps and requires each bound name to resolve in the registry — *and* that its args parse, because a typo'd mode name otherwise compiles into a dead key.\n- `every_hint_key_resolves_in_its_mode` (same file) splits each `AppMode::hint_text()` and requires every key it names to resolve in that mode's map. Its tokenizer knows the hint dialect explicitly and **panics on a token it cannot classify** rather than skipping it — which is the detail to copy, since a checker that silently skips what it does not understand reports success for the cases it never looked at.\n\nSo the design question is not \"how do we build such a check\" but \"what is the browser's analogue of *resolves*\". For the host, resolving means the registry answers. For a browser, it means `resolve::step` answers with `Some` rather than falling through to `KeyOutcome::Upstream`. The list of keys that legitimately still need the host already exists as `the_normal_mode_keys_that_still_need_the_host_are_these`, so the check is plausibly: every key named in a hint either resolves locally or is on that named list — which also forces closing one to come and say so.\n\nOne live instance, for scale: `\u003e` was advertised and silent, and that was one of six. The one-line interim (a `SetStatus` from `step`) closed `\u003e` only; the other five were deliberately left alone pending this ticket.\n","created_at":"2026-09-20T20:40:58Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-rmvx","title":"Six advertised keys do nothing in the browser, and the guard is scoped to one mode","description":"Found by generalising improvise-gd28's browser finding. `\u003e` was not an instance;\nit is a class. Gotcha 47 says every KeyOutcome::Upstream is a black hole —\nHost::handle answers ClientFrame::Key with an explicit empty vec — so any key\nthe app ADVERTISES and then resolves upstream does nothing at all in a browser\nand reads as broken rather than as unavailable.\n\nTHE LIST, advertised in a hint line AND unhandled by resolve::step:\n\n \u003e drill-into-cell Normal (fixed in PR #59)\n P toggle-prune-empty Normal\n R toggle-records-mode Normal\n o add-record-row RecordsNormal\n d/Del delete-formula-at-cursor FormulaPanel\n : enter-mode command Normal, RecordsNormal, FormulaPanel,\n CategoryPanel, ViewPanel, TileSelect\n\nThe `:` row is a DIFFERENT case and must not be lumped in. Its name IS handled;\nit is refused on purpose by strands_the_user, because CommandMode is a prompt\nthis client can enter but cannot commit — a dead room is worse than a locked\ndoor. So for `:` the refusal is RIGHT and only the silence is wrong. Fixing it\nmeans saying so, not implementing it.\n\nHOW THE CROSS-REFERENCE WAS MADE, because the method is reusable and the naive\nversion is wrong. help::advertised() is a table of (command, args, caption) —\nCOMMAND NAMES, not text — so the hint line can be crossed against what\nresolve::step handles exactly, with no tokenisation of the rendered string and\nno guessing which character in \"hjkl arrows :nav\" is a key. Anything that\ntokenises the hint TEXT is solving a harder problem than exists.\n\nMY FIRST PASS OVER-REPORTED AND I ALMOST BELIEVED IT. Resolving every bound key\nagainst an EMPTY ClientState flags edit-or-drill, commit-cell-edit,\ncreate-and-switch-view, page-prev/page-next and delete-formula-at-cursor as\nupstream — but most of those are handled names refused by STATE (no cell under\nthe cursor, no published basis), which is correct behaviour and not this bug.\n\"Upstream in an empty fixture\" and \"the name is unhandled\" are different\nquestions, and the first is not evidence for the second. The list above is the\nsecond, separated by checking each name against step's arms directly.\n\nAND THE NAMED LIST IS HONEST FOR ONE MODE ONLY — this is the point with reach.\nresolve.rs's `the_command_names_that_still_need_the_host_are_these` is the\nguard gotcha 47 says must be kept accurate, and it IS accurate — but it is\nbuilt with in_mode(AppMode::Normal), so it only ever asks about Normal-mode\nbindings. add-record-row (RecordsNormal) and delete-formula-at-cursor\n(FormulaPanel) are outside its reach entirely: they could never have failed it.\nA named list that silently scopes itself to one mode is a guard that LOOKS\nbroader than it is, and that is the same shape as every other defect this repo\nkeeps finding — a check passing while the thing it names is unexamined.\n\nTHE DECISION THIS TICKET WANTS, which is why nothing beyond `\u003e` was touched:\n\n (a) Do the same interim for the remaining five: each says what is true, in\n the client's existing status mechanism. Cheap, honest, reversible, and\n each needs its own TRUE wording — the `\u003e` fix was nearly routed through a\n message about aggregates, which would have been true-sounding about the\n wrong thing and worse than silence.\n (b) Implement some of them client-side instead. toggle-prune-empty and\n toggle-records-mode are view-shape changes and may be composable from\n existing ModelCommands; add-record-row and delete-formula-at-cursor\n touch the document and need checking against gotcha 38 first.\n (c) Make the class unrepresentable: a test asserting that every command named\n by help::advertised() either resolves locally or appears on a per-mode\n exemption list. That is the \"make the wrong thing impossible\" move and it\n would have caught all six at once. It is also the only option that stops\n the SEVENTH appearing.\n\nI did not pick. (c) is the one I would argue for, and it is independent of\nwhether (a) or (b) happens to the five.\n\nNot chased: whether any key is advertised in a hint for a mode whose keymap\ndoes not bind it at all. every_hint_key_resolves_in_its_mode covers that from\nthe keymap side, so it should be empty — but I did not verify it, and it is the\nmirror-image question.","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-wasm-keys-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T20:23:03Z","created_by":"wave3-webui","updated_at":"2026-09-21T00:24:01Z","started_at":"2026-09-20T23:21:14Z","closed_at":"2026-09-21T00:24:01Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","comments":[{"id":"e5a7ccf4-843d-504a-818b-283c47115c96","issue_id":"improvise-rmvx","author":"wave3-webui","text":"Precedent note (orchestrator's instruction; do NOT build this yet).\n\nThis ticket asks for a check that every key a hint line advertises actually resolves to something in a browser, so that \"advertised but Upstream\" — a black hole (gotcha 47) — fails the build instead of being discovered by a user.\n\nThat is **the same shape as two checks this repo already has**, which makes it precedent rather than novelty, and the two are worth reading before designing it:\n\n- `every_bound_command_name_is_registered` (improvise-command/src/command/keymap.rs) walks the keymaps and requires each bound name to resolve in the registry — *and* that its args parse, because a typo'd mode name otherwise compiles into a dead key.\n- `every_hint_key_resolves_in_its_mode` (same file) splits each `AppMode::hint_text()` and requires every key it names to resolve in that mode's map. Its tokenizer knows the hint dialect explicitly and **panics on a token it cannot classify** rather than skipping it — which is the detail to copy, since a checker that silently skips what it does not understand reports success for the cases it never looked at.\n\nSo the design question is not \"how do we build such a check\" but \"what is the browser's analogue of *resolves*\". For the host, resolving means the registry answers. For a browser, it means `resolve::step` answers with `Some` rather than falling through to `KeyOutcome::Upstream`. The list of keys that legitimately still need the host already exists as `the_normal_mode_keys_that_still_need_the_host_are_these`, so the check is plausibly: every key named in a hint either resolves locally or is on that named list — which also forces closing one to come and say so.\n\nOne live instance, for scale: `\u003e` was advertised and silent, and that was one of six. The one-line interim (a `SetStatus` from `step`) closed `\u003e` only; the other five were deliberately left alone pending this ticket.\n","created_at":"2026-09-20T20:40:58Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-iioi","title":"The view and formula panels have no pointer affordance while the category panel does","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated in Chromium.\n\nweb/app.js's viewRow (~:404) and formulaRow (~:422) build their \u003cli\u003e with NO click listener, where categoryList (~:453) now has one. Click (resolve.rs:71-79) has exactly three variants and no ViewRow/FormulaRow.\n\nDemonstrated: Shift+V, then click the last row of #views -\u003e NOTHING changes. Mode stays VIEWS, the row list is byte-identical before and after, the active marker does not move, the panel cursor does not move.\n\nWHY THIS IS THE ONE A USER HITS FIRST: a user who has just learned that category rows are clickable will click a view row and read the silence as broken — which is LITERALLY the bug report that produced improvise-7ybz ('I can't expand categories' meant 'clicking does nothing'). Shipping the cure for one panel and not its two siblings reproduces the original report.\n\nIt is also the CHEAP one: the resolver already has everything. view_at_cursor, switch-view-at-cursor and delete_view_at_cursor exist, and the formula panel already has a formula_cursor in reduce_view's SetPanelCursor arm. Click::ViewRow(usize) under the commit's own stated rule is a near-copy of click_category_row.\n\nADJACENT, same class, lower confidence and not chased by the reviewer: grid GROUP HEADERS (th.group) are collapse-toggles in the TUI and are not clickable here either; row/column header \u003cth\u003es likewise.\n\nNOT covered by the drill work: improvise-gd28 is refused-by-design because no wire vocabulary exists. The view and formula panels have full wire vocabulary today, so this is a different gap.\n\n## Steps to Reproduce\n\n1. Load /app/index.html, press Shift+V.\n2. Click a row in #views.\n3. Nothing changes — no switch, no cursor move, no status.","acceptance_criteria":"Clicking a view row switches to that view; clicking a formula row moves the formula cursor — each under the same rule as click_category_row (go there, then do what Enter does there). Covered by harness/tests/pointer.spec.js assertions proved against the pre-fix bundle.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"wave3-webui","owner":"el-github@elangley.org","created_at":"2026-09-20T20:18:11Z","created_by":"orchestrator","updated_at":"2026-09-20T20:20:22Z","started_at":"2026-09-20T20:20:22Z","comments":[{"id":"c306069a-89f1-55d3-ba64-0c81b8025a7a","issue_id":"improvise-iioi","author":"wave3-webui","text":"A RULING WANTED WHILE THIS TICKET IS OPEN, raised by the reviewer and sharpened\nrather than answered, because it follows from my rule and that is exactly why my\nrule should not settle it.\n\nTHE CLICK RULE I WROTE is \"go there, then do what Enter does there\". Applied to\nthe tile bar it gives: select the tile AND cycle its axis, because Enter in\nTileSelect is cycle-axis-for-tile. So there is NO click that merely selects a\ntile. A user cannot point at a tile to inspect it, or to see which one they are\nabout to act on. It is the one widget where a single click MUTATES THE DOCUMENT\n— every other click in the page moves a cursor, opens a panel, or expands a row.\n\nThat is consistent with the rule and it may still be wrong. A pointer carries an\nexpectation a keyboard does not: `T` then arrow then Enter is three deliberate\nacts, and collapsing them into one click removes the step where the user sees\nwhat they have selected before committing. The keyboard equivalent of \"click\"\nis arguably `T`+arrow (select), not `T`+arrow+Enter (select and act).\n\nTHE OPTIONS, none of which I have taken:\n (a) Keep it. One rule, no exceptions, and cycling is reversible in four\n clicks since Axis::next() is a rotation.\n (b) A tile click SELECTS only; cycling stays on Enter/Space and r/c/p. Costs\n the single-gesture affordance and makes the tile bar the one widget where\n a click does not activate.\n (c) Single click selects, double click cycles. Introduces a second pointer\n vocabulary the page does not currently have anywhere.\n\nI lean (a) on consistency grounds and distrust that lean, because it is the\nanswer my own rule produces and I am the one who wrote the rule.\n\nALSO, A CORRECTION TO MY OWN FRAMING, same source: the \"one rule\" is honestly\nTWO. click_cell does NOT do what Enter does in the grid — Enter is\nenter-advance, and a cell click moves the selection and delegates activation to\nthe page's inline editor, which is the pointer's `i` rather than its Enter. The\ncode has always done that; the commit message overstated by one widget. The\nhonest statement is: a click navigates to what was clicked, and activates it\nwhere activation is something the client can express.\n","created_at":"2026-09-20T20:23:17Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-hkr8","title":"A click that cannot compose its activation keeps the prefix and drops the payload","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated with a probe.\n\ncrates/improvise-protocol/src/resolve.rs:98 and :121 both do:\n\n commands.extend(filter_to_item_at(state, index).unwrap_or_default());\n commands.extend(tile_axis_at(state, index, None).unwrap_or_default());\n\nBoth callees return Option\u003cVec\u003cCommand\u003e\u003e and both None arms come from model(state, cmd)? -\u003e BasisCache::claim -\u003e self.current.as_ref()?, i.e. THE SERVER HAS NOT PUBLISHED A BASIS YET (gotcha 43: claim returns None until then; a record missing from the basis is filter_map'd away rather than failing, so this is the only None trigger).\n\nThe KEYBOARD handles the same None correctly: resolve() maps step(..) == None to KeyOutcome::Upstream — 'I cannot answer, ask the host'. The CLICK instead keeps the prefix and drops the payload.\n\nProbe on ClientState::default() (no basis), clicking an item row:\n\n [ SetPanelOpen { panel: Category, open: true },\n ChangeMode(CategoryPanel),\n SetPanelCursor { panel: Category, cursor: 1 } ]\n\nThe panel opens, focuses and the cursor moves — and the FILTER, which is the entire point of the click, is gone, with the frame reported as applied. Same for click_tile: you land in TILES mode and the axis never cycles.\n\nThis is gotcha 47's clause-4 failure (partial AND silent) reproduced one layer up, and it is worse than the keyboard's answer for the same input.\n\nREACHABILITY IS LOW and that is deliberate context, not a dismissal: the basis arrives in the bootstrap Snapshot and app.js folds a whole frame before repainting, so there is normally nothing painted to click before a basis exists. The reviewer could not construct a reachable browser path. AGENTS.md's rule applies — 'reachability is a reason not to rush a fix and not a reason not to notice one' — and the lost update CheckedBatch fixed had the same reachability argument and none of the documentation.\n\n## Steps to Reproduce\n\n1. Build a ClientState with no published basis (ClientState::default()).\n2. Call click_category on an item row index.\n3. Observe the navigation commands present and the filter absent.","acceptance_criteria":"A click is all of its intents or none: when the activation half cannot be composed (no published basis), click_category_row and click_tile emit nothing rather than the navigation prefix alone. Demonstrated by probing ClientState::default() and seeing an empty command list instead of SetPanelOpen/ChangeMode/SetPanelCursor without the filter.","status":"in_progress","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:17:59Z","created_by":"orchestrator","updated_at":"2026-09-20T20:46:22Z","started_at":"2026-09-20T20:46:22Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7ybz","title":"The browser category panel has no pointer affordance at all","description":"The user reported 'I can't expand categories'. Expanding works fully by KEYBOARD (Shift-C, j/k, Enter -\u003e Command::ToggleCatExpand, resolved client-side in improvise-protocol/src/resolve.rs:598). What does not work is the MOUSE: web/app.js::categoryList builds plain \u003cli\u003e elements with no click listener, so clicking the collapsed triangle does nothing. Verified in Chromium: clicking the 'Product' row leaves the panel byte-identical.\n\nThis is the whole page's pointer story, not one widget: #grid cells get a click handler ONLY when cell.editable, so there is no click-to-select either, and the tile bar and view rows added by fix/web-ui-correctness are likewise keyboard-only.\n\nThe DECISION needed before coding: does the browser get a pointer interaction model at all, and if so what does a click mean? Clicking a category row plausibly means (a) move cat_panel_cursor there, (b) also focus the panel (enter CategoryPanel mode) if it is not focused, and (c) toggle expand if the row is a header. That is three commands from one click, and app.js must stay protocol-free - so the shape would be a wasm entry point (Client::click_category(index) -\u003e Option\u003cString\u003e) mirroring typed_cell, not JS composing Commands. Same question for a grid cell click and a tile click.\n\nNot filed as part of the fix because it is a product call, not a defect in an implemented feature.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"wave3-webui","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:10Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:30:56Z","started_at":"2026-09-20T19:30:56Z","comments":[{"id":"89e3844a-2923-598b-87dc-5cddf6486dec","issue_id":"improvise-7ybz","author":"wave3-webui","text":"USER RULING on the tile click, recorded here because the reasoning is worth more than the behaviour: **a click SELECTS, a second click CYCLES.**\n\nThe first click on a tile moves the tile cursor and enters `TileSelect` without touching the document; cycling the axis needs a second click on the *already-selected* tile. `Enter`/`Space` keep cycling directly from the keyboard.\n\n**What it buys.** Every click in the UI is now non-mutating by default. That is a property of the whole pointer story, not a tile-specific concession, and the strongest version of the argument is a consequence the ruling was made without: a tile cycle is an `EffectKind::Model(LayoutGeometry)` change, so it bumps `App::cursor_epoch`, which `Sessions::adopt_cursor_epoch` broadcasts as `Command::ResetCursor` **to every connected client** (gotcha 24). A single stray click therefore used to cost every other viewer their cursor position. The pointer is the easiest input to trigger by accident, and it was wired to the one class of change that reaches other people.\n\n**What it cost, accepted knowingly.** Two clicks for the common action, and a **deliberate divergence from the keyboard**, where `Enter` cycles in one keystroke. Record it as a divergence chosen on purpose, not an inconsistency to tidy later — otherwise someone reads the keymap and \"fixes\" the pointer to match, which silently reinstates the broadcast-on-accident.\n\n**The implementation detail that is easy to get wrong.** \"Already selected\" must be `mode == TileSelect` **and** `tile_cat_idx == index`. The index alone is not enough: `tile_cat_idx` is 0 before anything has selected anything, so an index-only test reads tile 0 as already selected and cycles it on its very first click — the guarantee then holds for every tile but one. And \"second click\" must mean *on the already-selected tile*, not merely the second click of a session, or clicking A then B cycles B and the guarantee holds only for a session's first click. Both are asserted (`the_first_click_on_tile_zero_selects_it_like_any_other`, `clicking_a_different_tile_selects_it_rather_than_cycling_it`).\n\nEvidence discipline: the non-mutation is asserted **positively as well as negatively** — a test saying \"the axis did not change\" passes against a page where nothing happened at all. The fixture publishes a basis deliberately, so a missing `Command::Model` is a decision rather than a client that could not compose one.\n","created_at":"2026-09-20T20:41:14Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-nynk","title":"adopt_cursor_epoch resets every client's cursor, including clients on a view nothing reshaped","description":"Found by the adversarial review of PR #49, demonstrated AND bisected. This is\nimprovise-jytm's regression, not PR #49's -- but #49 widens it.\n\nimprovise-jytm (#48) made \"which view a client is on\" per-client: apply_switch\nmoves only the asker's mirror. `Sessions::adopt_cursor_epoch`\n(ui/project.rs:904-912) was not updated and still resets EVERY mirror and returns\n`Broadcast::ToAll(Command::ResetCursor)`. So a reshape of view A throws away the\ncursor of a client sitting on view B, which nothing reshaped.\n\nTHE BISECT IS THE LOAD-BEARING PART:\n - at a3bef6e (main, post-#48) with HideItem -\u003e PASSES\n - at a3bef6e (main, post-#48) with TransposeAxes -\u003e FAILS\nSo the hole is already in main. What PR #49 (improvise-9mo9) changes is the\nNUMBER OF EFFECTS THAT REACH IT: from 2 (TransposeAxes, CycleAxis) to 12.\n\nTHE FIX IS SMALL AND THE TYPE ALREADY EXISTS. `Broadcast::To(SessionId, Command)`\nis there for exactly this shape (improvise-6c6q, improvise-jytm). adopt_cursor_epoch\nshould reset only the mirrors whose `active_view` is the view that reshaped, and\nreturn one `To(id, ResetCursor)` per affected client instead of one ToAll. Note\nthe return type becomes Vec\u003cBroadcast\u003e rather than Option\u003cBroadcast\u003e.\n\nWHY IT WAS NOT FIXED IN PR #49: changing ToAll -\u003e To for cursor invalidation is a\ndecision about broadcast semantics in a design that landed hours earlier, and\ngotcha 36 records a deliberate argument for ToAll here (\"a consequence the asker\nis owed like everybody else\"). That argument is still right for a client on the\nRESHAPED view and wrong for everyone else, which is a distinction #48 created and\nnobody has yet written down. It wants jytm's owner, not a review fix-up.\n\nimprovise-ni6k answers it for free: a per-view grid-frame fingerprint is\nper-view by construction.\n\n## Steps to Reproduce\n\n1. Two sessions on one document.\n2. Bystander sends Command::SwitchView(\"Second\"), then SetRowOffset(2).\n3. Asker sends ModelCommand::HideItem (or TransposeAxes, on main) against the\n host's \"Default\" view.\n4. The bystander's row_offset goes to 0 and the broadcast is\n [ToAll(Command::ResetCursor)] -- for a view the bystander is not on and that\n nothing reshaped.","acceptance_criteria":"adopt_cursor_epoch resets only the mirrors whose active_view is the view that reshaped, and addresses the relay per client with Broadcast::To rather than ToAll. The regression guard is a two-session test where the bystander is on a DIFFERENT view from the asker and keeps its offset -- and it must also keep the existing positive arm, where a bystander on the SAME view is still reset and still told, or 'never invalidate anyone' passes it. Note the bisect: this reproduces at a3bef6e with TransposeAxes, so it is not PR #49's to fix, but #49 takes it from 2 effects to 12.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:05Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T19:17:05Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pdvm","title":"AddItem/RemoveItem reshape the grid but declare CellData, so layout_version and cursor_epoch disagree","description":"Found in the adversarial review of PR #49 (improvise-9mo9). PRE-EXISTING, but\nthat PR is the first thing in the repo to produce evidence for it.\n\n`AddItem`, `AddItemInGroup` and `RemoveItem` declare\n`EffectKind::Model(ModelChange::CellData)`. `App::apply_effects` bumps\n`layout_version` only for `ModelChange::LayoutGeometry`, so these three do not\nmove it. Since improvise-9mo9 they DO call `app.reset_cursor()`, which bumps\n`cursor_epoch`.\n\nSo one event moves one counter and not the other, and the two counters are\nanswering the same question: \"has the grid's shape changed under you\". A client\nis told its cursor is invalid (`Command::ResetCursor`) while its `layout_version`\nbasis tier still reads current, so a batch composed against the old layout is\naccepted.\n\nTHE EVIDENCE IS THIS PR'S OWN TEST. `every_reshaping_model_command_invalidates_\nevery_cursor` now asserts, with a `grid_shape()` guard over `row_items`/\n`col_items`, that `AddItem`/`AddItemInGroup`/`RemoveItem` REALLY DO reshape the\ngrid. That is the assertion `ModelChange::CellData` contradicts.\n\nWHY IT IS NOT OBVIOUSLY \"RECLASSIFY THEM\". `EffectKind` is read by three\nconsumers off one classification (gotcha 40): `Dirty::of`, `layout_version`, and\n`model_state.dirty`. Moving these three to `LayoutGeometry` changes what every\nclient is told is dirty and widens the basis rejection surface. It also interacts\nwith gotcha 3's traps -- `AddCategory`/`RemoveCategory` are already the\n\"looks like data, is geometry\" case -- and with the `ModelCommand` derivation.\n\nRELATED, NOT DUPLICATE: improvise-75ld is the same family in the other direction\n(a refused or no-op AddCategory bumps layout_version and raises dirty when\nnothing happened). Decide them together; a single question -- \"what does an\nEffectKind entitle a counter to assume\" -- is under both.\n\n## Steps to Reproduce\n\n1. On a view with a category on Row, apply `ModelCommand::AddItem` for a new item\n in that category.\n2. `grid_shape()` (or `layout.row_count()`) changes: a row was inserted.\n3. `app.cursor_epoch()` has moved and clients receive `Command::ResetCursor`.\n4. `app.layout_version()` has NOT moved, because the effect declares CellData.\n5. A client may now compose a `Command::Model` whose `Basis.layout_version` is\n the pre-change value and have it accepted.","acceptance_criteria":"One event moves both counters or neither. Either AddItem/AddItemInGroup/RemoveItem are reclassified as LayoutGeometry -- in which case the blast radius through Dirty::of, the basis rejection surface and gotcha 3's traps is argued, not assumed -- or layout_version stops being read off EffectKind and is observed the way cursor_epoch and record_versions already are. Decide alongside improvise-75ld, which is the same question in the other direction, and alongside improvise-ni6k, whose grid-frame fingerprint would answer all three at once. The regression guard must assert the two counters agree over the same table every_reshaping_model_command_invalidates_every_cursor already drives, so it cannot pass by naming nothing.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:57:17Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T18:57:17Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nynk","title":"adopt_cursor_epoch resets every client's cursor, including clients on a view nothing reshaped","description":"Found by the adversarial review of PR #49, demonstrated AND bisected. This is\nimprovise-jytm's regression, not PR #49's -- but #49 widens it.\n\nimprovise-jytm (#48) made \"which view a client is on\" per-client: apply_switch\nmoves only the asker's mirror. `Sessions::adopt_cursor_epoch`\n(ui/project.rs:904-912) was not updated and still resets EVERY mirror and returns\n`Broadcast::ToAll(Command::ResetCursor)`. So a reshape of view A throws away the\ncursor of a client sitting on view B, which nothing reshaped.\n\nTHE BISECT IS THE LOAD-BEARING PART:\n - at a3bef6e (main, post-#48) with HideItem -\u003e PASSES\n - at a3bef6e (main, post-#48) with TransposeAxes -\u003e FAILS\nSo the hole is already in main. What PR #49 (improvise-9mo9) changes is the\nNUMBER OF EFFECTS THAT REACH IT: from 2 (TransposeAxes, CycleAxis) to 12.\n\nTHE FIX IS SMALL AND THE TYPE ALREADY EXISTS. `Broadcast::To(SessionId, Command)`\nis there for exactly this shape (improvise-6c6q, improvise-jytm). adopt_cursor_epoch\nshould reset only the mirrors whose `active_view` is the view that reshaped, and\nreturn one `To(id, ResetCursor)` per affected client instead of one ToAll. Note\nthe return type becomes Vec\u003cBroadcast\u003e rather than Option\u003cBroadcast\u003e.\n\nWHY IT WAS NOT FIXED IN PR #49: changing ToAll -\u003e To for cursor invalidation is a\ndecision about broadcast semantics in a design that landed hours earlier, and\ngotcha 36 records a deliberate argument for ToAll here (\"a consequence the asker\nis owed like everybody else\"). That argument is still right for a client on the\nRESHAPED view and wrong for everyone else, which is a distinction #48 created and\nnobody has yet written down. It wants jytm's owner, not a review fix-up.\n\nimprovise-ni6k answers it for free: a per-view grid-frame fingerprint is\nper-view by construction.\n\n## Steps to Reproduce\n\n1. Two sessions on one document.\n2. Bystander sends Command::SwitchView(\"Second\"), then SetRowOffset(2).\n3. Asker sends ModelCommand::HideItem (or TransposeAxes, on main) against the\n host's \"Default\" view.\n4. The bystander's row_offset goes to 0 and the broadcast is\n [ToAll(Command::ResetCursor)] -- for a view the bystander is not on and that\n nothing reshaped.","acceptance_criteria":"adopt_cursor_epoch resets only the mirrors whose active_view is the view that reshaped, and addresses the relay per client with Broadcast::To rather than ToAll. The regression guard is a two-session test where the bystander is on a DIFFERENT view from the asker and keeps its offset -- and it must also keep the existing positive arm, where a bystander on the SAME view is still reset and still told, or 'never invalidate anyone' passes it. Note the bisect: this reproduces at a3bef6e with TransposeAxes, so it is not PR #49's to fix, but #49 takes it from 2 effects to 12.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:05Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T23:59:39Z","closed_at":"2026-09-20T23:59:39Z","close_reason":"Fixed in PR #70 (14757a7), reviewed and merged. layout_version and dirty moved from declared EffectKind to observed change; adopt_cursor_epoch narrowed to the reshaped views with an is-empty fail-safe; Panel-\u003efield consolidated onto ViewState accessors.","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pdvm","title":"AddItem/RemoveItem reshape the grid but declare CellData, so layout_version and cursor_epoch disagree","description":"Found in the adversarial review of PR #49 (improvise-9mo9). PRE-EXISTING, but\nthat PR is the first thing in the repo to produce evidence for it.\n\n`AddItem`, `AddItemInGroup` and `RemoveItem` declare\n`EffectKind::Model(ModelChange::CellData)`. `App::apply_effects` bumps\n`layout_version` only for `ModelChange::LayoutGeometry`, so these three do not\nmove it. Since improvise-9mo9 they DO call `app.reset_cursor()`, which bumps\n`cursor_epoch`.\n\nSo one event moves one counter and not the other, and the two counters are\nanswering the same question: \"has the grid's shape changed under you\". A client\nis told its cursor is invalid (`Command::ResetCursor`) while its `layout_version`\nbasis tier still reads current, so a batch composed against the old layout is\naccepted.\n\nTHE EVIDENCE IS THIS PR'S OWN TEST. `every_reshaping_model_command_invalidates_\nevery_cursor` now asserts, with a `grid_shape()` guard over `row_items`/\n`col_items`, that `AddItem`/`AddItemInGroup`/`RemoveItem` REALLY DO reshape the\ngrid. That is the assertion `ModelChange::CellData` contradicts.\n\nWHY IT IS NOT OBVIOUSLY \"RECLASSIFY THEM\". `EffectKind` is read by three\nconsumers off one classification (gotcha 40): `Dirty::of`, `layout_version`, and\n`model_state.dirty`. Moving these three to `LayoutGeometry` changes what every\nclient is told is dirty and widens the basis rejection surface. It also interacts\nwith gotcha 3's traps -- `AddCategory`/`RemoveCategory` are already the\n\"looks like data, is geometry\" case -- and with the `ModelCommand` derivation.\n\nRELATED, NOT DUPLICATE: improvise-75ld is the same family in the other direction\n(a refused or no-op AddCategory bumps layout_version and raises dirty when\nnothing happened). Decide them together; a single question -- \"what does an\nEffectKind entitle a counter to assume\" -- is under both.\n\n## Steps to Reproduce\n\n1. On a view with a category on Row, apply `ModelCommand::AddItem` for a new item\n in that category.\n2. `grid_shape()` (or `layout.row_count()`) changes: a row was inserted.\n3. `app.cursor_epoch()` has moved and clients receive `Command::ResetCursor`.\n4. `app.layout_version()` has NOT moved, because the effect declares CellData.\n5. A client may now compose a `Command::Model` whose `Basis.layout_version` is\n the pre-change value and have it accepted.","acceptance_criteria":"One event moves both counters or neither. Either AddItem/AddItemInGroup/RemoveItem are reclassified as LayoutGeometry -- in which case the blast radius through Dirty::of, the basis rejection surface and gotcha 3's traps is argued, not assumed -- or layout_version stops being read off EffectKind and is observed the way cursor_epoch and record_versions already are. Decide alongside improvise-75ld, which is the same question in the other direction, and alongside improvise-ni6k, whose grid-frame fingerprint would answer all three at once. The regression guard must assert the two counters agree over the same table every_reshaping_model_command_invalidates_every_cursor already drives, so it cannot pass by naming nothing.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:57:17Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T23:59:37Z","closed_at":"2026-09-20T23:59:37Z","close_reason":"Fixed in PR #70 (14757a7), reviewed and merged. layout_version and dirty moved from declared EffectKind to observed change; adopt_cursor_epoch narrowed to the reshaped views with an is-empty fail-safe; Panel-\u003efield consolidated onto ViewState accessors.","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-o31u","title":"The landing page's casts render at a 4.8px font on a phone, and it is now the site root","description":"Found in adversarial review of PR #47, measured rather than eyeballed, and\nreproduced independently before filing.\n\nOn a 390px viewport all four asciinema players on `docs/index.html` compute a\nterminal `font-size` of **4.83px**. The arithmetic is not subtle: the player is\ntold `cols: 120`, the container is ~356px wide inside the page's 16px gutters,\nand 356 / 120 gives about 3px per character, so the player scales the font down\nto 4.83px to fit. Desktop is **13.11px** and perfectly fine, so this is purely a\nnarrow-viewport effect and nothing about the current desktop rendering needs to\nchange.\n\nWHY THE PRIORITY IS HIGHER THAN IT LOOKS: until the user's ruling on\nimprovise-kh8 the landing page sat at `/landing/` and this was a page few people\nwould reach on a phone. It is now the SITE ROOT — the first thing a phone\nvisitor sees is four panels of unreadable grey mush above the call to action.\n\nPROPOSED FIX (the reviewer's, and it is the right shape): stop asking the player\nto fit 120 columns into the viewport and let it overflow instead —\n`overflow-x: auto` on `.player`, plus either `fit: false` or an explicit\n`terminalFontSize` in the `AsciinemaPlayer.create()` options, so the font is\nchosen rather than derived from the width.\n\nONE THING TO CHECK WHEN FIXING, because getting it wrong trades this bug for a\nworse one: the overflow must be contained INSIDE `.player`. A fix that lets the\npage itself scroll sideways breaks the whole document on a phone rather than one\nsection of it. The check is that `document.documentElement.scrollWidth` still\nequals `clientWidth` at 390px afterwards.\n\nNOTE A CANDIDATE that would close this for free: improvise-2oxt re-records the\ncasts. If they are re-recorded at a narrower geometry than 120 columns, the\narithmetic changes by itself — so whoever takes 2oxt should decide the geometry\nwith this ticket in front of them rather than after.\n\nA fix was written and measured during PR #47 (4.83px -\u003e 11.95px via a\n`max-width: 900px` media query giving `.player` `overflow-x: auto` and an 880px\nmin-width, page scrollWidth unchanged at 390) and then REVERTED on the user's\nruling that this should be filed rather than fixed in that PR. The measurements\nare evidence that the approach works; the code is not in the tree.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/` in a browser sized to 390x844 (or Playwright\n with `viewport: { width: 390, height: 844 }`).\n4. Press play on any of the four players and read the computed style of\n `.ap-terminal`: `font-size` is 4.83px. At 1280px wide it is 13.11px.","acceptance_criteria":"At a 390px viewport every player on docs/index.html renders its terminal at a legible font size (\u003e= 10px measured as the computed font-size of .ap-terminal), and document.documentElement.scrollWidth still equals clientWidth so the page itself does not scroll sideways. Desktop rendering at 1280px is unchanged. Verified by measuring both viewports against the assembled site, not against docs/ directly.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:32Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jmjz","title":"grid_of evaluates the whole grid three times per frame; compute_col_widths is ~45% of it","description":"Found by improvise-i5tk's benchmark (`cargo bench --bench grid_projection`), which\nwas run to decide a *different* question and answered this one instead.\n\nMEASURED, on this machine, one `project::grid_of` call = one TUI frame's grid cache:\n\n typical 100 x 4 = 400 cells whole-grid 1.902ms col_widths 45%\n large 1200 x 8 = 9600 cells whole-grid 185.644ms col_widths 64%\n 18k cells (gotcha 41) 3000 x 6 = 18000 cells whole-grid 402.435ms col_widths 42%\n pathological 9000 x 10 = 90000 cells whole-grid 5.235s col_widths 44%\n\nA 60fps frame is 16.7ms. At 18,000 cells -- the size gotcha 41 already treats as\nrealistic -- a TUI redraw takes about four tenths of a second, and the TUI redraws\non every keypress.\n\nWHY: the whole grid is evaluated roughly THREE TIMES per frame.\n\n1. `ui/viewport.rs::compute_col_widths` walks every (row, col) calling\n `layout.display_text`, to size the columns.\n2. The SAME function then walks every (row, col) AGAIN summing column totals, to\n size the total row.\n3. `project::grid_of` then evaluates the visible cells via `cell_display`, and\n `column_totals` sums every row again for each visible column.\n\nEvery one of those goes through `Model::evaluate_aggregated`, and every result is\nthrown away at the end of the frame.\n\nWHAT THIS IS NOT: it is not the viewport question. improvise-i5tk asked whether\nnarrowing the TUI's `GridCache` to its viewport would help, and the benchmark says\nit buys only 1.2-1.5x, because passes 1 and 2 are viewport-INDEPENDENT by\nconstruction -- `compute_col_widths` takes no viewport and a column total must sum\nevery row including scrolled-past ones (gotcha 44). So narrowing removes part of\none pass out of three. The fix here is caching or a single fused pass, not windowing.\n\n## Steps to Reproduce\n\n1. `cargo bench --bench grid_projection` on this branch.\n2. Read the `whole-grid` column: 402ms for 18,000 cells, against a 16.7ms frame.\n3. Read the `ratio` column: windowing the cache buys 1.5x, so the cost is not in\n the part a viewport would narrow.\n4. Read the `col_widths` column: one function, taking two whole-grid passes of its\n own, is 42-64% of the frame.","acceptance_criteria":"A frame's grid cache evaluates each cell at most once. The benchmark in benches/grid_projection.rs is the evidence both ways: it must show the whole-grid figure dropping by roughly the number of redundant passes removed, and it must still be run AFTER the change rather than the change argued from the structure. Note what the measurement already rules out: windowing the cache to a viewport is not the fix (1.2-1.5x measured, improvise-i5tk), because compute_col_widths takes no viewport and a column total must sum every row (gotcha 44). Do not lose the totals' whole-grid correctness while removing the whole-grid cost.","notes":"SECOND RUN 2026-09-20, same machine. The col_widths share is stable at 45-46%, not 42-64%: the single 64% in the first run's 'large' row was noise. whole-grid 1.9ms / 190ms / 327ms / 5.0s; ratio 1.2-1.5x throughout. The two figures the decision rests on -- ratio ~1.4x and col_widths ~half the frame -- reproduce; the per-row percentages do not to better than a few points. Quote ranges, not single runs.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:57Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T18:18:24Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","notes":"THE TRIGGER, added after the adversarial review of PR #48 (2026-09-20). This is deferrable, but it is deferred on a CONDITION rather than on a judgement, and the condition is one line of somebody else's future work away. It is unreachable today ONLY because no shipped client emits Command::SwitchView. That is the whole of the argument. The browser already emits view-configuration model commands -- keys.rs resolves a keypress to ModelCommand::TransposeAxes locally and sends it -- so the machinery on the other side of the ambiguity is live. THE DAY ANY WAVE WIRES A VIEW SWITCH INTO THE BROWSER, THIS GOES LIVE ON THE NEXT TRANSPOSE KEYSTROKE, AND SILENTLY: a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does. Two consequences. (1) Do not close this by arguing it is unreachable -- reachability is the thing that is about to change, and gotcha 47 already records that 'unreachable today' was the argument under which the lost update CheckedBatch later fixed had shipped. (2) There is a cheap interim guard worth landing before the real fix: assert that no shipped browser client emits Command::SwitchView, a test over improvise-wasm-client's key resolver. It turns the silent failure into a loud one at exactly the moment the premise stops holding, which is the only moment anybody would otherwise not be looking.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:34:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-zkkw","title":"The browser shell is only ever run on Chromium, but improvise-bck's criterion says Firefox too","description":"Found walking improvise-bck's acceptance criteria in wave 2. Criterion (1) is\n\"all test plan steps pass in CHROME AND FIREFOX\", and bck's own grooming note\nrecords the standalone deployment as demonstrated by harness/. Both are true of\nChromium and neither is true of Firefox.\n\n`harness/playwright.config.js` declares three projects, and only the chromium\none has no `testMatch`. Firefox and WebKit both carry\n`testMatch: /opfs\\.spec\\.js/`, so `shell.spec.js` — every assertion about\nweb/app.js, including \"the page draws the demo document out of the worker\" and\n\"committing an edit sends one frame and shows the host's value\" — runs on\nChromium alone. The config's own comment says so (\"The shell spec is\nChromium-only\"), and the stated reason is that `routeWebSocket` is slower and\nflakier across three engines.\n\nTHAT REASON APPLIES TO FOUR OF THE SIX SPECS, NOT SIX. The four `?ws=` specs\nmock a peer with `routeWebSocket`. The two under \"the standalone deployment\"\ndo not: they load `/index.html` with no query and drive the in-page worker over\nOPFS — which is precisely the deployment bck's criterion is about, and\nprecisely the code Firefox exercises differently (its OPFS implementation is\nalready run by opfs.spec.js, so the engine is known to support the path).\n\nSo the cheap and honest change is to let the standalone describe block run on\nFirefox while leaving the routeWebSocket block Chromium-only. That is a\n`testMatch`/grep split rather than a third copy of the spec.\n\nWHAT MUST NOT HAPPEN: turning the whole file on for Firefox and then quieting\nwhatever flakes. The flakiness argument is about the mocked-socket specs and is\nrecorded; widening it to the standalone ones would be adopting a reason that\nwas never given for them.\n\n## Steps to Reproduce\n\n1. `grep -n testMatch harness/playwright.config.js` — firefox and webkit are\n both pinned to `opfs.spec.js`.\n2. `cd harness \u0026\u0026 npx playwright test --project=firefox --list` — no test from\n `shell.spec.js` is listed.\n3. improvise-bck criterion (1) nevertheless reads \"in Chrome and Firefox\".","acceptance_criteria":"harness/tests/shell.spec.js's 'the standalone deployment' describe block runs on the firefox project as well as chromium, and passes there in CI; the four routeWebSocket specs stay Chromium-only with the existing reason still written down. Verified by 'npx playwright test --project=firefox --list' naming the two standalone tests, and by a green browser job. improvise-bck criterion (1) is then re-checked against what actually runs.","notes":"CORRECTION, same session: this bead as first written read as though nobody had\ndecided the Chromium-only question. Somebody had.\n`invariant(opfs_is_run_but_bindings_is_not)` in prolog-sessions/kb.pl records\nit, and gives TWO reasons, not one:\n\n \"harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY --\n engine-independent logic, and routeWebSocket is slower across three.\"\n\nThe description above only answers the second. The first — the logic is\nengine-independent — is a general claim covering all six specs, and against\nthe specs as a test of web/app.js it is correct: app.js is plain DOM and\nplain event wiring, and running the same assertions on a second engine buys\nvery little.\n\nWHAT IT DOES NOT COVER is the claim improvise-bck criterion (1) actually\nmakes. That criterion is not \"app.js's logic works in Firefox\", it is \"the\nstandalone deployment works in Firefox\" — and the standalone deployment is\nmodule workers, `wasm-pack` output, `createSyncAccessHandle` through\nbindings.rs and the seed fetch, none of which is app.js's logic and all of\nwhich is engine-specific. opfs.spec.js covers Firefox's OPFS through\n`OpfsFs` directly and, per the same invariant, NEVER INSTANTIATES\nbindings.rs. So the two standalone specs on Firefox would be the first\nexecution of that shim on a second engine, and near enough the first on any.\n\nSo the disagreement with the invariant is narrow and should be argued on\nthose terms: the engine-independence reason is a statement about app.js, and\nthe two standalone specs are not only a test of app.js. If that argument is\nrejected, the right outcome is to REWRITE improvise-bck criterion (1) to say\nChromium, not to leave a criterion standing that names an engine nothing\nruns on — a criterion nobody checks is the failure mode this whole pass\nexists to catch.\n\nEither way the invariant's wording is owed an update, since it currently\nreads as a blanket justification for a split that has two different reasons\nbehind its two halves.\nCORRECTION — MY OWN PREMISE WAS WRONG, found in adversarial review of PR #47.\n\nThe note above says the two standalone specs on Firefox \"would be the first\nexecution of that shim on a second engine, and near enough the first on any\".\nThe second half is false. `bindings.rs` HAS been executed, on Chromium, since\nimprovise-vvl3 landed the harness. The chain is unbroken and each link is\ncheckable:\n\n /app/index.html with no ?ws= -\u003e app.js `new Worker(\"./worker.js\")`\n worker.js `import { start } from \"./worker-pkg/improvise_worker_server.js\"`\n bindings.rs `pub async fn start()` -\u003e `boot()` -\u003e `OpfsFs::open().await`\n\nand `harness/tests/shell.spec.js`'s own comment on the standalone test has said\nso all along: \"If this draws a grid, worker.js, bindings.rs, OpfsFs and the seed\nfetch all ran.\"\n\nI RAN THAT SPEC GREEN AT 2ccd21f while verifying the /app/ move, and watched the\nbrowser fetch /app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm\nand draw the grid with #status reading \"in-page worker\". The evidence was in my\nhand and I still repeated the claim, because I took it from kb.pl and the repo\nmap instead of from the run.\n\nWHAT SURVIVES: the argument for running the two standalone specs on Firefox is\nUNAFFECTED and is still the point of this ticket. Firefox would be a genuine\nsecond engine for the shim, and improvise-bck's criterion (1) still names an\nengine nothing runs. Only the \"first on any engine\" rhetoric goes.\n\nFIVE PLACES STATE THE FALSE VERSION and want one grep, not five edits:\nkb.pl:11, kb.pl:105 (invariant opfs_is_run_but_bindings_is_not), repo-map.md:241,\nrepo-map.md:467, and improvise-bck's own NOT-DEMONSTRATED list. Those three files\nare orchestrator-owned; the bck half is corrected in bck's notes.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:19:44Z","labels":["area:epic"],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-zkkw","title":"The browser shell is only ever run on Chromium, but improvise-bck's criterion says Firefox too","description":"Found walking improvise-bck's acceptance criteria in wave 2. Criterion (1) is\n\"all test plan steps pass in CHROME AND FIREFOX\", and bck's own grooming note\nrecords the standalone deployment as demonstrated by harness/. Both are true of\nChromium and neither is true of Firefox.\n\n`harness/playwright.config.js` declares three projects, and only the chromium\none has no `testMatch`. Firefox and WebKit both carry\n`testMatch: /opfs\\.spec\\.js/`, so `shell.spec.js` — every assertion about\nweb/app.js, including \"the page draws the demo document out of the worker\" and\n\"committing an edit sends one frame and shows the host's value\" — runs on\nChromium alone. The config's own comment says so (\"The shell spec is\nChromium-only\"), and the stated reason is that `routeWebSocket` is slower and\nflakier across three engines.\n\nTHAT REASON APPLIES TO FOUR OF THE SIX SPECS, NOT SIX. The four `?ws=` specs\nmock a peer with `routeWebSocket`. The two under \"the standalone deployment\"\ndo not: they load `/index.html` with no query and drive the in-page worker over\nOPFS — which is precisely the deployment bck's criterion is about, and\nprecisely the code Firefox exercises differently (its OPFS implementation is\nalready run by opfs.spec.js, so the engine is known to support the path).\n\nSo the cheap and honest change is to let the standalone describe block run on\nFirefox while leaving the routeWebSocket block Chromium-only. That is a\n`testMatch`/grep split rather than a third copy of the spec.\n\nWHAT MUST NOT HAPPEN: turning the whole file on for Firefox and then quieting\nwhatever flakes. The flakiness argument is about the mocked-socket specs and is\nrecorded; widening it to the standalone ones would be adopting a reason that\nwas never given for them.\n\n## Steps to Reproduce\n\n1. `grep -n testMatch harness/playwright.config.js` — firefox and webkit are\n both pinned to `opfs.spec.js`.\n2. `cd harness \u0026\u0026 npx playwright test --project=firefox --list` — no test from\n `shell.spec.js` is listed.\n3. improvise-bck criterion (1) nevertheless reads \"in Chrome and Firefox\".","acceptance_criteria":"harness/tests/shell.spec.js's 'the standalone deployment' describe block runs on the firefox project as well as chromium, and passes there in CI; the four routeWebSocket specs stay Chromium-only with the existing reason still written down. Verified by 'npx playwright test --project=firefox --list' naming the two standalone tests, and by a green browser job. improvise-bck criterion (1) is then re-checked against what actually runs.","notes":"PR #72. Tagged @mocked-peer; firefox testMatch widened with grepInvert. Measured 2.0s for the standalone pair, 10/10 under --repeat-each=5.","status":"closed","priority":2,"issue_type":"task","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T00:24:06Z","started_at":"2026-09-20T23:21:57Z","closed_at":"2026-09-21T00:24:06Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:epic"],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-864a","title":"Concurrent merges to main race on one Pages environment and the superseded deploys fail","description":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","acceptance_criteria":"A run whose commit is no longer the tip of main skips actions/deploy-pages instead of failing; a burst of merges to main produces exactly one deploy attempt and no red X on the superseded runs.","notes":"CORRECTION 2026-09-20, from adversarial review of PR #46 — THE ORIGINAL DESCRIPTION'S PREMISE IS WRONG, and the fix built on it was closed unmerged.\n\nThe description says only the NEWEST run keeps its artifact. Re-checked against the API, it was the MIDDLE one:\n\n 04e4e3d (oldest) run 35526758561 artifacts=0 deploy=failure\n 5f871f7 (middle) run 35526767814 artifacts=1 deploy=SUCCESS\n 52507a6 (newest) run 35526774007 artifacts=0 deploy=failure \u003c- tip of main\n\nSo a 'skip unless this commit is the tip of main' guard would have skipped 5f871f7 — the one deploy that actually published — and let 52507a6 deploy and fail anyway. Pages never updates, main still red: strictly worse than the status quo. PR #46 closed on that evidence.\n\nThe guard also aimed at the wrong failure class: it reduces the NUMBER of deploys, while the observed error is a missing artifact within a run's OWN scope. Queue depth is not what makes an artifact survive.\n\nWHAT IS STILL UNKNOWN, and what any future fix must establish FIRST: why two of three artifacts were deleted when the upload step was green in all three. Note the surviving artifact belongs to the run whose deploy SUCCEEDED, which suggests the causation may run the other way (the deploys failed because their artifacts were already gone, not the reverse). Do not ship another guard until that is measured — the first one was built on a misread table and review caught it, which is the only reason it did not land.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:55:01Z","created_by":"orchestrator","updated_at":"2026-09-20T18:03:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-fdt6","title":"A browser cannot add a formula, so FormulaEdit is a room that swallows typing","description":"Found while grooming improvise-bck after wave 1. It is the one acceptance criterion of the standalone web MVP that is not a verification gap but a MISSING FEATURE.\n\nimprovise-bck says: \"load a bundled demo .improv file, EDIT CELLS, ADD A FORMULA, save to OPFS, reload the page, verify persistence\". Everything in that sentence is now demonstrated by harness/ except adding a formula, because THE BROWSER HAS NO FORMULA ENTRY AT ALL.\n\nWHAT EXISTS: the formula PANEL has a projection (FormulaCache) and the browser receives it. What does not exist is any way to compose one — `commit-buffer formula` is in the hand-written list of text-entry commits the client cannot resolve (see improvise-e24v and the audit on improvise-g9jb), so FormulaEdit is a mode a browser can ENTER and not COMMIT. That is the dead-room shape doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key) names, and `strands_the_user` currently refuses SavePrompt and ExportPrompt but NOT FormulaEdit.\n\nSO THERE ARE TWO HONEST OUTCOMES AND THEY SHOULD BE DECIDED TOGETHER:\n 1. Implement `commit-buffer formula` client-side. All its operands are in ViewState (improvise-e24v lists it under \"needs nothing added to the protocol\"), so this is the cheap half — and then the browser can add a formula and improvise-bck's criterion is met as written.\n 2. Or refuse FormulaEdit in `strands_the_user` until (1) lands, so the browser stops offering a room that swallows typing — and improvise-bck's criterion is amended to say formulas are a TUI-only affordance for now.\n\nDOING NEITHER IS THE CURRENT STATE AND IT IS THE WORST OF THE THREE: the mode opens, the user types a formula, and nothing happens.\n\nNOTE FOR WHOEVER TAKES IT: the emitted formula must go through the same add_formula path as a hand-typed one, so _Measure target handling stays identical (gotcha 7) — the same constraint improvise-vx3 worked under. And parse_formula strips pipes off a target (gotcha 34), so the client must not pre-quote.\n\n## Steps to Reproduce\n\n1. Open the document in the browser and press `F` (or `:enter-mode formula-edit`).\n2. The mode opens — `enter-mode` resolves locally in the wasm client.\n3. Type a formula and press Enter. `commit-buffer formula` has no arm in `keys.rs::step`, so `resolve` returns `Upstream`, and `Host::handle` answers `ClientFrame::Key` with an explicit empty vec.\n4. Nothing happens. The typing is gone and no error is shown.\n\n`strands_the_user` refuses `SavePrompt` and `ExportPrompt` for exactly this shape and does not refuse `FormulaEdit`.\n","acceptance_criteria":"Either commit-buffer formula resolves client-side and a browser can add a formula end to end (demonstrated in harness/), or FormulaEdit is refused by strands_the_user with improvise-bck's criterion amended to say formulas are TUI-only for now. The current state — enterable, uncommittable, silent — is not an acceptable outcome. Whichever is chosen, the audit list in improvise-g9jb and the named gap list in keys.rs move with it.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:55Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-fdt6","depends_on_id":"improvise-e24v","type":"relates-to","created_at":"2026-09-20T07:55:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-fdt6","title":"A browser cannot add a formula, so FormulaEdit is a room that swallows typing","description":"Found while grooming improvise-bck after wave 1. It is the one acceptance criterion of the standalone web MVP that is not a verification gap but a MISSING FEATURE.\n\nimprovise-bck says: \"load a bundled demo .improv file, EDIT CELLS, ADD A FORMULA, save to OPFS, reload the page, verify persistence\". Everything in that sentence is now demonstrated by harness/ except adding a formula, because THE BROWSER HAS NO FORMULA ENTRY AT ALL.\n\nWHAT EXISTS: the formula PANEL has a projection (FormulaCache) and the browser receives it. What does not exist is any way to compose one — `commit-buffer formula` is in the hand-written list of text-entry commits the client cannot resolve (see improvise-e24v and the audit on improvise-g9jb), so FormulaEdit is a mode a browser can ENTER and not COMMIT. That is the dead-room shape doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key) names, and `strands_the_user` currently refuses SavePrompt and ExportPrompt but NOT FormulaEdit.\n\nSO THERE ARE TWO HONEST OUTCOMES AND THEY SHOULD BE DECIDED TOGETHER:\n 1. Implement `commit-buffer formula` client-side. All its operands are in ViewState (improvise-e24v lists it under \"needs nothing added to the protocol\"), so this is the cheap half — and then the browser can add a formula and improvise-bck's criterion is met as written.\n 2. Or refuse FormulaEdit in `strands_the_user` until (1) lands, so the browser stops offering a room that swallows typing — and improvise-bck's criterion is amended to say formulas are a TUI-only affordance for now.\n\nDOING NEITHER IS THE CURRENT STATE AND IT IS THE WORST OF THE THREE: the mode opens, the user types a formula, and nothing happens.\n\nNOTE FOR WHOEVER TAKES IT: the emitted formula must go through the same add_formula path as a hand-typed one, so _Measure target handling stays identical (gotcha 7) — the same constraint improvise-vx3 worked under. And parse_formula strips pipes off a target (gotcha 34), so the client must not pre-quote.\n\n## Steps to Reproduce\n\n1. Open the document in the browser and press `F` (or `:enter-mode formula-edit`).\n2. The mode opens — `enter-mode` resolves locally in the wasm client.\n3. Type a formula and press Enter. `commit-buffer formula` has no arm in `keys.rs::step`, so `resolve` returns `Upstream`, and `Host::handle` answers `ClientFrame::Key` with an explicit empty vec.\n4. Nothing happens. The typing is gone and no error is shown.\n\n`strands_the_user` refuses `SavePrompt` and `ExportPrompt` for exactly this shape and does not refuse `FormulaEdit`.\n","acceptance_criteria":"Either commit-buffer formula resolves client-side and a browser can add a formula end to end (demonstrated in harness/), or FormulaEdit is refused by strands_the_user with improvise-bck's criterion amended to say formulas are TUI-only for now. The current state — enterable, uncommittable, silent — is not an acceptable outcome. Whichever is chosen, the audit list in improvise-g9jb and the named gap list in keys.rs move with it.","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-wasm-keys-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-21T00:24:03Z","started_at":"2026-09-20T23:21:15Z","closed_at":"2026-09-21T00:24:03Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-fdt6","depends_on_id":"improvise-e24v","type":"relates-to","created_at":"2026-09-20T07:55:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-8qlr","title":"Column-group run coalescing is derived twice, and the agreement test does not pin the TUI arm","description":"Found by the post-wave code consistency review, CONFIRMED BY INJECTION.\n\nColumn-group RUN COALESCING is derived in two places:\n * src/ui/grid.rs:189-192, coalescing on `group_name != prev_group`\n * crates/improvise-wasm-client/src/render.rs:339-364 (`col_groups`), coalescing by `label` alone — with a comment saying \"which is what src/ui/grid.rs does ... and the two must agree\"\n\nA comment is what keeps them in step. AxisFrame already owns `groups()`; a `group_runs()` there would make this one rule instead of two.\n\n## Steps to Reproduce\n\n1. In src/ui/grid.rs, force the TUI to emit the group label on EVERY column (`match (true, col_group)`) — a visibly different grid.\n2. `cargo test -p improvise` stays GREEN.\n\n`tests/grid_frame_agreement.rs::the_two_front_ends_draw_the_same_column_header` compares the BROWSER's spans against a hard-coded `vec![(Some(\"Q1\"), 6)]` but only checks the TUI's group line with `group_line.contains(\"▼ Q1\")`. That `contains` is the unpinned arm.\n\n## Note\n\nThe rest of that test file is exemplary — it drives each front end the way it is really driven and records its injected-fault evidence. This is its one weak assertion, and it is weak in the direction that matters: the side the migration could most easily have broken.\n","acceptance_criteria":"The run rule lives once, on AxisFrame (a group_runs() beside groups()), and both front ends call it. the_two_front_ends_draw_the_same_column_header compares the TUI's spans EXACTLY rather than with contains(), so forcing the TUI to emit a group label on every column fails it — verify by injecting that fault.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:43Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8uj8","title":"every_rejection_message_says_the_edit_did_not_land covers five of seven variants","description":"Found by the post-wave code consistency review, CONFIRMED BY INJECTION.\n\ncrates/improvise-wasm-client/src/socket.rs:273-288. The test is named `every_rejection_message_says_the_edit_did_not_land` and its doc says the fact \"is asserted over all of them rather than one at a time\". It drives a HAND-WRITTEN LIST of Rejection variants, and that list predates PR #26, which added `NoDocumentPath` and `SaveFailed`.\n\n## Steps to Reproduce\n\n1. Add `NoDocumentPath` and `SaveFailed` to the test's list.\n2. It FAILS: \"... which does not say the edit did not land\".\n\nSo two variants were silently exempted from a rule the test claims is universal, rather than argued out of it.\n\n## The nuance that makes this a re-scope rather than a message fix\n\nThe messages themselves are defensible — a save failure did not fail to APPLY anything, so \"the edit did not land\" is the wrong sentence for it. But then the TEST'S CLAIM is what is wrong, and a test asserting something false about its own subject is worse than no test.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) names this exactly.\n","acceptance_criteria":"The test is driven by an EXHAUSTIVE match over Rejection so a new variant cannot slip, and its claim matches what it checks — either every variant really does say the edit did not land, or the test is re-scoped to the subset for which that sentence is correct and says which and why. Adding a variant to Rejection fails the build until someone answers for it.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:42Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-75ld","title":"A refused or no-op AddCategory still bumps layout_version and raises dirty","description":"Found by the post-wave code consistency review, CONFIRMED BY EXECUTION against a real App.\n\nApp::apply_effects (crates/improvise-command/src/ui/app.rs:637-650) runs `effect.apply(); dirty.union(of(kind)); dirty = true; layout_version += 1;` and ONLY THEN checks abort_effects. So the aborting effect itself is counted.\n\n## Steps to Reproduce\n\n1. Fill a document to MAX_CATEGORIES (12), then add a 13th via `:add-cat X` or the `+cat` records column. The category is NOT created — `AddCategory` now sets abort_effects on refusal (PR #27) — yet `layout_version` goes 12 -\u003e 13 and `model_state.dirty` -\u003e true.\n2. Or simply `:add-cat Region` twice. `Model::add_category` returns Ok for a duplicate (types.rs:177), so this does not even abort: a pure no-op bumps both.\n\n## Consequences\n\n * EVERY OTHER CONNECTED CLIENT'S NEXT Command::Model IS REFUSED with Rejection::LayoutMoved, for a keystroke that changed nothing.\n * The browser worker's `if app.model_state.dirty { persist() }` (worker.rs:178) rewrites the whole OPFS document — and Storage is whole-document (gotcha 41).\n\n## Why this is a contradiction and not just an over-approximation\n\nThe `dirty` half IS argued as acceptable over-approximation in the comment at app.rs:617-622. The `layout_version` half is NOT — the same comment block says \"an aborted batch must not claim the effects it never ran (gotcha 21)\", which is exactly what it does. The code and its own stated rule disagree, four lines apart.\n\n## Note before fixing\n\nMoving the two bumps below the abort check closes the MAX_CATEGORIES arm but NOT the duplicate arm, because a duplicate AddCategory does not abort at all. The reachable, silent case is the duplicate. Consider bumping on observed change instead — `apply_effects` already fingerprints records for the CellData tier (gotcha 40), so \"did anything actually change\" is machinery that exists.\n","acceptance_criteria":"A refused AddCategory (MAX_CATEGORIES) and a duplicate AddCategory both leave layout_version and model_state.dirty untouched. Note the duplicate arm does not abort, so moving the bumps below the abort check is not sufficient on its own — the reachable silent case is the duplicate. A test drives both arms through App::apply_effects and asserts neither counter moved; injecting the old order fails it. State in the commit what the rule now is: a counter is bumped on an observed change, not on a declared intent.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:41Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:41Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-75ld","title":"A refused or no-op AddCategory still bumps layout_version and raises dirty","description":"Found by the post-wave code consistency review, CONFIRMED BY EXECUTION against a real App.\n\nApp::apply_effects (crates/improvise-command/src/ui/app.rs:637-650) runs `effect.apply(); dirty.union(of(kind)); dirty = true; layout_version += 1;` and ONLY THEN checks abort_effects. So the aborting effect itself is counted.\n\n## Steps to Reproduce\n\n1. Fill a document to MAX_CATEGORIES (12), then add a 13th via `:add-cat X` or the `+cat` records column. The category is NOT created — `AddCategory` now sets abort_effects on refusal (PR #27) — yet `layout_version` goes 12 -\u003e 13 and `model_state.dirty` -\u003e true.\n2. Or simply `:add-cat Region` twice. `Model::add_category` returns Ok for a duplicate (types.rs:177), so this does not even abort: a pure no-op bumps both.\n\n## Consequences\n\n * EVERY OTHER CONNECTED CLIENT'S NEXT Command::Model IS REFUSED with Rejection::LayoutMoved, for a keystroke that changed nothing.\n * The browser worker's `if app.model_state.dirty { persist() }` (worker.rs:178) rewrites the whole OPFS document — and Storage is whole-document (gotcha 41).\n\n## Why this is a contradiction and not just an over-approximation\n\nThe `dirty` half IS argued as acceptable over-approximation in the comment at app.rs:617-622. The `layout_version` half is NOT — the same comment block says \"an aborted batch must not claim the effects it never ran (gotcha 21)\", which is exactly what it does. The code and its own stated rule disagree, four lines apart.\n\n## Note before fixing\n\nMoving the two bumps below the abort check closes the MAX_CATEGORIES arm but NOT the duplicate arm, because a duplicate AddCategory does not abort at all. The reachable, silent case is the duplicate. Consider bumping on observed change instead — `apply_effects` already fingerprints records for the CellData tier (gotcha 40), so \"did anything actually change\" is machinery that exists.\n","acceptance_criteria":"A refused AddCategory (MAX_CATEGORIES) and a duplicate AddCategory both leave layout_version and model_state.dirty untouched. Note the duplicate arm does not abort, so moving the bumps below the abort check is not sufficient on its own — the reachable silent case is the duplicate. A test drives both arms through App::apply_effects and asserts neither counter moved; injecting the old order fails it. State in the commit what the rule now is: a counter is bumped on an observed change, not on a declared intent.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:41Z","created_by":"Edward Langley","updated_at":"2026-09-20T23:59:38Z","closed_at":"2026-09-20T23:59:38Z","close_reason":"Fixed in PR #70 (14757a7), reviewed and merged. layout_version and dirty moved from declared EffectKind to observed change; adopt_cursor_epoch narrowed to the reshaped views with an is-empty fail-safe; Panel-\u003efield consolidated onto ViewState accessors.","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yqpd","title":"Transactional rollback: a frame of mutations applies whole or not at all (ADR 001 clause 4)","description":"Clause 4 of the transaction contract the user specified 2026-09-20. Clauses 1-3 landed in PR #26; this is the half that was deliberately deferred with a costed design note.\n\nTHE CONTRACT, in the user's own words: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses:\n\n 1. A frame states ONE basis, describing the base the client composed against. [DONE, PR #26]\n 2. Checked ONCE, before anything is applied. [DONE, PR #26]\n 3. Applied IN ORDER, each command seeing what its predecessor left. [DONE, PR #26]\n 4. ALL OR NOTHING -- a failure leaves the document exactly as the base described. [THIS BEAD]\n\nWHERE THE MECHANISM BELONGS: App::apply_effects, NOT Host::commands. A boundary drawn around a sequence of COMMANDS gets redrawn the day one command becomes a sequence -- and the user has said a command could eventually be \"run script\", which makes an unbounded number of mutations internally. So arity is not a property of a variant (doctrine(arity_is_not_a_property_of_a_variant)): a classifier can honestly say WHETHER a command changes the document, never HOW MANY TIMES. Design at the one mutation site and let both the frame case and the future script case use it.\n\nTHE CORE PROBLEM IN ONE LINE: abort_effects stops a batch and UNDOES NOTHING. That is an abort, not a rollback (gotcha 21), and clause 4 needs the latter. Two live examples sit in main today: a failed Save, and AddCategory refusing on MAX_CATEGORIES (PR #27).\n\nTHE DESIGN NOTE FROM PR #26, whose arguments should not be re-derived:\n * SNAPSHOT AND RESTORE, not speculative validation and not inverse effects. Speculation fails a concrete divergence argument: AcceptWizardModel calls take_built(), which CONSUMES the operand a real run would need. Inverse effects degenerate -- the general inverse of a document-replacing effect IS a snapshot -- so they do not avoid the clone, they multiply the surface by ~50.\n * THE FINGERPRINTS CANNOT BE AN UNDO LOG. The per-record XOR (gotcha 40) is a change DETECTOR, not invertible, and covers CellData only.\n * THE BOUNDARY IS THE DOCUMENT AND ITS TIERS, NOT ALL App STATE. AddCategory writes its refusal into view_state.status_msg and THEN aborts, so rolling that back would undo the explanation along with the damage.\n * WHY ROLLBACK IS SOUND AT ALL, structurally rather than by a flag: the three storage-WRITING effects (Save, SaveAs, ExportCsv) are all ViewOnly and unreachable from ModelCommand; LoadModel and ImportJsonHeadless only read. That is testable, and it becomes an acceptance criterion for any future RunScript -- a script language that can run :w breaks it.\n\nTWO ACCEPTANCE CRITERIA THAT CAME OUT OF THE P1 THE REVIEWER FOUND, and they are not obvious:\n * THE CHECKS MUST STAY BEFORE ANY APPLICATION. A frame that writes ONE RECORD TWICE is legitimate and likely -- two measures on one record IS one record -- and it works only because the second write is compared to the record's PRE-FRAME counter rather than the one its own sibling just bumped. Proved by injection: moving the record check into the apply loop fails that arm. `a_later_write_in_a_chain_still_answers_for_its_own_record` arm 3 is the guard.\n * CLAUSE 4'S ROLLBACK IS NOT THE RECORD TIER. They fail on DISJOINT frames, which is exactly why the P1 could not be deferred into this bead: rollback never fires on a frame where every command SUCCEEDS, and the lost update was a wrong answer fully applied.\n\nTHE TRADE NOBODY HAD NAMED, and the reason arm 3 exists: INTERLEAVING THE RECORD CHECK WITH THE APPLIES WOULD CLOSE THIS BEAD'S SILENCE FOR FREE. [RemoveItem(North), SetCell(North)] under interleaving is refused RecordGone instead of silently half-applying. So check-before-apply is not a free win -- it BUYS the legitimate double-write and SELLS the loud failure on a broken chain. Clause 4 must pay that back some other way, and arm 3 is what stops someone taking the trade without noticing they made it. Confirmed by two independent injections (the author's and the reviewer's, which differ in detail and agree on the direction).\n\nTHE BIGGEST AND LEAST OBVIOUS COST: ROLLING BACK THE App IS NOT ENOUGH. Host::command projects PER COMMAND, and apply_switch/apply_model adopt mirrors inline, so by the time a later command fails every session's mirror and believed grid cache have already moved. Deferring projection and relay to commit is the right fix and is also a bandwidth win -- but it changes Applied and restructures project.rs, so it is a merge-order problem as well as a size one.\n\nPROTOCOL MOVE THAT BELONGS HERE: clause 1 says the FRAME states one basis, but the wire still carries a Basis inside each Command::Model -- right when one command was one transaction. Later commands' Basis fields are currently IGNORED. Moving it to ClientFrame::Commands makes \"one basis per frame\" unrepresentable-otherwise instead of a convention.\n\nALSO FOLD IN: the `answered`/`frame_basis` classifier is a matches! whose failure direction is safe (a missed variant over-checks, never under-checks) -- make it exhaustive when the classifier returns, for the reason mutates_the_document was made exhaustive before it was deleted.\n\nESTIMATED COST from the design note: ~350-450 lines of production and as much again in tests, across app.rs, reduce.rs, project.rs, host.rs, improvise-protocol and an ADR section.\n\nINVERT `a_frame_whose_later_mutation_fails_is_not_yet_undone` when this lands. It currently characterizes the hole; all three of its assertions flip.\n","acceptance_criteria":"A ClientFrame::Commands whose later command fails leaves the document exactly as its stated basis described. The mechanism sits at or near App::apply_effects, not Host::commands, so a future multi-mutation command (RunScript) uses the same machinery. The checks stay BEFORE any application — a frame writing one record twice is legitimate and refuses itself otherwise, which a_later_write_in_a_chain_still_answers_for_its_own_record arm 3 guards. Rolling back the App is not enough: Host::command projects per command, so mirrors and believed grid caches must be deferred to commit. a_frame_whose_later_mutation_fails_is_not_yet_undone inverts. And the Basis moves from Command::Model to ClientFrame::Commands, making one-basis-per-frame unrepresentable-otherwise.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T07:29:16Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0o6x","title":"A group header first on its axis is never drawn, so a leading collapsed group has no way back","description":"Found by the independent adversarial review of PR #23, which calls it \"the most valuable thing I found that nobody named\". PRE-EXISTING and correctly characterized, but improvise-anr9 changes what it costs.\n\nTHE BEHAVIOUR: body_from(idx) starts at the first ITEM, so at row_offset == 0 a leading FrameEntry::Group is skipped. GROUPED_FRAME shows it plainly -- the group header \"Coastal\" above \"East\" is simply not drawn -- and that constant is BYTE-IDENTICAL to main, so the TUI has always behaved this way and the characterization is honest.\n\nWHY IT MATTERS MORE NOW: a COLLAPSED group that is first on its axis has NO HEADER ANYWHERE ON SCREEN, in either front end, and therefore NO WAY BACK. Its rows are hidden and the only affordance for unhiding them is the header that is not drawn.\n\nAND IT SITS EXACTLY WHERE THE WINDOWING RULE SAYS IT MUST NOT. improvise-anr9's axis_frame carries ALL headers between two in-window items specifically because \"a collapsed group's header is the only thing on screen the user can click to get its rows back\". That clause is correct and the rule is right. Position zero is the one place the rule does not reach -- not because the frame omits the header (it does not) but because body_from drops it on the way to the screen.\n\nTHE AUTHOR KNEW, WHICH IS WHY THIS IS FILED RATHER THAN REDISCOVERED: tests/grid_frame_agreement.rs deliberately orders its fixture with \"Carol\" before \"Family\" to dodge the case, and says so in a comment. It was not in the PR body and no ticket was cited, so the knowledge lived in one comment in one test.\n\nFILE THIS BEFORE ANYONE BUILDS GROUP INTERACTION IN THE BROWSER. web/app.js's group \u003cth\u003e has no click listener today and `toggle-group-under-cursor` is Upstream -- a black hole, doctrine(an_upstream_key_is_a_black_hole) -- so the affordance does not exist in the browser at all yet. The moment it does, a leading collapsed group becomes a trap: rows hidden, nothing to click.\n\nWHAT THE FIX TOUCHES, so nobody starts casually: body_from/visual_of decide what a renderer paints from a window offset. Drawing a leading header changes the row count of the painted body, which moves the exact-frame characterization buffers in src/ui/grid.rs -- so this is a deliberate re-pinning with the reason in the commit message, the same acceptance clause improvise-q0vb had. Check the interaction with the windowing rule's first clause (the nearest header ABOVE the window is carried for group_of and discarded by body_from): at offset zero there is no \"above\", so the leading header is an in-window entry that body_from is discarding, which may make the fix a one-line boundary condition rather than a redesign. Establish which before estimating.\n\nRELATED: improvise-plcx (the harness needs a grouped, multi-level fixture) is what would put this in front of a browser at all.\n\n## Steps to Reproduce\n\n1. Build a view whose row axis has a group as its FIRST entry — e.g. a category whose first item belongs to group `Coastal`.\n2. Open it in the TUI at `row_offset == 0`.\n3. The `▼ Coastal` header is not drawn at all: `body_from(0)` starts at the first ITEM and skips the leading `FrameEntry::Group`. `GROUPED_FRAME` in `src/ui/grid.rs` pins this, byte-identical to pre-anr9.\n4. Now collapse that group. Its rows disappear and its header is still not drawn — so there is nothing on screen to act on, in either front end.\n","acceptance_criteria":"A group header that is first on its axis is drawn, in the TUI and in the browser, so a collapsed group at position zero has an affordance. The exact-frame characterization buffers in src/ui/grid.rs are re-pinned DELIBERATELY, with the commit message naming which moved and why — the improvise-q0vb acceptance shape. Establish first whether this is a boundary condition in body_from rather than a redesign: at offset zero there is no 'above', so the leading header is an in-window entry body_from is discarding.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:26:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:12Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-0o6x","depends_on_id":"improvise-plcx","type":"relates-to","created_at":"2026-09-20T07:26:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-plcx","title":"The browser harness needs a grouped, multi-level fixture — the demo has neither","description":"Named by improvise-anr9 (PR #23) as \"the highest-value follow-up in this hand-back\", and it is a gap in the NEW harness rather than in the old blind spot.\n\nmain now carries harness/tests/shell.spec.js (improvise-vvl3), which renders web/app.js in Chromium, Firefox and WebKit and asserts the grid draws, a header reads \"Revenue\", an editable cell exists, and click-to-edit commits and shows the host's value. It PASSED on PR #23's merged head, which means the DOM rewrite that PR shipped is genuinely browser-verified.\n\nBUT ONLY ON THE SIMPLE PATH. web/demo.improv has NO GROUPS AND ONE CATEGORY PER VISIBLE AXIS -- deliberately, per gotcha 46, because before improvise-anr9 a second category on an axis put the slash-join gap on the front page. That is no longer a reason, and it is now the harness's blind spot: the multi-level header, group-span, collapse-indicator and inline-group-row paths of gridTable are EXACTLY the code improvise-anr9 added, and the harness never exercises any of them.\n\nWHAT IT NEEDS: a second .improv served by the harness -- not a change to web/demo.improv, which is the shipped front page and is correct as it is -- carrying at least two categories on one axis, a group with a couple of members, a collapsed group, and enough rows to scroll. Then assertions that the DOM has real header levels rather than slash-joined labels, that a group header spans its members, that the collapse indicator renders, and that an inline group row carries no cells (RenderRow::Group exists so it cannot, which is worth asserting from the outside).\n\nWHY IT IS WORTH DOING SOON: it would be the FIRST TEST IN THIS REPOSITORY able to see that code in a browser, and improvise-anr9's own hand-back says so. It also unblocks the sibling scrollIntoView bead, which needs a document big enough to scroll.\n\nNOTE THE STALE-DOCUMENTATION LESSON THAT CAME WITH IT, recorded because it cost real caution: improvise-anr9 wrote \"web/app.js is never executed here\" into three comments and a PR body on the strength of gotcha 42 -- while a harness that renders it in three browsers had ALREADY LANDED on main and was passing on its own code. Gotcha 48 says a fact stated in several places is a grep. This is the version where the stale copy is the one telling you what you CANNOT test, and it makes you under-claim your own evidence.\n","acceptance_criteria":"The harness serves a SECOND .improv (not web/demo.improv, which is the shipped front page and correct as it is) carrying at least two categories on one axis, a group with several members, a collapsed group, and enough rows to scroll. Specs assert: real header levels rather than slash-joined labels; a group header spanning its members; the collapse indicator rendering; and an inline group row carrying no cells (RenderRow::Group makes that unconstructible, which is worth asserting from outside). Each assertion is proved discriminating before it is kept.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:23Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-plcx","depends_on_id":"improvise-0o6x","type":"relates-to","created_at":"2026-09-20T07:26:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"improvise-plcx","title":"The browser harness needs a grouped, multi-level fixture — the demo has neither","description":"Named by improvise-anr9 (PR #23) as \"the highest-value follow-up in this hand-back\", and it is a gap in the NEW harness rather than in the old blind spot.\n\nmain now carries harness/tests/shell.spec.js (improvise-vvl3), which renders web/app.js in Chromium, Firefox and WebKit and asserts the grid draws, a header reads \"Revenue\", an editable cell exists, and click-to-edit commits and shows the host's value. It PASSED on PR #23's merged head, which means the DOM rewrite that PR shipped is genuinely browser-verified.\n\nBUT ONLY ON THE SIMPLE PATH. web/demo.improv has NO GROUPS AND ONE CATEGORY PER VISIBLE AXIS -- deliberately, per gotcha 46, because before improvise-anr9 a second category on an axis put the slash-join gap on the front page. That is no longer a reason, and it is now the harness's blind spot: the multi-level header, group-span, collapse-indicator and inline-group-row paths of gridTable are EXACTLY the code improvise-anr9 added, and the harness never exercises any of them.\n\nWHAT IT NEEDS: a second .improv served by the harness -- not a change to web/demo.improv, which is the shipped front page and is correct as it is -- carrying at least two categories on one axis, a group with a couple of members, a collapsed group, and enough rows to scroll. Then assertions that the DOM has real header levels rather than slash-joined labels, that a group header spans its members, that the collapse indicator renders, and that an inline group row carries no cells (RenderRow::Group exists so it cannot, which is worth asserting from the outside).\n\nWHY IT IS WORTH DOING SOON: it would be the FIRST TEST IN THIS REPOSITORY able to see that code in a browser, and improvise-anr9's own hand-back says so. It also unblocks the sibling scrollIntoView bead, which needs a document big enough to scroll.\n\nNOTE THE STALE-DOCUMENTATION LESSON THAT CAME WITH IT, recorded because it cost real caution: improvise-anr9 wrote \"web/app.js is never executed here\" into three comments and a PR body on the strength of gotcha 42 -- while a harness that renders it in three browsers had ALREADY LANDED on main and was passing on its own code. Gotcha 48 says a fact stated in several places is a grep. This is the version where the stale copy is the one telling you what you CANNOT test, and it makes you under-claim your own evidence.\n","acceptance_criteria":"The harness serves a SECOND .improv (not web/demo.improv, which is the shipped front page and correct as it is) carrying at least two categories on one axis, a group with several members, a collapsed group, and enough rows to scroll. Specs assert: real header levels rather than slash-joined labels; a group header spanning its members; the collapse indicator rendering; and an inline group row carrying no cells (RenderRow::Group makes that unconstructible, which is worth asserting from outside). Each assertion is proved discriminating before it is kept.","notes":"PR #72. harness/fixtures/grouped.improv + harness/tests/grouped.spec.js, five injections. Note: the leading group header (Coastal) is never drawn -- improvise-0o6x observed in the wild; the spec asserts 2 group rows and says so.","status":"closed","priority":2,"issue_type":"task","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:28Z","created_by":"Edward Langley","updated_at":"2026-09-21T00:24:05Z","started_at":"2026-09-20T23:21:56Z","closed_at":"2026-09-21T00:24:05Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-plcx","depends_on_id":"improvise-0o6x","type":"relates-to","created_at":"2026-09-20T07:26:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-sihr","title":"Every minibuffer mode entered from records mode exits to plain Normal while the layout is still records","description":"Found by Copilot on PR #27 against the new Ctrl-W save prompt, and DECLINED there with evidence because fixing the one arm the finding named would have been one arm of a three-arm rule.\n\nTHE BEHAVIOUR: every minibuffer mode entered from RecordsNormal exits to plain Normal while the LAYOUT IS STILL RECORDS. It affects `:` (CommandMode), Ctrl-E (ExportPrompt) and Ctrl-W (SavePrompt, new in PR #27). Only the last is new; the class predates it.\n\nTHE MECHANISM: the exit mode is a CONSTANT, baked into TextEntry::new(.., \"normal\") in the keymap builder and into each commit command's ChangeMode. Nothing carries where the user came FROM, so nothing can put them back.\n\nWHAT IS NOT THE MECHANISM, checked rather than reasoned (doctrine(binding_dump_diff)): the binding TABLE is fine. RecordsNormal goes 155 -\u003e 156 dumped lines across PR #27, the single addition being Char('w')+CONTROL -\u003e enter-mode [\"save-prompt\"], with nothing removed. with_parent inheritance (gotcha 13) is intact. What is lost is the RUNTIME MODE, not a binding.\n\nCHARACTERIZED, NOT FIXED: every_minibuffer_mode_entered_from_records_returns_to_plain_normal drives all three and asserts both halves -- the mode is Normal, and the layout is still records. So the current behaviour is pinned and a fix will have to update it deliberately.\n\nTHE FIX, and it is why this is its own bead: text-entry modes must carry their entry mode. That is a change to TextEntry, to AppMode (the exit is part of a mode's configuration, so it likely belongs beside MinibufferConfig -- note gotcha 11, MinibufferConfig::buffer_key is a String and not \u0026'static str precisely because ViewState must stay Deserialize-able, and the same constraint applies to anything added there), and to three commit commands. Wider than the PR that found it.\n\nWATCH TWO THINGS:\n * The exit is currently spelled in TWO places per mode -- the TextEntry spine's Esc binding and the commit command's ChangeMode. If they are made to carry an entry mode, make them read ONE source or the two will disagree on cancel versus commit.\n * SearchMode is hand-written on purpose and has no commit key (gotcha 11). It is not part of this class; do not sweep it in.\n\nRELATED: improvise-mai9 (a status message written while a minibuffer is open is never drawn) is the other half of why these modes are awkward -- both are consequences of the minibuffer owning the bottom row and nothing tracking what it displaced.\n\n## Steps to Reproduce\n\n1. Enter records mode (`R`).\n2. Press `:`, or `Ctrl-E`, or `Ctrl-W` — any minibuffer mode.\n3. Commit or cancel.\n4. The app is now in plain `Normal` while the LAYOUT is still records: the exit mode is a constant baked into `TextEntry::new(.., \"normal\")` and into each commit command's `ChangeMode`, and nothing carries where the user came from.\n\n`every_minibuffer_mode_entered_from_records_returns_to_plain_normal` drives all three and asserts both halves.\n","acceptance_criteria":"A minibuffer mode entered from RecordsNormal returns to RecordsNormal. Text-entry modes carry their entry mode rather than a constant 'normal'. The exit is currently spelled TWICE per mode — the TextEntry spine's Esc binding and the commit command's ChangeMode — so both read ONE source or they will disagree on cancel versus commit. SearchMode is hand-written on purpose and is not part of this class. every_minibuffer_mode_entered_from_records_returns_to_plain_normal is updated deliberately, not deleted.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:12:20Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:17Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-sihr","depends_on_id":"improvise-mai9","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-kc7n","title":"improvise-ws-server never checks Origin, so any page a user visits can drive their session","description":"Found by Copilot on PR #21 round 5, against a reverse-proxy recipe whose IP allow-list had itself been added two rounds earlier to close a DIFFERENT hole. Verified independently by the orchestrator: `grep -rni origin crates/improvise-ws-server/src/` returns NOTHING. The server never inspects the Origin header and accepts every handshake.\n\nTHE HOLE: a WebSocket is EXEMPT FROM THE SAME-ORIGIN POLICY -- there is no CORS preflight on a handshake. So any hostile page that an allowed user visits can open a socket to the endpoint FROM THAT USER'S OWN ALLOWED ADDRESS, and read and edit every document the server holds. That is cross-site WebSocket hijacking.\n\nWHY NETWORK CONTROLS CANNOT SEE IT: an IP allow-list, a private network, loopback binding -- all answer \"may this CLIENT reach the endpoint\", and in this attack the answer is genuinely yes. The request really does come from an allowed client; it is simply being DRIVEN by a page the user did not intend to trust. Only Origin separates the two.\n\nLOOPBACK IS NOT EXEMPT, and this is the half a documentation-only fix cannot reach. In the local pairing case there is no proxy at all, so there is nothing in front to check Origin -- and loopback is not a boundary a browser enforces. While the server runs, ANY page open in that browser can reach ws://127.0.0.1:8787. So the local development path, which is the one the project actually recommends, is the least protected.\n\nDISTINCT FROM improvise-kpnr, and both are needed. kpnr is AUTHENTICATION: who is allowed to talk to this server at all. This is CSWSH: given that someone is allowed, what may put words in their mouth. An answer to either leaves the other open.\n\nTHE FIX IS CHEAP AND SERVER-SIDE: an allow-list of permitted origins checked at the handshake, defaulting to something safe for the local case (the page's own origin, and whatever --bind implies), with a flag to widen it. Refuse the upgrade rather than accepting and ignoring.\n\nTWO THINGS TO GET RIGHT:\n * A MISSING Origin header is not the same as a disallowed one. Non-browser clients (the repo's own tests, curl, a native client) send none, and the browser always does. Decide explicitly what absent means rather than letting the match arm decide by accident.\n * PR #21 has already documented Origin filtering in the PROXY as a required companion to the network control, phrased as \"in addition to, never instead of\". When this lands, the README should say the server does it too, and the proxy line becomes defence in depth rather than the only defence.\n\nTHE GENERAL LESSON, now doctrine(who_may_connect_is_two_questions): ask of any access control whether it constrains the CLIENT or what may DRIVE the client. Three consecutive rounds of that PR reasoned about the page's capabilities and none about the attacker's.\n\n## Steps to Reproduce\n\n1. Run `improvise-ws-server` on any document.\n2. From a browser that is allowed to reach it — the user's own machine, or any address an IP allow-list permits — visit an unrelated page that opens `new WebSocket(\"ws://127.0.0.1:8787\")`.\n3. The handshake succeeds. A WebSocket is exempt from the same-origin policy, so there is no CORS preflight and `grep -rni origin crates/improvise-ws-server/src/` returns nothing — the server never looks.\n4. That page can now read and edit every document the server holds, from the user's own allowed address.\n\nLoopback is not exempt and has no proxy in front of it, so the local development path is the least protected.\n","acceptance_criteria":"improvise-ws-server refuses a handshake whose Origin is not allow-listed, defaulting to something safe for the local case with a flag to widen it. A MISSING Origin is decided explicitly rather than by accident — non-browser clients (the repo's own tests, curl, a native client) send none and browsers always do. The README's proxy-side Origin filtering becomes defence in depth rather than the only defence.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:09:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:18Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-kc7n","depends_on_id":"improvise-kpnr","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mai9","title":"A status message written while a minibuffer is open is never drawn","description":"Found by WU-G while building improvise-lae (PR #27), and CONFIRMED THE HARD WAY in the same PR: Copilot then found a regression that this mechanism causes, which the agent had itself written down an hour earlier and not checked its own change against.\n\nTHE MECHANISM: src/draw.rs::draw_bottom_bar renders the minibuffer INSTEAD OF the status line. So a status_msg written while a minibuffer is open is never drawn at all -- not truncated, not overwritten, simply never rendered.\n\nWHO IS AFFECTED TODAY:\n * CategoryAdd / ItemAdd: a failed `commit-buffer category` is INVISIBLE. Worse, improvise-6t3 deliberately ordered that command's effects so the add effect is emitted LAST, specifically so its failure message would WIN over the command's optimistic status. It wins a race to a surface nobody can see.\n * A failed `:w \u003cpath\u003e` from CommandMode, which is the regression Copilot found on PR #27: the abort stops the batch before ReturnToNormalUnlessMoved and clear-buffer run, so the app stays in CommandMode with the stale command in the minibuffer and the new error hidden behind it.\n\nTHE WORKAROUND ALREADY SHIPPED, and it is a workaround: CommitSave emits [ChangeMode(Normal), SaveAs] so the error lands when the bottom row is the status bar again. That works for one command and does not generalise -- every other text-entry commit has the same hole.\n\nTWO CANDIDATE FIXES, and the choice is the ticket:\n * Draw both: give the minibuffer and the status line separate rows, or render the status into the minibuffer row when a message is pending. Cheap to describe, and it changes frame geometry, which is pinned by exact-frame characterization tests (see improvise-q0vb for what that costs).\n * Make it unrepresentable: a status message set while a minibuffer is open is a bug in the CALLER, so make the caller unable to do it -- e.g. commit paths always leave the mode before reporting. That is what CommitSave now does by hand, promoted to a rule.\n\nPREFER WHICHEVER MAKES THE FAILURE LOUD. The current state is the quiet kind: the code sets a message, the test asserts the message was set, and the user never sees it. A test asserting `app.status_msg == \"...\"` passes in every one of these cases, which is why this survived improvise-6t3's careful effect ordering.\n\n## Steps to Reproduce\n\n1. Enter a text-entry mode that opens a minibuffer — `CategoryAdd` (`N`) is the clearest.\n2. Commit something that fails, so the command sets a status message.\n3. Nothing appears: `src/draw.rs::draw_bottom_bar` renders the minibuffer INSTEAD OF the status line, so the message is never drawn at all — not truncated, not overwritten.\n4. A test asserting `app.status_msg == \"...\"` passes, which is why this survived improvise-6t3's careful effect ordering.\n\nSecond path, found on PR #27: a failed `:w /bad/path` aborts before `ReturnToNormalUnlessMoved`, leaving the app in `CommandMode` with the stale command drawn and the error hidden behind it.\n","acceptance_criteria":"A status message set while a minibuffer is open reaches the user. Either the two share the bottom row, or a commit path is structurally unable to report while its minibuffer is open (which is what CommitSave now does by hand, promoted to a rule). Whichever is chosen, the failure becomes LOUD: a test asserting app.status_msg was set must no longer pass while the user sees nothing.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:20Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:16Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-mai9","depends_on_id":"improvise-sihr","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -184,7 +187,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-2f97","title":"kb_consistent does not check a clause name cited inside another clause's string","description":"prolog-sessions/kb.pl clauses cross-reference each other by name inside their argument strings - e.g. one doctrine's text cites doctrine(an_advisory_gate_is_not_a_gate) by name. Nothing in kb_consistent/0 checks that a name mentioned inside a string resolves to a clause that exists.\n\nConsequence: dropping or renaming a clause silently dangles every other clause that cites it, and the KB has exactly the fact-stated-twice hazard its own doctrine warns about in prose.\n\nFound while batching wave-3 additions: two queued clauses had this shape, plus a related positional bug - one clause opened 'The companion to the doctrine below', which is meaningless after a loadProgram, since replacing a whole predicate does not preserve 'below'.\n\nFix: a kb_error/1 arm that extracts name-shaped tokens from clause argument strings and requires each to resolve as a doctrine/2, invariant/2 or decision/3 head. Needs a deliberate token rule so ordinary prose does not false-positive - probably only tokens appearing inside doctrine(...)/invariant(...)/decision(...) brackets within the string.\n\nAlso worth ruling on: whether positional language ('below', 'above', 'the previous clause') should be rejected outright, since predicate order is not stable across a loadProgram.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T22:06:35Z","created_by":"orchestrator","updated_at":"2026-09-20T22:06:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9za9","title":"Demoting an evidence heading to prose is an unguarded escape hatch","description":"check-evidence classifies a heading as a claim by SHAPE -- a bare identifier is a Rust test, a quoted string a JS test, anything else is prose. So any orphaned heading can be retired by rewriting it with a space and a dash, and nothing checks whether the prose is honest.\n\nDEMONSTRATED: appending '#### Helper -- `thing`' to an evidence file is silently unchecked. None of the 14 new --self-test evidence cases covers this, and no mechanism could: the checker cannot know whether prose is truthful.\n\nThis is disclosed rather than hidden -- the docstring names it and points at context/code/tooling.md before loosening -- and the safe direction is confirmed: prose that LOOKS like an identifier is classified as a Rust claim and fails loudly ('#### Background' reports as an orphan) rather than passing silently.\n\nThe live instance was PR #69 demoting '? opens help, and its rows are the shared ones' while PR #72 was restoring that coverage under a different name. That is the argument for treating a demotion as something a reviewer must read rather than something a gate can hold. Worth deciding whether a demotion should require a marker the checker can count, so at least the NUMBER of retired claims is visible.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T00:11:25Z","created_by":"orchestrator","updated_at":"2026-09-21T00:11:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-w8ih","title":"extract web/assemble.sh's stamp gate so it can be exercised in isolation","description":"Found by review of PR #69. improvise-x4wv made stamped() record every missing cache-bust stamp and put the fail in a four-line 'test -z $missing' gate after the four calls. That gate is the whole guard, and deleting it is invisible: both failures still print and assemble.sh exits 0, proved by injection on #69.\n\nThe decline of a full assemble harness stands -- it would have to fabricate web/pkg and web/worker-pkg, a second answer to 'what does the site assemble from', which improvise-vvl3 exists to prevent. But the gate alone does not need the tree: extract it (a function taking the missing list, or a tiny script) and exercise it the way scripts/node-test-test.sh and scripts/kb-test.sh exercise theirs -- empty list exits 0, non-empty exits 1 naming every entry. Wire it into CI in the same change.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T00:01:05Z","created_by":"claude-agent-scripts","updated_at":"2026-09-21T00:01:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xf4","title":"layout_geometry_effects_actually_change_view_config asserts against a test-only third fingerprint","description":"The test hand-lists 6 of the 13 LayoutGeometry effects and asserts against effect.rs::tests::view_config_fingerprint -- a THIRD fingerprint that exists only in test code, beside the production document_config and view_shape introduced by PR #70.\n\nSince model_state.dirty for LayoutGeometry is now decided solely by document_config, a future geometry effect that writes something view_config_fingerprint sees but document_config does not would pass this test and NEVER mark the document dirty -- i.e. never be saved.\n\nAll 13 are fine today; checked by hand (AddCategory, RemoveCategory, CreateView, DeleteView, SetAxis, SetPageSelection, ToggleGroup, HideItem, ShowItem, TransposeAxes, CycleAxis, SetNumberFormat, TogglePruneEmpty). AddFormula is CellData, so formulas -- which document_config does not hash -- still raise dirty off the declaration.\n\nFix: point the test at document_config itself and list all 13, which turns a third derivation into the guard for the production one.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:59:49Z","created_by":"orchestrator","updated_at":"2026-09-20T23:59:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2avl","title":"layout_version moved outside the effect loop and neither abort arm is tested","description":"PR #70 moved layout_version from 'once per LayoutGeometry effect, inside the loop' to 'once per batch, after the loop'. Gotcha 40 records the inside-the-loop position as load-bearing: an aborted batch must not claim effects it never ran.\n\nThe author's argument for why the end-of-batch position is safe for layout_version specifically -- nothing ever CLEARS layout_version, so an end comparison sees only the effects that actually ran, whereas dirty must stay in the loop because Save unsets it mid-batch -- is sound and is written into context/code/command.md. But an argument is not a test.\n\nTwo arms, neither covered: an aborted batch that DID reshape before aborting (must bump), and one that aborted before reshaping (must not).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T23:59:49Z","created_by":"orchestrator","updated_at":"2026-09-20T23:59:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-y4xg","title":"CmdContext carries a fourth Panel-to-field mapping, and it is completely unguarded","description":"registry.rs:359 and :393 map Panel -\u003e CmdContext fields (formula_panel_open / category_panel_open / view_panel_open) in the interactive arms of toggle-panel-visibility and toggle-panel-and-focus. CmdContext's flags are copied from view_state at app.rs:528, so it is the same fact wearing a different struct.\n\nDEMONSTRATED unguarded: swapping Formula and Category at both sites leaves cargo test --workspace --no-fail-fast at 1400 passed, 0 failed. With the swap in place, toggling the formula panel reads the CATEGORY panel's open flag to decide open-vs-close -- a visible wrong behaviour with zero coverage.\n\nPre-existing (identical at 00f7678) and outside improvise-56ek's scope, which consolidated the Panel -\u003e ViewState mapping. The cure is a CmdContext::panel_open(panel) accessor in command/cmd/core.rs, or a command-side sibling of a_panel_command_writes_that_panel_and_no_other.\n\nNOT the same defect: resolve.rs:492 and registry.rs:447 map Panel -\u003e (cursor, collection length). That is a composite -- the length half is genuinely per-panel and per-source -- so the match must exist; it is lockstep.rs's subject and the permutation injection does reach it.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:59:48Z","created_by":"orchestrator","updated_at":"2026-09-20T23:59:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2f97","title":"kb_consistent does not check a clause name cited inside another clause's string","description":"prolog-sessions/kb.pl clauses cross-reference each other by name inside their argument strings - e.g. one doctrine's text cites doctrine(an_advisory_gate_is_not_a_gate) by name. Nothing in kb_consistent/0 checks that a name mentioned inside a string resolves to a clause that exists.\n\nConsequence: dropping or renaming a clause silently dangles every other clause that cites it, and the KB has exactly the fact-stated-twice hazard its own doctrine warns about in prose.\n\nFound while batching wave-3 additions: two queued clauses had this shape, plus a related positional bug - one clause opened 'The companion to the doctrine below', which is meaningless after a loadProgram, since replacing a whole predicate does not preserve 'below'.\n\nFix: a kb_error/1 arm that extracts name-shaped tokens from clause argument strings and requires each to resolve as a doctrine/2, invariant/2 or decision/3 head. Needs a deliberate token rule so ordinary prose does not false-positive - probably only tokens appearing inside doctrine(...)/invariant(...)/decision(...) brackets within the string.\n\nAlso worth ruling on: whether positional language ('below', 'above', 'the previous clause') should be rejected outright, since predicate order is not stable across a loadProgram.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T22:06:35Z","created_by":"orchestrator","updated_at":"2026-09-20T23:35:13Z","started_at":"2026-09-20T23:22:06Z","closed_at":"2026-09-20T23:35:13Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-669b","title":"The harness spec and the page can still disagree about HANDSHAKE_TIMEOUT_MS through a stale assemble","description":"PR #65 single-homed the handshake window: web/shell.js exports HANDSHAKE_TIMEOUT_MS and both web/app.js and harness/tests/shell.spec.js import it. That closes the demonstrated vacuity where an understated spec constant let the whole suite pass against reverted socket routing.\n\nBut the home is single in the TREE, not in what the page RUNS. The spec imports ../../web/shell.js (source); the page loads harness/serve/app/shell.js (the assembled copy). Demonstrated by review: shrink the constant in web/shell.js only, leave serve/ at 8000, revert both socket arms - 6/6 green. The exact vacuity, restored by a stale assemble.\n\nMuch narrower than before (it needs a stale serve/, and a stale serve/ invalidates the whole harness anyway - playwright.config.js already warns about reuseExistingServer adopting another checkout's server). Not urgent.\n\nTwo fixes, either sufficient: one sentence in context/test-evidence/clients.md naming the assemble as the remaining coupling, or have outlastTheWatchdog read the page's own timeoutMs through the window.__improviseShell hook it already uses for timesArmed() and compare.\n\nALSO IN SCOPE, same file, one line: context/test-evidence/clients.md:522 still says HANDSHAKE_TIMEOUT_MS 'restates web/app.js's own constant, because two tests have to outlast it'. Now false twice over - it restates nothing, and one test outlasts it. Line 610 in the same file states the new arrangement correctly. Gotcha 48's third clause: when a fact is stated in more than one place, changing it is a grep, not an edit.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T21:31:43Z","created_by":"orchestrator","updated_at":"2026-09-20T21:31:43Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-res9","title":"Doc site: derive the published set, so an unpublished context/ doc fails the build","description":"Found by review on PR #62 (improvise-djoo). scripts/build-doc-site.mjs publishes by a hand-maintained SECTIONS manifest. A commit that adds a context/code/*.md or context/test-evidence/*.md publishes nothing and says nothing: build green, link check green, doc simply absent.\n\nThis is EXACTLY the hardcoded-48 failure mode that PR documents in context/code/doc-site.md section 6 -- silent in precisely the direction the list moves, since the list only ever grows. Verified latent, not live: 23 markdown files under context/ plus docs/design-notes.md, 21 published, and the only two unpublished are context/plan.md and context/cloud-agents.md, which are deliberate omissions argued in doc-site.md section 2.\n\nCURE (the same derive-then-verify applied twice already in that PR): walk context/**/*.md plus docs/design-notes.md, subtract the published set, and fail unless the remainder is exactly a named OMITTED allowlist carrying those two files. An allowlist rather than a count, because the omissions are decisions and a new one should have to be restated in code next to the list rather than only in prose.\n\nSECOND, SMALLER ITEM in the same file: nothing enforces that the generator is the LAST command in web/assemble.sh. It must run last because its link check resolves ../index.html and ../app/ against the filesystem. PR #63 appended a module-import loop and it happened to land above the generator; a future append below it would make the outward-link check pass for the wrong reason -- silently, because the links would resolve against a half-built tree.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:18:44Z","created_by":"wave3-docsite","updated_at":"2026-09-20T21:18:44Z","comments":[{"id":"84622e30-3d60-5c8b-adec-f2990ae70cf5","issue_id":"improvise-res9","author":"wave3-docsite","text":"A TESTED PATCH EXISTS (reviewer, round 6 follow-up). 20 lines, fault-injected in both directions, exit codes observed:\n\n A clean tree at 702c1d4 exit=0\n B new unpublished doc exit=1 \"context/code/newarea.md is neither published nor a named omission\"\n C omission file vanished exit=1 \"context/plan.md is listed as omitted but does not exist\"\n\nDropped in after `const TICKETS = readTickets();`, needs readdirSync added to the node:fs import:\n\n const OMITTED = new Map([\n [\"context/plan.md\", \"a prompt, not a plan; cites a context/SPEC.md that no longer exists\"],\n [\"context/cloud-agents.md\", \"operational notes for agents, not about the program\"],\n ]);\n {\n const onDisk = [];\n (function walk(d) {\n for (const e of readdirSync(join(root, d), { withFileTypes: true })) {\n const rel = `${d}/${e.name}`;\n if (e.isDirectory()) walk(rel);\n else if (e.name.endsWith(\".md\")) onDisk.push(rel);\n }\n })(\"context\");\n onDisk.push(\"docs/design-notes.md\");\n const unaccounted = onDisk.filter((f) =\u003e !PAGE_BY_SOURCE.has(f) \u0026\u0026 !OMITTED.has(f));\n const vanished = [...OMITTED.keys()].filter((f) =\u003e !existsSync(join(root, f)));\n if (unaccounted.length || vanished.length) {\n for (const f of unaccounted) console.error(`doc-site: ${f} is neither published nor a named omission`);\n for (const f of vanished) console.error(`doc-site: ${f} is listed as omitted but does not exist`);\n process.exit(1);\n }\n }\n\nTHE C ARM IS THE POINT, and is the half that would not get written first. Without it, deleting or renaming plan.md leaves a stale entry asserting a reason for a file nobody can find -- the same failure as a dead ticket id, which AGENTS.md already calls worse than no id. With it, an omission must be restated the moment it stops being true, which is what makes OMITTED a decision record rather than a suppression list.\n\nIt also puts the reason in the code beside the list. Today \"why is plan.md unpublished\" lives in doc-site.md section 2 and \"plan.md is unpublished\" lives in the manifest, with nothing connecting them -- the two-copies shape gotcha 48's third paragraph is about.\n\nONE JUDGEMENT LEFT OPEN by the reviewer, worth deciding when this is picked up: the prototype runs at module scope, before any page is generated, so it fails before doing work (good) but is NOT covered by the \"generator runs last\" ordering argument in the header comment. Consequence: a bare `node scripts/build-doc-site.mjs out` outside an assembled tree would report a missing doc BEFORE it reports the 48 outward-link failures. Whether that is a useful early answer or a confusing interleaving of two unrelated failure modes is the call to make.\n\nNOT DONE IN PR #62 deliberately: latent rather than live (verified -- 21 of 23 context docs published, the two omissions are the intended ones), the PR was already on the orchestrator's desk having been extended three times, and the reviewer who found it recommended the follow-up.\n","created_at":"2026-09-20T21:19:08Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-qf57","title":"The formula-row click test lost its no-editor-opened assertion in the #61 relocation","description":"The relocation of the pointer assertions into `harness/tests/contract.spec.js` (PR #60) dropped `await expect(page.locator(\"#grid input\")).toHaveCount(0)` from the formula-row click assertions.\n\nReviewer checked whether it matters and it mostly does not: `#mode` is asserted with `toHaveText(\"FORMULAS\")`, which is exact, and `mode_label(AppMode::FormulaEdit)` is `\"FORMULA\"` — so an invented activation (opening an editor on a formula-row click) still fails on the mode assertion.\n\nFiled so the drop is deliberate rather than incidental. Two options when picking it up:\n\n1. Restore the assertion, on the grounds that \"no editor opened\" is the property the test is named for and the mode label catching it is a coincidence of two strings differing by one character.\n2. Decide the mode assertion is the guard and record that, so nobody restores it as a reflex.\n\nEither way the thing to preserve is the ARGUMENT in `context/code/clients.md`: the formula panel binds no `Enter`, so a click activates nothing, and the discriminating guard is `the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does` in `improvise-protocol/src/resolve.rs` — which asserts the KEYMAP rather than the click. That is what catches the real drift; the browser assertion is confirmation that the decision survives the wire.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:12:31Z","created_by":"wave3-webui","updated_at":"2026-09-20T21:12:31Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -317,14 +325,15 @@ {"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9d5k","title":"Two small wrong claims in kb-merge.pl's own comments","description":"Both found by review on PR #69, both one-line fixes, both are claims ABOUT A CHECK sitting where nothing verifies them -- which is the subject of the PR they arrived in.\n\n1. kb-test.sh case 10's comment says 'under unification conflicts sees the non-ground key match both facts and refuses the add outright, so the reverted fix fails HERE.' DEMONSTRATED false: reverting conflicts to unification leaves case 10 PASSING and fails case 17 instead. A rule now keys on its whole clause, so the ':-' term cannot unify with probe_fact(a) and conflicts never fires there. The site is guarded, by a different case, so this is a wrong explanation rather than a hole.\n\n2. replace's new refusal message says '...so it would ADD them beside the ones already there' even when the predicate has no existing clauses. Behaviour is right and fail-safe; only that clause of the sentence is wrong in that arm.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T00:11:27Z","created_by":"orchestrator","updated_at":"2026-09-21T00:11:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-r08u","title":"IS and WHERE differ on trailing word boundary in formula.pest","description":"improvise-4zl7 gave `IS` a trailing word-boundary lookahead: `is_expr` is compound-atomic so `Region ISLAND` no longer parses as `Is(Region, LAND)`. `^\"WHERE\"` in `where_clause` and `inline_where` still has the identical hole — `SUM(Rev WHEREAS x = y)` would read as a WHERE on a category `AS`. It is far less exposed than IS was (WHERE sits in one fixed position after an expression, where IS follows any identifier in expression position), which is why it was left alone rather than fixed in the same pass: it is pre-existing and changing it is a grammar change with its own round-trip risk.\n\n## Steps to Reproduce\n\n1. `parse_formula(\"X = SUM(Rev WHEREAS x = y)\", \"Cat\")`\n2. It parses, reading `AS` as the filter category, where it should be a syntax error.","acceptance_criteria":"Either `^\"WHERE\"` gets the same `!bare_char` lookahead `IS` has and a test asserting `WHEREAS` does not parse as a WHERE clause, or the asymmetry is declined in writing in context/code/formula-and-io.md with the reason. The grammar-walking generator proptests must still pass either way.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:16:46Z","created_by":"wave3-4zl7","updated_at":"2026-09-20T19:16:46Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1l9o","title":"furnished_client hand-builds a basis instead of taking the server's","description":"A nit from the adversarial review of PR #50, explicitly not worth a push at\nthe time.\n\nlockstep.rs's furnished_client builds its BasisCache by walking the client's\nown grid for Record cells and calling basis::published(0, 0, records) with\nevery version hard-coded to 0. That is a small re-implementation of\nproject.rs::basis_update's record selection, in a test whose entire purpose is\nto rule out re-implementations.\n\nIt is harmless today -- a fresh App really is at generation 0, layout 0, and\nevery record at version 0 -- which is exactly why it will stop being harmless\nsilently if any of those three stops being true.\n\nThe reviewer demonstrated the alternative while checking something else: seed\nthe ClientState from a real Sessions::connect snapshot and let the server's own\nProjection::Basis populate the cache. That removes the fabrication and makes\nthe fixture a client the server actually produced.\n\nNote the same fabrication exists in client_ready_to_edit, which predates this\nPR, so a fix should take both.","acceptance_criteria":"The lockstep fixtures get their basis from a real Sessions::connect\nsnapshot rather than constructing one. Both furnished_client and\nclient_ready_to_edit, since they share the fabrication. The tests that use\nthem must still pass unchanged -- if seeding from the server changes an\noutcome, that is a finding about the fixture and worth stopping for.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:58:19Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:58:19Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-thpe","title":"help::compact discriminates a chord by parsing a string it formatted","description":"Finding 3 of the adversarial review of PR #50. Not live-broken and\ndeliberately not fixed in that PR.\n\nimprovise_protocol::help::compact drops chorded keys from a status-bar hint by\ntesting whether the formatted label starts with \"Ctrl-\" or \"Alt-\". The\nreviewer enumerated every format_key_label output and none begins with either,\nso there is no false positive today.\n\nThe defect is the shape, not the behaviour. keymap.rs::labels_for HAS the\nstructured fact -- it matches KeyPattern::Key(code, mods) and then throws mods\naway into a string -- and compact then reconstructs a worse version of it by\nparsing. A future modifier spelled any other way (Super-, Cmd-, a bare glyph)\nis silently not a chord, lands in the status bar, and nothing fails.\n\nThe fix is to carry the fact rather than re-derive it: either Hint keeps the\nKeyModifiers beside each label, or labels_for returns (KeyCode, KeyModifiers)\npairs and help.rs formats at the end. The second is tidier and is what\nwhich_key already does with key_label(code, *mods).\n\n## Steps to Reproduce\n1. Add a modifier to keymap.rs::key_label spelled with any prefix other than\n \"Ctrl-\" or \"Alt-\" -- \"Super-x\", say.\n2. Bind a command advertised by a mode's hint to that chord only.\n3. hint_line puts it in the status bar. Nothing fails.","acceptance_criteria":"compact stops parsing a string to learn something the keymap already\nknew: either Hint carries the KeyModifiers beside each label, or labels_for\nreturns (KeyCode, KeyModifiers) and help.rs formats last. Proved by adding a\nmodifier spelled differently and watching a test fail -- the point is that\ntoday it would not.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:36Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:45:36Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-h074","title":"Two edges of App::resolve_view: a worse panic message, and a host-side DeleteView bypass","description":"Found by the adversarial review of PR #48 (improvise-jytm). Two small things, both\nabout what happens at the edges of the fallback improvise-jytm introduced.\n\n**The panic message got worse.** `App::resolve_view` falls back to\n`workbook.active_view` for a name the document has not got, then indexes\n`workbook.views[default]`. `Workbook::active_view()` — which several of these call\nsites used before — carries `.expect(\"active_view always names an existing view\")`.\n`IndexMap`'s `Index` impl panics with no such message. So a `Workbook` whose\n`active_view` names nothing (which only a hand-built or deserialized-from-elsewhere\nworkbook can be, since `create_view` and `delete_view` both maintain it) now fails\nwith a bare index panic where it used to name the invariant that was broken. Cheap\nfix: keep the `.expect` string.\n\n**A host-initiated `DeleteView` bypasses `adopt_deleted_views`.** That function runs\nin `Sessions::apply_model`, which is the door a *client's* command comes through. A\nhost driving `App::apply_effects` directly — through `Host::app_mut()` — deletes a\nview without any session mirror being moved off it.\n\n**Read the unreachability argument carefully, because it is weaker than it looks.**\nA first version of this ticket said `app_mut()` had one caller, in\n`improvise-ws-server/src/main.rs`, at startup, before any client had connected. That\nwas wrong, and review caught it. The one caller is\n`crates/improvise-worker-server/src/worker.rs:64`, inside `persist()` — which runs\n**after every accepted model command, with a client connected**. The bypass is still\nunreachable, but only because `persist()` happens to pass `effect::Save`, which\ndeletes no view. \"The one existing caller passes a harmless effect\" is a much thinner\nguarantee than \"nobody is connected yet\", and it is the exact shape of premise this\nrepo has been burned by: gotcha 47 records that the lost update `CheckedBatch` later\nfixed had shipped under an unreachability argument too.\n\nThe second is the interesting one to decide rather than to patch: it is the general\nshape of \"session bookkeeping that only the session door performs\". `Host::project`\nis already where every outbound path funnels, so whether the adoption belongs there\ninstead is the design question.\n\n## Steps to Reproduce\n\nThe panic message:\n\n1. Build a `Workbook` whose `active_view` names a view not in `views` (only reachable\n by constructing the struct directly — the constructors maintain the invariant).\n2. Call `App::view()`. It panics from `IndexMap`'s `Index` impl with no message,\n where `Workbook::active_view()` would have said which invariant broke.\n\nThe bypass:\n\n1. A client connects and switches to \"Second\".\n2. The host calls `host.app_mut().apply_effects(vec![Box::new(DeleteView(\"Second\"))])`\n — the same door `improvise-worker-server`'s `persist()` uses for `effect::Save`\n after every accepted model command.\n3. The session's mirror still names \"Second\". Nothing moved it and nothing told the\n client, because `Sessions::adopt_deleted_views` runs only in `apply_model`.\n","acceptance_criteria":"App::resolve_view's fallback index carries the invariant message Workbook::active_view() had. And a decision is recorded on whether session adoption belongs at the Sessions door or at Host::project — with a test for a host-initiated DeleteView if the answer moves it.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:48:10Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-x4wv","title":"assemble.sh's stamped guard under-reports multiple broken substitutions, and its comment reads as though it does not","description":"Found by injection during PR #47 (the landing-page inversion), by the WU-P agent.\n\nweb/assemble.sh's four 'stamped' checks exist because one grep over four files succeeds when ANY one of them matches, so three failed substitutions could still produce a successful assembly under set -e (found by Copilot on PR #24). The comment says each stamped reference 'is verified on its own and names itself when it is the one missing'.\n\nInjection result: breaking ONE substitution (a wrong sedi pattern for worker.js) produces exactly the claimed behaviour —\n\n assemble: .../app/worker.js is missing the cache-bust stamp: worker-pkg/improvise_worker_server.js?v=inj\n\nwith the app.js guard ahead of it passing and printing its match. Correct and isolated.\n\nBut 'stamped' calls exit 1 on the FIRST failure, so breaking BOTH app.js and worker.js reports app.js alone. The phrase 'when it is the one missing' is accurate for a single failure and misleading for a multiple one: a reader takes it as 'each missing stamp names itself'.\n\nThis FAILS SAFE — the build stops and a real problem is reported — so it is P4, not a correctness bug. It is filed because the prose is what a future agent will act on, and because the original guard was itself the product of a review finding about a check that passed when it should not have.\n\nDeliberately NOT fixed in PR #47: pre-existing, out of that unit, and reshaping stamped would rewrite a guard whose current form came out of the PR #24 finding. The cheap fix is probably the comment, not the code.\n\n## Steps to Reproduce\n\n1. In web/assemble.sh, corrupt the sedi patterns for BOTH app.js and worker.js.\n2. Run web/assemble.sh into a scratch dir.\n3. Only the app.js failure is reported; the worker.js one is never reached.","acceptance_criteria":"Either stamped reports every missing stamp before exiting, or its comment says plainly that it reports the FIRST failure only. Demonstrated by breaking two substitutions and seeing both named, or by the comment matching the single-report behaviour.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T18:14:57Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gcvb","title":"The browser cannot commit a records-mode creating column, though it is now nameable","description":"`keys.rs::commit` returns early for EVERY records-mode commit:\n\n if state.view.mode.is_records() { return None; }\n\nThe comment gives the real reason — \"a category column renames every measure\ncell in the row, which needs `record_col`\" (gotcha 1) — and that reason is\nsound for the coordinate columns and for the measure columns' advance, which\n`CommitAndAdvance` handles with `AddRecordRow` at the bottom-right and a\n`RebuildLayout` when `+cat` inserts a column.\n\nBut the guard is broader than its reason. improvise-poia made the two CREATING\ncolumns nameable: a cell at the cursor now projects `CellId::Create(Measure)`\nor `CellId::Create(Category)`, and committing one is a single existing\n`ModelCommand` over the typed NAME — `AddItem { category: \"_Measure\", item }`\nor `AddCategory(name)`. Neither reads `record_col`, neither touches the record\nunder the cursor, and neither writes a cell. So this is the one records-mode\ncommit a thin client could already compose, and the early return is what stops\nit.\n\nWhat has to be answered before it lands, and why it is not a one-liner:\n\n- The ADVANCE differs. `CommitAndAdvance` returns straight after a `+cat`\n commit with a `RebuildLayout` and no cursor move, because an inserted column\n shifts every index to its right and the advance was computed from the\n pre-commit layout (gotcha 23). A client that advanced anyway would land one\n column off, silently.\n- `AddCategory` can be REFUSED (the 12-category limit), and it sets\n `abort_effects` (gotcha 21). A client composing `[AddCategory, ChangeMode]`\n is composing a batch whose first step may abort — which is exactly the hole\n clause 4 of gotcha 47 leaves open.\n\nBoth are arguments for doing this with a lockstep test against\n`CommitAndAdvance`, not by reading the resolver and hoping.\n","acceptance_criteria":"keys.rs::commit composes AddItem/AddCategory when the cell at the cursor is CellId::Create, instead of returning None for the whole of records mode — and a tests/lockstep.rs case proves the client and CommitAndAdvance land the document AND the cursor in the same place, including the +cat case where an inserted column means the TUI does not advance at all. The coordinate and measure columns stay the host's; the early return narrows rather than disappears.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:13:31Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-x4wv","title":"assemble.sh's stamped guard under-reports multiple broken substitutions, and its comment reads as though it does not","description":"Found by injection during PR #47 (the landing-page inversion), by the WU-P agent.\n\nweb/assemble.sh's four 'stamped' checks exist because one grep over four files succeeds when ANY one of them matches, so three failed substitutions could still produce a successful assembly under set -e (found by Copilot on PR #24). The comment says each stamped reference 'is verified on its own and names itself when it is the one missing'.\n\nInjection result: breaking ONE substitution (a wrong sedi pattern for worker.js) produces exactly the claimed behaviour —\n\n assemble: .../app/worker.js is missing the cache-bust stamp: worker-pkg/improvise_worker_server.js?v=inj\n\nwith the app.js guard ahead of it passing and printing its match. Correct and isolated.\n\nBut 'stamped' calls exit 1 on the FIRST failure, so breaking BOTH app.js and worker.js reports app.js alone. The phrase 'when it is the one missing' is accurate for a single failure and misleading for a multiple one: a reader takes it as 'each missing stamp names itself'.\n\nThis FAILS SAFE — the build stops and a real problem is reported — so it is P4, not a correctness bug. It is filed because the prose is what a future agent will act on, and because the original guard was itself the product of a review finding about a check that passed when it should not have.\n\nDeliberately NOT fixed in PR #47: pre-existing, out of that unit, and reshaping stamped would rewrite a guard whose current form came out of the PR #24 finding. The cheap fix is probably the comment, not the code.\n\n## Steps to Reproduce\n\n1. In web/assemble.sh, corrupt the sedi patterns for BOTH app.js and worker.js.\n2. Run web/assemble.sh into a scratch dir.\n3. Only the app.js failure is reported; the worker.js one is never reached.","acceptance_criteria":"Either stamped reports every missing stamp before exiting, or its comment says plainly that it reports the FIRST failure only. Demonstrated by breaking two substitutions and seeing both named, or by the comment matching the single-report behaviour.","status":"closed","priority":4,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T23:35:12Z","started_at":"2026-09-20T23:22:06Z","closed_at":"2026-09-20T23:35:12Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gcvb","title":"The browser cannot commit a records-mode creating column, though it is now nameable","description":"`keys.rs::commit` returns early for EVERY records-mode commit:\n\n if state.view.mode.is_records() { return None; }\n\nThe comment gives the real reason — \"a category column renames every measure\ncell in the row, which needs `record_col`\" (gotcha 1) — and that reason is\nsound for the coordinate columns and for the measure columns' advance, which\n`CommitAndAdvance` handles with `AddRecordRow` at the bottom-right and a\n`RebuildLayout` when `+cat` inserts a column.\n\nBut the guard is broader than its reason. improvise-poia made the two CREATING\ncolumns nameable: a cell at the cursor now projects `CellId::Create(Measure)`\nor `CellId::Create(Category)`, and committing one is a single existing\n`ModelCommand` over the typed NAME — `AddItem { category: \"_Measure\", item }`\nor `AddCategory(name)`. Neither reads `record_col`, neither touches the record\nunder the cursor, and neither writes a cell. So this is the one records-mode\ncommit a thin client could already compose, and the early return is what stops\nit.\n\nWhat has to be answered before it lands, and why it is not a one-liner:\n\n- The ADVANCE differs. `CommitAndAdvance` returns straight after a `+cat`\n commit with a `RebuildLayout` and no cursor move, because an inserted column\n shifts every index to its right and the advance was computed from the\n pre-commit layout (gotcha 23). A client that advanced anyway would land one\n column off, silently.\n- `AddCategory` can be REFUSED (the 12-category limit), and it sets\n `abort_effects` (gotcha 21). A client composing `[AddCategory, ChangeMode]`\n is composing a batch whose first step may abort — which is exactly the hole\n clause 4 of gotcha 47 leaves open.\n\nBoth are arguments for doing this with a lockstep test against\n`CommitAndAdvance`, not by reading the resolver and hoping.\n","acceptance_criteria":"keys.rs::commit composes AddItem/AddCategory when the cell at the cursor is CellId::Create, instead of returning None for the whole of records mode — and a tests/lockstep.rs case proves the client and CommitAndAdvance land the document AND the cursor in the same place, including the +cat case where an inserted column means the TUI does not advance at all. The coordinate and measure columns stay the host's; the early return narrows rather than disappears.","status":"closed","priority":4,"issue_type":"task","assignee":"claude-wasm-keys-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-21T00:24:03Z","started_at":"2026-09-20T23:21:16Z","closed_at":"2026-09-21T00:24:03Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-trd3","title":"Help pages clamp at both ends; decide whether they should wrap","description":"Split out of improvise-e24v, where the divergence was found and closed the\nconservative way.\n\neffect::HelpPageNext clamps at HELP_PAGE_COUNT - 1 and HelpPagePrev saturates\nat 0. reduce_view's Command::HelpPage arm wrapped modulo HELP_PAGE_COUNT. Two\nderivations of one fact, disagreeing, and invisible because no client had ever\nsent Command::HelpPage: the overlay's h/l were upstream keys until e24v made\nthem a client's.\n\ne24v changed the WIRE to match the EFFECT, because the effect is what ships in\nthe TUI and changing that is a product decision nobody asked for. But wrapping\nmay well be the nicer behaviour on a five-page overlay, and if it is wanted it\nhas to change in the effect *and* the reducer together.\n\n## Steps to Reproduce\n1. Open the TUI and press ? for help.\n2. Press l five times.\n3. The overlay stops on page 5 of 5. Before e24v, a browser client folding the\n same binding would have been back on page 1.","acceptance_criteria":"A ruling: either both ends clamp (today's behaviour, in which case close\nthis and the existing tests are the record), or both wrap. If wrapping wins,\neffect::HelpPageNext/HelpPagePrev and reduce_view's HelpPage arm change in one\ncommit and walking_off_the_end_of_the_help_pages_agrees_with_the_host is\nupdated with them — the point of the ticket is that these two cannot be\nallowed to disagree again.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:16Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:16Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-bgtf","title":"The landing page is served by the harness and asserted by nothing","description":"`docs/index.html` (improvise-e61) is copied into the deployed site at\n`/landing/` by `web/assemble.sh`, so the browser harness already serves it —\n`harness/serve` is the same assembled tree — and nothing asserts anything about\nit. That is the shape gotcha 48 names: argued and read, never executed.\n\nWHAT A CHECK MUST DO, and the reason this bead exists rather than a one-liner:\ncounting the four `.ap-player` elements PROVES NOTHING. `autoPlay` is false, so\nasciinema-player does not fetch a cast until the viewer presses play. A page\nwhose every cast path 404s constructs four players, throws no page error before\nthe click, and looks identical to a working one. This was verified by injecting\nexactly that fault (`./casts/` -\u003e `./cats/`) while building the page: the\nconstruction-only check passed on the broken page; pressing play and counting\n`.ap-line` returned 0 and surfaced four 404s.\n\nSo the spec must press play on each of the four and assert rendered lines, and\nits own fault-injection must be recorded in `context/test-evidence/`.\n\nSCOPE: one spec beside `harness/tests/shell.spec.js`, Chromium only (nothing\nhere is engine-dependent), navigating to `/landing/index.html`.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/landing/index.html` — it renders, and no test\n in the repository would have noticed if it had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page\n still looks structurally correct until a play button is pressed.","acceptance_criteria":"A Playwright spec beside harness/tests/shell.spec.js loads /landing/index.html, presses play on each of the four casts, and asserts each renders a non-zero number of terminal lines with no failed request and no page error. The spec is proved able to fail by breaking one cast path and watching it fail, and that injection is recorded in context/test-evidence/.","notes":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.\nWITHDRAWN FROM THIS TICKET: the 199-line budget paragraph above.\n\nIt was added here on the reasoning that \"a 200-line limit met by one line with\nnothing checking it\" is an instance of this ticket's one fact. On review that is\nthe wrong home: the better answer is to rewrite improvise-e61's criterion, since\nthe line count was only ever a proxy for single file / vanilla / no build step /\nno framework — properties nothing threatens and which a line count measures\nbadly. That has been done on improvise-e61.\n\nThis ticket is about the landing page being SERVED and ASSERTED BY NOTHING, and\nit should stay that. Read the paragraph above as superseded; the rest of the\nnotes — press play, count rendered lines, resolve the call-to-action href — is\nwhat this ticket is for.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:56Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bgtf","title":"The landing page is served by the harness and asserted by nothing","description":"`docs/index.html` (improvise-e61) is copied into the deployed site at\n`/landing/` by `web/assemble.sh`, so the browser harness already serves it —\n`harness/serve` is the same assembled tree — and nothing asserts anything about\nit. That is the shape gotcha 48 names: argued and read, never executed.\n\nWHAT A CHECK MUST DO, and the reason this bead exists rather than a one-liner:\ncounting the four `.ap-player` elements PROVES NOTHING. `autoPlay` is false, so\nasciinema-player does not fetch a cast until the viewer presses play. A page\nwhose every cast path 404s constructs four players, throws no page error before\nthe click, and looks identical to a working one. This was verified by injecting\nexactly that fault (`./casts/` -\u003e `./cats/`) while building the page: the\nconstruction-only check passed on the broken page; pressing play and counting\n`.ap-line` returned 0 and surfaced four 404s.\n\nSo the spec must press play on each of the four and assert rendered lines, and\nits own fault-injection must be recorded in `context/test-evidence/`.\n\nSCOPE: one spec beside `harness/tests/shell.spec.js`, Chromium only (nothing\nhere is engine-dependent), navigating to `/landing/index.html`.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/landing/index.html` — it renders, and no test\n in the repository would have noticed if it had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page\n still looks structurally correct until a play button is pressed.","acceptance_criteria":"A Playwright spec beside harness/tests/shell.spec.js loads /landing/index.html, presses play on each of the four casts, and asserts each renders a non-zero number of terminal lines with no failed request and no page error. The spec is proved able to fail by breaking one cast path and watching it fail, and that injection is recorded in context/test-evidence/.","notes":"PR #72. harness/tests/landing.spec.js presses play on all four casts and asserts rendered lines plus four distinct 200s; ./casts/ -\u003e ./cats/ fails it. Note the bead's path is stale: the landing page is at the site ROOT (/index.html) since improvise-kh8, not /landing/.","status":"closed","priority":4,"issue_type":"task","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T00:24:05Z","started_at":"2026-09-20T23:21:57Z","closed_at":"2026-09-21T00:24:05Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5jm5","title":"Model::evaluate_aggregated takes a list it only tests for emptiness","description":"Found by CC-core while capping comments (2026-09-20). Not filed as a bug because nothing misbehaves today; filed because the signature promises something the body does not do.\n\n`Model::evaluate_aggregated` takes `none_cats: \u0026[String]` and only ever tests `none_cats.is_empty()`. The aggregation itself is over `key.0` via `matching_values`. So the parameter is a BOOLEAN WEARING A LIST'S TYPE.\n\n## Steps to Reproduce\n\n1. Read `Model::evaluate_aggregated`'s signature: it takes a slice of category names.\n2. Read the body: the slice is consulted once, for emptiness. No element is ever examined.\n3. Pass a list with different contents but the same emptiness — the result is identical.\n\n## Why it is worth an hour\n\nThe name promises the function filters by that list. It does not. A caller reasoning about which categories are on `Axis::None` will believe this function honours them, and the next person to add a case will thread a carefully-built list into a parameter that cannot use it. That is the same shape as `prune_axis_items` taking a flag vector before it took a predicate: a parameter whose type admits states the body cannot distinguish.\n\n## The fix is a decision, not a rename\n\nEither the emptiness test is the real dispatch — in which case take a `bool` (or better, an enum saying what the two states MEAN) and let the caller compute it — or the function was meant to filter and does not, which is a behaviour question and needs a test first. Establish which before touching it; `git log -S` on the parameter will say whether it ever filtered.\n","acceptance_criteria":"evaluate_aggregated's parameter admits only the states the body can distinguish — a bool or a named enum if the emptiness test is the real dispatch, or a filter that actually filters with a test proving it. Establish which it was MEANT to be first: git log -S on the parameter says whether it ever examined an element.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T16:53:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:36Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-bidq","title":"Two answers to 'is this category virtual', four lines apart in view/types.rs","description":"Found by the post-wave code consistency review. PRE-EXISTING, carried across unchanged by PR #27's 8173b87.\n\ncrates/improvise-core/src/view/types.rs:112 and :118 test whether a category is virtual with `cat_name.starts_with('_')`. Four lines away, `is_records_layout()` (:67-74) uses `CategoryKind::of_name(..)`, and `CategoryKind` is imported at :6.\n\nTwo answers to \"is this category virtual\", in one file, four lines apart.\n\n## Why it is reachable rather than theoretical\n\n`Model::add_category` does not validate names. So a user category literally named `_Foo` is:\n * `CategoryKind::Regular` to records_columns, cell_id and is_empty_model;\n * \"virtual\" to these two lines — which would seat it on Axis::None and it would never appear in a pivot.\n\ndesign-principles §2 is explicit that business rules go through the enum rather than a name prefix, and names this exact class: \"enforced by matching on the enum, not by checking name prefixes\".\n\n## Note\n\nThe cheap fix is to use CategoryKind at both sites. The better question, worth one line of thought first: should `add_category` refuse a leading underscore outright? That would make the divergence unrepresentable rather than merely consistent — but it is a user-visible restriction and belongs in the commit message either way.\n\n## Steps to Reproduce\n\n1. `Model::add_category(\"_Foo\")` — the name is not validated.\n2. `CategoryKind::of_name(\"_Foo\")` makes it `Regular`, so `records_columns`, `cell_id` and `is_empty_model` treat it as an ordinary user category.\n3. `view/types.rs:112` and `:118` test `cat_name.starts_with('_')`, so *they* treat it as virtual — and seat it on `Axis::None`.\n4. The category therefore never appears in a pivot, while every other consumer believes it is a normal one.\n\nFour lines away, `is_records_layout()` (`:67-74`) asks `CategoryKind`, and `CategoryKind` is imported at `:6`.\n","acceptance_criteria":"Both sites use CategoryKind, so there is one answer. Decide explicitly whether Model::add_category should refuse a leading underscore — if yes, the divergence becomes unrepresentable and the restriction is stated in the commit; if no, say why a user category named _Foo is allowed and now behaves consistently.","notes":"SHARPENED by CC-core (comment wave, 2026-09-20), and the trap is subtler than this ticket stated.\n\nIt is not two sites four lines apart. View::on_category_added's early return is:\n\n cat_name.starts_with('_') || self.is_records_layout()\n\nso THE SAME EXPRESSION mixes both derivations, ONE OPERATOR APART -- the left operand tests a name prefix and the right operand asks CategoryKind. A reader checking this line sees the enum being used and has no reason to look at the operand beside it.\n\nConfirmed still present at the comment wave's base commit; deliberately untouched (a behaviour change is out of that wave's scope). The comment residue there no longer asserts either spelling, and context/code/core.md section 7 records it as an open defect.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:34Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:44:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 59f582fa..de9fd46f 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -95,9 +95,13 @@ From `design-principles.md` §7 and `doctrine/2`: ## Verifying +Run the gate **CI runs**, flags and all. Clippy exits 0 on warnings, so without +`-D warnings` this file was green on exactly the input CI is red on — a check +that cannot fail in the direction the defect lies. + ```bash -cargo test --workspace # must stay under ~2s -cargo clippy --workspace --all-targets +cargo test --workspace --no-fail-fast # fail-fast hides the blast radius +cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all --check ``` diff --git a/README.md b/README.md index 9127ccb8..9e3af633 100644 --- a/README.md +++ b/README.md @@ -433,9 +433,10 @@ Caveat, narrowed twice: `harness/` runs `web/app.js` under Chromium — the standalone deployment under Firefox too — and `OpfsFs` under Chromium and Firefox, in CI. The grouped and multi-level DOM is executed since improvise-plcx, against a fixture document the harness serves beside the demo. -What is still never executed is `bindings.rs`, the CSS and the -`ResizeObserver` arithmetic — and Safari, whose Playwright build provides no -OPFS at all. +What is still never executed is the CSS and the `ResizeObserver` arithmetic — +and Safari, whose Playwright build provides no OPFS at all. `bindings.rs` **is** +run: the no-`?ws` test loads the app, which spawns the worker, which calls +`start()` and `OpfsFs::open()`. ## What's interesting about the architecture diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index 24ff75bb..f98c6fc2 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -222,6 +222,7 @@ doctrine(a_key_rule_must_ask_whether_the_relation_is_multi_valued,'scripts/kb ke doctrine(a_local_shell_is_not_the_ci_shell,'zsh ABORTS on an unmatched glob and bash leaves it as a literal, so a CI step written and probed in zsh can fail in a way no developer shell can reproduce. node --test web/tests/*.test.js under bash therefore globbed the literal itself, matched nothing, reported 0 tests and exited 0 -- both node steps gated nothing the moment their directory moved (improvise-vz54). Local shell vs CI shell is a fault-injection axis in its own right: probe a CI line under the shell the workflow names, not the one you are typing in. The fix moves the glob into scripts/node-test.sh so the expansion is the script\'s and can be refused.'). doctrine(a_convention_that_keeps_a_checker_quiet_is_the_one_that_blinds_it,'context/test-evidence used #### for browser tests precisely BECAUSE --check-evidence resolved names against Rust fn names only, so a ### heading naming a Playwright test would be reported as an orphan. The workaround made every browser entry invisible, and a deleted test left its heading promising coverage for a whole release (improvise-5ray). Cure: collect ### and #### alike and decide by SHAPE -- a bare identifier is a Rust test, a quoted string is a JS test, anything else is prose naming nothing. When a checker forces a convention on the thing it checks, ask what that convention hides.'). doctrine(a_refusal_is_evidence_only_when_it_is_the_one_you_named,'Two kb-test cases passed against a tree with the fix they guarded reverted, because a DIFFERENT guard refused the same delta and the assertion was only \"fails, and the kb is unchanged\" (review of PR #69). A tool with several guards will usually refuse a broken delta somehow, so \"it was refused\" is nearly free and says nothing about the fix under test. Assert the REFUSAL MESSAGE, or construct a case only the fix can catch -- and prove it by reverting that one fix and watching the case fail. The same trap in reverse: a case asserting an OUTCOME (the add lands and every sibling survives) can discriminate where a message cannot, because what a wrong key destroys is the outcome.'). +doctrine(a_weak_check_and_a_strong_one_agree_until_the_defect,'Five instances in one wave, and they are one mistake rather than five: a guard watching KeyOutcome::Upstream but not Ignored, clippy with versus without -D warnings, a grep for \"warning[\" versus a bare \"warning:\", toBeVisible versus toHaveText, and a grep for test( versus node own pass count. In every case the weak check and the strong one AGREE ON EVERY INPUT ANYONE TRIED and diverge only on the input that is also the defect -- which is why the weak one survives review and why running it proves nothing. The common cause is a check written against the failure you IMAGINED rather than the outcome the USER SEES. The repair is the same each time: assert over the observable -- is anything on the screen, did the process exit non-zero, did the document move -- rather than over the mechanism, which variant, which log prefix, which substring. Two things in this wave got it right and are the pattern to copy: furnished() seats the panel cursors off the edge so move-panel-cursor can answer the question rather than the test agreeing not to ask it, and scripts/node-test.sh reads node own report instead of grepping for test(.'). 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.'). From d2a54ac3203d2103b55e82cf15d54218d71636e2 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 19:40:33 -0700 Subject: [PATCH 5/6] kb: restore six doctrines a hard reset discarded, and record how Consolidates four doc branches and restores what resyncing lost. I wrote doctrines to kb.pl on local main, pushed each as a branch, then ran `git reset --hard gh/main` twice to resync -- discarding six clauses that existed only in the unmerged local commits. Nothing failed: kb_consistent passed on the reduced file, the counts simply read lower, and the loss was found only by grepping for clause names I happened to remember writing. That is the same shape as everything else this wave found. A count that looks plausible is not evidence, and the check that would have caught it -- diff the clause NAMES, not the total -- is the one this repo already applies to tests after a merge. doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch) states the two rules: write a fact once, on the branch that will carry it to main, not on a local main that is going to be resynced; and after any reset or force-move of a branch holding knowledge, re-count and diff clause names. Restored: every_wave_agent_gets_its_own_worktree, the_orchestrator_delegates_substantial_work, a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary, run_the_gate_ci_runs, an_observation_correct_about_what_changed_can_be_wrong_about_when_it_is_read, a_siblings_unmerged_pr_is_invisible_to_bd_search. Also carries: the wave skill, the Copilot-gate correction to AGENTS.md, the help evidence rename, and the code-review skill's missing -D warnings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH --- .beads/issues.jsonl | 2 +- prolog-sessions/kb.pl | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index d4cedf56..979b039e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -50,7 +50,7 @@ {"_type":"issue","id":"improvise-4ibz","title":"The TUI launch frame is untested, and it now carries a decision","description":"PR #60 changed `ViewState::default()` to `category_panel_open: true` on a user ruling (\"the categories panel should be visible at launch\"), implemented as the SHARED default rather than a browser special case — a browser-only default is \"the page overrides the flag\", which is the same defect as the \"the page ignores the flag\" bug improvise-wsaw fixed.\n\nThe consequence, accepted deliberately and flagged at the top of that PR's body: **`improvise model.improv` now opens with the category sidebar showing**, because `App::new` builds `..ViewState::default()` and `src/draw.rs` draws the panel off that same field.\n\nTwo gaps that PR deliberately did not grow to cover.\n\n**1. No TUI test covers the launch frame at all.** The browser half is asserted end to end in `harness/tests/contract.spec.js` (visible with no keypress, and `C` still hides it — two states, because either alone passes against the wsaw bug). The terminal half is asserted nowhere: nothing renders a first frame and looks at it. So the sidebar could vanish from the TUI, or appear when it should not, with the suite green. That is not a regression this PR introduced — the launch frame was never covered — but the PR is what made the launch frame carry a decision.\n\n**2. The panel-focus cycle with all three panels open is now untested, and the uncovered case MOVED rather than shrank.** `panel_focus_cycles_from_the_focused_panel_not_the_first` used to inherit \"category panel closed\" from the default; it now states that precondition explicitly, which is the right fix and preserves what the test is for (skipping a *closed* panel). But the state nothing covers is now the launch-adjacent one: all three open. Worth a case.\n\nNote what should NOT be done here: do not re-derive the launch state from anywhere else. It is stated in exactly one place (`ViewState::default()`), asserted by exactly one Rust test (`a_fresh_session_opens_with_the_category_panel`, which also asserts the other two panels stay shut so it cannot pass against a default that opens everything), and reverting it fails exactly that one test under `--no-fail-fast`. That shape is correct and a second statement of the fact would be the disease.\n\n**This ticket is also the place to record a refinement if the user scopes the ruling back to the browser.** If they do, the change is NOT \"special-case the browser\" — it is to give the two front ends different *initial* values through something that is not a shared default, and the argument for why that is not a divergence would have to be made first.\n\nAcceptance: a TUI test that renders the first frame and asserts the category panel is drawn; the all-three-open cycle case; both proved by injection.\n","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:01:46Z","created_by":"wave3-webui","updated_at":"2026-09-20T21:10:59Z","closed_at":"2026-09-20T21:10:59Z","close_reason":"User ruled BOTH (TUI + browser); TUI launch frame now tested in tests/tui_launch_state.rs","comments":[{"id":"547f7ff6-32fa-556f-8479-9c6be120a2d9","issue_id":"improvise-4ibz","author":"wave3-webui","text":"ANSWERED by the user, 2026-09-20, relayed through the orchestrator: **BOTH**. The category sidebar is the launch default in the TUI as well as the browser. `improvise model.improv` opening with the sidebar showing is intended, not collateral. The behaviour PR #60 already had is the behaviour wanted; no narrowing to the web.\n\nBoth gaps this ticket named are now closed in #60, so it closes rather than staying open as a question.\n\n**1. The TUI launch frame is tested.** `tests/tui_launch_state.rs` (root crate — the TUI's own crate), three tests:\n- `the_terminal_app_launches_with_the_category_panel_open`\n- `the_other_two_panels_are_shut_at_launch` — so \"open everything at launch\" cannot pass the first\n- `the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it` — walks `Panel::ALL` comparing `App::new` against `ViewState::default()`, which is what would catch a TUI-only launch state being reintroduced (the divergence improvise-wsaw closed)\n\nNot a rendered-frame test: design-principles §7 says not to test ratatui `Widget::render`, and there is no `TestBackend` anywhere in the repo. The honest target is the TUI's own constructor; `src/draw.rs`'s one-line read of the flag stays argued-not-tested per gotcha 48. Proved by injection — putting the default back to `false`, and separately making `App::new` override it, each fail the expected tests and only those.\n\n**2. The all-three-panels-open cycle: deliberately NOT added, and the reviewer's reasoning is better than the diff-size one I gave.** `panel_focus_cycles_from_the_focused_panel_not_the_first` is about *skipping a closed panel*; that is its property and it is correctly pinned by its now-explicit precondition. A three-open case tests plain rotation, which the two-open assertions already establish structurally. Not a gap.\n\n**What this ticket did not predict, and is the most valuable thing it produced.** A launch default **disarms every test that asserted the thing the default now supplies**. Three tests asserted `category_panel_open` was *true* after doing something to make it true, and all three then passed without the code under them working:\n\n- `set_panel_open_and_cursor` (`ui/effect.rs`) — the serious one. With it vacuous, `effect::SetPanelOpen`'s Category arm could be replaced by a **no-op** — `C` doing nothing in the terminal — and the entire workspace stayed green at 1391 tests.\n- `panel_commands_address_the_named_panel_only` (`reduce.rs`) — the browser's copy of the same mapping, vacuous the same way. **Its first repair was ALSO vacuous**, which is the subtle part: folding `[open:false, open:true]` and asserting the final state is *true* still passes against a reducer that folds nothing, because the default already supplies `true`. Only asserting the **`false`** direction discriminates.\n- `panel_focus_cycles_from_the_focused_panel_not_the_first` — inherited its precondition from the default; now states it.\n\n**The rule to carry: assert the direction the default cannot supply.** Closing the panel is the discriminating move; opening it proves nothing. Every one of these was caught by injection, not by reading — including the failed first repair, which reported NONE failing and would otherwise have shipped looking fixed.\n\nOne thing filed rather than fixed: **improvise-56ek** — `effect::SetPanelOpen` and `reduce_view`'s `Command::SetPanelOpen` are two three-way matches in two crates, so a guard on either says nothing about the other, and both had to be repaired independently here.\n","created_at":"2026-09-20T21:10:58Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-igxp","title":"The browser help overlay's rows are asserted as present, never as content","description":"Found by adversarial review of PR #61, 2026-09-20 (finding 5), and inherited\nverbatim from the test it replaced — the deleted shell.spec.js help test had\nthe same gap, so this is older than the PR that carries it now.\n\nharness/tests/contract.spec.js asserts the help overlay's rows exist and are\nvisible:\n\n await expect(page.locator(\".help-body dt\").first()).toBeVisible();\n\nIt never asserts their TEXT. Blanking `keys.textContent` in web/app.js's\npaintHelp leaves all three contract tests passing: an empty \u003cdt\u003e is a visible\n\u003cdt\u003e. The rows and their keys are improvise_protocol::help's — the whole point\nof improvise-ktv1 was that the page draws the SHARED content rather than\nspelling keys itself — so a page that draws the right NUMBER of empty rows is\nexactly the failure the test exists to catch.\n\nThis is the \"a class is a flag, and a flag is not a pixel\" shape. The same PR\ncaught one instance of it (the typed buffer was asserted by its `typing` class\nand not its caret, and the assertion was strengthened until the injection\nfailed) and did not sweep the file for siblings.\n\nFIX: assert a key row's text against something the keymap really produces —\nnot a literal the page could drift from. The tab titles already are asserted\nby text (\"Welcome\", \"Navigation\"), so the model is there.\n\nINJECTION THAT MUST FAIL AFTER THE FIX: blank `keys.textContent` in paintHelp\n(or set it to \"\"), rebuild harness/serve, run contract.spec.js. Today: 3\npassed.\n","notes":"PR #72. help.spec.js asserts text, and contract.spec.js's two help lines went from toBeVisible to text. The named injection (blank keys.textContent in paintHelp) now fails contract.spec.js and both tests in help.spec.js.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T20:53:45Z","created_by":"wave3-jstests","updated_at":"2026-09-21T00:24:04Z","started_at":"2026-09-20T23:21:56Z","closed_at":"2026-09-21T00:24:04Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lyyc","title":"check-evidence is one-directional: a test citing the evidence file can have no entry","description":"`scripts/check-comment-runs.py --check-evidence` validates one direction only: every `### name` heading in `context/test-evidence/` must name a test that exists. It never asks the reverse — that a test whose doc comment *points at* the evidence file has an entry there.\n\nThat blind spot has now been walked into for the first time. PR #59 added `the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons`, whose doc comment directs the reader to `context/test-evidence/clients.md`, and that file had no entry for it. The checker was green throughout. Worse, the same file still described the behaviour #59 *ships* as one of the two injected faults that had proved the old test — so a reader following the pointer arrived at prose that contradicted the code. Both were found by human review, which is the thing the checker exists to avoid needing.\n\nThe asymmetry in the original design is deliberate and correct as far as it goes: most tests have no evidence and should not be forced to. What is missing is the narrower obligation — **a test that names the evidence file must appear in it**. That is checkable: grep test doc comments for `test-evidence`, take the enclosing `fn` name, require a matching heading.\n\nNote the second half of the incident is not covered by that check either, and probably cannot be: an entry can exist and still be *stale*. The drill entry existed and described its own subject backwards. The only defence there is the rule that a fact stated twice is changed by a grep rather than an edit (gotcha 48), which is discipline rather than a gate. Worth saying so in the ticket so nobody implements the cheap half and believes the class is closed.\n\nAcceptance: `--check-evidence` gains the second direction; it fails on a test that cites the evidence file and has no heading; proved by deleting one heading and watching it fail.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T20:40:48Z","created_by":"wave3-webui","updated_at":"2026-09-20T23:35:12Z","started_at":"2026-09-20T23:22:06Z","closed_at":"2026-09-20T23:35:12Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-djoo","title":"Publish the context/ prose as a documentation site folded into GitHub Pages","description":"Generate a static documentation site from context/ (design-principles, repo-map, code/*, adr/*, test-evidence/*) plus docs/design-notes.md, assembled by web/assemble.sh under /docs/ alongside the landing page at the root and the app at /app/. No framework, no build step beyond one zero-dependency node script. Ticket ids resolve to a generated index built from .beads/issues.jsonl; source paths link to GitHub at the assembled ref. The generator link-checks its own output and exits non-zero on a break, so a dead cross-reference fails the assembly. context/plan.md and context/cloud-agents.md are deliberately not published.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T20:34:53Z","created_by":"wave3-docsite","updated_at":"2026-09-20T20:34:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-djoo","title":"Publish the context/ prose as a documentation site folded into GitHub Pages","description":"Generate a static documentation site from context/ (design-principles, repo-map, code/*, adr/*, test-evidence/*) plus docs/design-notes.md, assembled by web/assemble.sh under /docs/ alongside the landing page at the root and the app at /app/. No framework, no build step beyond one zero-dependency node script. Ticket ids resolve to a generated index built from .beads/issues.jsonl; source paths link to GitHub at the assembled ref. The generator link-checks its own output and exits non-zero on a break, so a dead cross-reference fails the assembly. context/plan.md and context/cloud-agents.md are deliberately not published.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T20:34:53Z","created_by":"wave3-docsite","updated_at":"2026-09-21T02:39:07Z","closed_at":"2026-09-21T02:39:07Z","close_reason":"Shipped and deployed in PR #62. The context/ prose is published at /docs/ on GitHub Pages, assembled by web/assemble.sh, with tickets.html generated from the beads export. Six adversarial review rounds; the deploy is confirmed live on main.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rmvx","title":"Six advertised keys do nothing in the browser, and the guard is scoped to one mode","description":"Found by generalising improvise-gd28's browser finding. `\u003e` was not an instance;\nit is a class. Gotcha 47 says every KeyOutcome::Upstream is a black hole —\nHost::handle answers ClientFrame::Key with an explicit empty vec — so any key\nthe app ADVERTISES and then resolves upstream does nothing at all in a browser\nand reads as broken rather than as unavailable.\n\nTHE LIST, advertised in a hint line AND unhandled by resolve::step:\n\n \u003e drill-into-cell Normal (fixed in PR #59)\n P toggle-prune-empty Normal\n R toggle-records-mode Normal\n o add-record-row RecordsNormal\n d/Del delete-formula-at-cursor FormulaPanel\n : enter-mode command Normal, RecordsNormal, FormulaPanel,\n CategoryPanel, ViewPanel, TileSelect\n\nThe `:` row is a DIFFERENT case and must not be lumped in. Its name IS handled;\nit is refused on purpose by strands_the_user, because CommandMode is a prompt\nthis client can enter but cannot commit — a dead room is worse than a locked\ndoor. So for `:` the refusal is RIGHT and only the silence is wrong. Fixing it\nmeans saying so, not implementing it.\n\nHOW THE CROSS-REFERENCE WAS MADE, because the method is reusable and the naive\nversion is wrong. help::advertised() is a table of (command, args, caption) —\nCOMMAND NAMES, not text — so the hint line can be crossed against what\nresolve::step handles exactly, with no tokenisation of the rendered string and\nno guessing which character in \"hjkl arrows :nav\" is a key. Anything that\ntokenises the hint TEXT is solving a harder problem than exists.\n\nMY FIRST PASS OVER-REPORTED AND I ALMOST BELIEVED IT. Resolving every bound key\nagainst an EMPTY ClientState flags edit-or-drill, commit-cell-edit,\ncreate-and-switch-view, page-prev/page-next and delete-formula-at-cursor as\nupstream — but most of those are handled names refused by STATE (no cell under\nthe cursor, no published basis), which is correct behaviour and not this bug.\n\"Upstream in an empty fixture\" and \"the name is unhandled\" are different\nquestions, and the first is not evidence for the second. The list above is the\nsecond, separated by checking each name against step's arms directly.\n\nAND THE NAMED LIST IS HONEST FOR ONE MODE ONLY — this is the point with reach.\nresolve.rs's `the_command_names_that_still_need_the_host_are_these` is the\nguard gotcha 47 says must be kept accurate, and it IS accurate — but it is\nbuilt with in_mode(AppMode::Normal), so it only ever asks about Normal-mode\nbindings. add-record-row (RecordsNormal) and delete-formula-at-cursor\n(FormulaPanel) are outside its reach entirely: they could never have failed it.\nA named list that silently scopes itself to one mode is a guard that LOOKS\nbroader than it is, and that is the same shape as every other defect this repo\nkeeps finding — a check passing while the thing it names is unexamined.\n\nTHE DECISION THIS TICKET WANTS, which is why nothing beyond `\u003e` was touched:\n\n (a) Do the same interim for the remaining five: each says what is true, in\n the client's existing status mechanism. Cheap, honest, reversible, and\n each needs its own TRUE wording — the `\u003e` fix was nearly routed through a\n message about aggregates, which would have been true-sounding about the\n wrong thing and worse than silence.\n (b) Implement some of them client-side instead. toggle-prune-empty and\n toggle-records-mode are view-shape changes and may be composable from\n existing ModelCommands; add-record-row and delete-formula-at-cursor\n touch the document and need checking against gotcha 38 first.\n (c) Make the class unrepresentable: a test asserting that every command named\n by help::advertised() either resolves locally or appears on a per-mode\n exemption list. That is the \"make the wrong thing impossible\" move and it\n would have caught all six at once. It is also the only option that stops\n the SEVENTH appearing.\n\nI did not pick. (c) is the one I would argue for, and it is independent of\nwhether (a) or (b) happens to the five.\n\nNot chased: whether any key is advertised in a hint for a mode whose keymap\ndoes not bind it at all. every_hint_key_resolves_in_its_mode covers that from\nthe keymap side, so it should be empty — but I did not verify it, and it is the\nmirror-image question.","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-wasm-keys-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T20:23:03Z","created_by":"wave3-webui","updated_at":"2026-09-21T00:24:01Z","started_at":"2026-09-20T23:21:14Z","closed_at":"2026-09-21T00:24:01Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","comments":[{"id":"e5a7ccf4-843d-504a-818b-283c47115c96","issue_id":"improvise-rmvx","author":"wave3-webui","text":"Precedent note (orchestrator's instruction; do NOT build this yet).\n\nThis ticket asks for a check that every key a hint line advertises actually resolves to something in a browser, so that \"advertised but Upstream\" — a black hole (gotcha 47) — fails the build instead of being discovered by a user.\n\nThat is **the same shape as two checks this repo already has**, which makes it precedent rather than novelty, and the two are worth reading before designing it:\n\n- `every_bound_command_name_is_registered` (improvise-command/src/command/keymap.rs) walks the keymaps and requires each bound name to resolve in the registry — *and* that its args parse, because a typo'd mode name otherwise compiles into a dead key.\n- `every_hint_key_resolves_in_its_mode` (same file) splits each `AppMode::hint_text()` and requires every key it names to resolve in that mode's map. Its tokenizer knows the hint dialect explicitly and **panics on a token it cannot classify** rather than skipping it — which is the detail to copy, since a checker that silently skips what it does not understand reports success for the cases it never looked at.\n\nSo the design question is not \"how do we build such a check\" but \"what is the browser's analogue of *resolves*\". For the host, resolving means the registry answers. For a browser, it means `resolve::step` answers with `Some` rather than falling through to `KeyOutcome::Upstream`. The list of keys that legitimately still need the host already exists as `the_normal_mode_keys_that_still_need_the_host_are_these`, so the check is plausibly: every key named in a hint either resolves locally or is on that named list — which also forces closing one to come and say so.\n\nOne live instance, for scale: `\u003e` was advertised and silent, and that was one of six. The one-line interim (a `SetStatus` from `step`) closed `\u003e` only; the other five were deliberately left alone pending this ticket.\n","created_at":"2026-09-20T20:40:58Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-iioi","title":"The view and formula panels have no pointer affordance while the category panel does","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated in Chromium.\n\nweb/app.js's viewRow (~:404) and formulaRow (~:422) build their \u003cli\u003e with NO click listener, where categoryList (~:453) now has one. Click (resolve.rs:71-79) has exactly three variants and no ViewRow/FormulaRow.\n\nDemonstrated: Shift+V, then click the last row of #views -\u003e NOTHING changes. Mode stays VIEWS, the row list is byte-identical before and after, the active marker does not move, the panel cursor does not move.\n\nWHY THIS IS THE ONE A USER HITS FIRST: a user who has just learned that category rows are clickable will click a view row and read the silence as broken — which is LITERALLY the bug report that produced improvise-7ybz ('I can't expand categories' meant 'clicking does nothing'). Shipping the cure for one panel and not its two siblings reproduces the original report.\n\nIt is also the CHEAP one: the resolver already has everything. view_at_cursor, switch-view-at-cursor and delete_view_at_cursor exist, and the formula panel already has a formula_cursor in reduce_view's SetPanelCursor arm. Click::ViewRow(usize) under the commit's own stated rule is a near-copy of click_category_row.\n\nADJACENT, same class, lower confidence and not chased by the reviewer: grid GROUP HEADERS (th.group) are collapse-toggles in the TUI and are not clickable here either; row/column header \u003cth\u003es likewise.\n\nNOT covered by the drill work: improvise-gd28 is refused-by-design because no wire vocabulary exists. The view and formula panels have full wire vocabulary today, so this is a different gap.\n\n## Steps to Reproduce\n\n1. Load /app/index.html, press Shift+V.\n2. Click a row in #views.\n3. Nothing changes — no switch, no cursor move, no status.","acceptance_criteria":"Clicking a view row switches to that view; clicking a formula row moves the formula cursor — each under the same rule as click_category_row (go there, then do what Enter does there). Covered by harness/tests/pointer.spec.js assertions proved against the pre-fix bundle.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"wave3-webui","owner":"el-github@elangley.org","created_at":"2026-09-20T20:18:11Z","created_by":"orchestrator","updated_at":"2026-09-20T20:20:22Z","started_at":"2026-09-20T20:20:22Z","comments":[{"id":"c306069a-89f1-55d3-ba64-0c81b8025a7a","issue_id":"improvise-iioi","author":"wave3-webui","text":"A RULING WANTED WHILE THIS TICKET IS OPEN, raised by the reviewer and sharpened\nrather than answered, because it follows from my rule and that is exactly why my\nrule should not settle it.\n\nTHE CLICK RULE I WROTE is \"go there, then do what Enter does there\". Applied to\nthe tile bar it gives: select the tile AND cycle its axis, because Enter in\nTileSelect is cycle-axis-for-tile. So there is NO click that merely selects a\ntile. A user cannot point at a tile to inspect it, or to see which one they are\nabout to act on. It is the one widget where a single click MUTATES THE DOCUMENT\n— every other click in the page moves a cursor, opens a panel, or expands a row.\n\nThat is consistent with the rule and it may still be wrong. A pointer carries an\nexpectation a keyboard does not: `T` then arrow then Enter is three deliberate\nacts, and collapsing them into one click removes the step where the user sees\nwhat they have selected before committing. The keyboard equivalent of \"click\"\nis arguably `T`+arrow (select), not `T`+arrow+Enter (select and act).\n\nTHE OPTIONS, none of which I have taken:\n (a) Keep it. One rule, no exceptions, and cycling is reversible in four\n clicks since Axis::next() is a rotation.\n (b) A tile click SELECTS only; cycling stays on Enter/Space and r/c/p. Costs\n the single-gesture affordance and makes the tile bar the one widget where\n a click does not activate.\n (c) Single click selects, double click cycles. Introduces a second pointer\n vocabulary the page does not currently have anywhere.\n\nI lean (a) on consistency grounds and distrust that lean, because it is the\nanswer my own rule produces and I am the one who wrote the rule.\n\nALSO, A CORRECTION TO MY OWN FRAMING, same source: the \"one rule\" is honestly\nTWO. click_cell does NOT do what Enter does in the grid — Enter is\nenter-advance, and a cell click moves the selection and delegates activation to\nthe page's inline editor, which is the pointer's `i` rather than its Enter. The\ncode has always done that; the commit message overstated by one widget. The\nhonest statement is: a click navigates to what was clicked, and activates it\nwhere activation is something the client can express.\n","created_at":"2026-09-20T20:23:17Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-hkr8","title":"A click that cannot compose its activation keeps the prefix and drops the payload","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated with a probe.\n\ncrates/improvise-protocol/src/resolve.rs:98 and :121 both do:\n\n commands.extend(filter_to_item_at(state, index).unwrap_or_default());\n commands.extend(tile_axis_at(state, index, None).unwrap_or_default());\n\nBoth callees return Option\u003cVec\u003cCommand\u003e\u003e and both None arms come from model(state, cmd)? -\u003e BasisCache::claim -\u003e self.current.as_ref()?, i.e. THE SERVER HAS NOT PUBLISHED A BASIS YET (gotcha 43: claim returns None until then; a record missing from the basis is filter_map'd away rather than failing, so this is the only None trigger).\n\nThe KEYBOARD handles the same None correctly: resolve() maps step(..) == None to KeyOutcome::Upstream — 'I cannot answer, ask the host'. The CLICK instead keeps the prefix and drops the payload.\n\nProbe on ClientState::default() (no basis), clicking an item row:\n\n [ SetPanelOpen { panel: Category, open: true },\n ChangeMode(CategoryPanel),\n SetPanelCursor { panel: Category, cursor: 1 } ]\n\nThe panel opens, focuses and the cursor moves — and the FILTER, which is the entire point of the click, is gone, with the frame reported as applied. Same for click_tile: you land in TILES mode and the axis never cycles.\n\nThis is gotcha 47's clause-4 failure (partial AND silent) reproduced one layer up, and it is worse than the keyboard's answer for the same input.\n\nREACHABILITY IS LOW and that is deliberate context, not a dismissal: the basis arrives in the bootstrap Snapshot and app.js folds a whole frame before repainting, so there is normally nothing painted to click before a basis exists. The reviewer could not construct a reachable browser path. AGENTS.md's rule applies — 'reachability is a reason not to rush a fix and not a reason not to notice one' — and the lost update CheckedBatch fixed had the same reachability argument and none of the documentation.\n\n## Steps to Reproduce\n\n1. Build a ClientState with no published basis (ClientState::default()).\n2. Call click_category on an item row index.\n3. Observe the navigation commands present and the filter absent.","acceptance_criteria":"A click is all of its intents or none: when the activation half cannot be composed (no published basis), click_category_row and click_tile emit nothing rather than the navigation prefix alone. Demonstrated by probing ClientState::default() and seeing an empty command list instead of SetPanelOpen/ChangeMode/SetPanelCursor without the filter.","status":"in_progress","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:17:59Z","created_by":"orchestrator","updated_at":"2026-09-20T20:46:22Z","started_at":"2026-09-20T20:46:22Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index 0769442c..20e4217e 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -223,6 +223,13 @@ doctrine(a_local_shell_is_not_the_ci_shell,'zsh ABORTS on an unmatched glob and doctrine(a_convention_that_keeps_a_checker_quiet_is_the_one_that_blinds_it,'context/test-evidence used #### for browser tests precisely BECAUSE --check-evidence resolved names against Rust fn names only, so a ### heading naming a Playwright test would be reported as an orphan. The workaround made every browser entry invisible, and a deleted test left its heading promising coverage for a whole release (improvise-5ray). Cure: collect ### and #### alike and decide by SHAPE -- a bare identifier is a Rust test, a quoted string is a JS test, anything else is prose naming nothing. When a checker forces a convention on the thing it checks, ask what that convention hides.'). doctrine(a_refusal_is_evidence_only_when_it_is_the_one_you_named,'Two kb-test cases passed against a tree with the fix they guarded reverted, because a DIFFERENT guard refused the same delta and the assertion was only \"fails, and the kb is unchanged\" (review of PR #69). A tool with several guards will usually refuse a broken delta somehow, so \"it was refused\" is nearly free and says nothing about the fix under test. Assert the REFUSAL MESSAGE, or construct a case only the fix can catch -- and prove it by reverting that one fix and watching the case fail. The same trap in reverse: a case asserting an OUTCOME (the add lands and every sibling survives) can discriminate where a message cannot, because what a wrong key destroys is the outcome.'). doctrine(a_weak_check_and_a_strong_one_agree_until_the_defect,'Five instances in one wave, and they are one mistake rather than five: a guard watching KeyOutcome::Upstream but not Ignored, clippy with versus without -D warnings, a grep for \"warning[\" versus a bare \"warning:\", toBeVisible versus toHaveText, and a grep for test( versus node own pass count. In every case the weak check and the strong one AGREE ON EVERY INPUT ANYONE TRIED and diverge only on the input that is also the defect -- which is why the weak one survives review and why running it proves nothing. The common cause is a check written against the failure you IMAGINED rather than the outcome the USER SEES. The repair is the same each time: assert over the observable -- is anything on the screen, did the process exit non-zero, did the document move -- rather than over the mechanism, which variant, which log prefix, which substring. Two things in this wave got it right and are the pattern to copy: furnished() seats the panel cursors off the edge so move-panel-cursor can answer the question rather than the test agreeing not to ask it, and scripts/node-test.sh reads node own report instead of grepping for test(.'). +doctrine(every_wave_agent_gets_its_own_worktree,'Dispatching parallel agents without worktree isolation puts all of them in the SAME working tree. Observed 2026-09-20 with four agents: one git status showed one agent effect.rs beside another harness/build.sh and a third scripts/node-test.sh, so any commit would have swept up half-finished work from three units and any checkout would have destroyed it. The tell is cheap -- run git status in the primary tree after dispatch and see whether it holds files from more than one unit. Recovery is per-agent: create a worktree, copy across only the files that agent can PROVE it touched, then git checkout -- those files in the primary tree; an agent that guesses which files are its own makes it worse. Ambiguity is real -- two units can legitimately both be in crates/improvise-protocol -- so the instruction is to check an edit history, not to infer from the ticket.'). +doctrine(the_orchestrator_delegates_substantial_work,'User instruction 2026-09-20: substantial work goes to an agent in a worktree rather than being done in the orchestrator, and repeated tasks become a script or a skill rather than being retyped. The obvious reason is that heavy orchestrator work blocks every sub-agent waiting on it. The non-obvious one is that orchestrator work lands in the SHARED primary tree, so it collides with whatever the agents are doing there -- an orchestrator fixing a bug in scripts/ while an agent owns scripts/ is a conflict the agent did not agree to. Corollary: a brief retyped by hand drifts between dispatches, which is what the wave skill exists to stop.'). +doctrine(a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary,'A test asserting WHICH refusal fired -- rather than that something refused -- is only as strong as that message being unique across every refusal the code can emit. The moment two share a phrase it degrades back to \"something refused\", the exact defect it was written to repair, wearing a more convincing sentence. So when an assertion moves onto a message, enumerate the whole error vocabulary: on PR #69 the reviewer listed all seven REFUSED strings in kb-merge.pl and confirmed each asserted phrase appears once, in the refusal its case names. Where no message can discriminate -- a wrong has_key is always caught by check_total, the backstop -- assert the OUTCOME instead, and guard the backstop separately or a case can lean on it while claiming to test something else.'). +doctrine(run_the_gate_ci_runs,'A local gate differing from CI in its flags or its output filter can only fail in directions the defect is not. On PR #71 the author ran clippy WITHOUT -D warnings and grepped for a lint name in brackets, which cannot match the bare \"warning:\" unused_imports emits, so the check printed zero and read clean while CI went red. Worse, the repo own code-review skill specified the same flagless invocation, so it was the INSTRUCTION rather than one person shell mistake -- a reviewer following it would report clean and be wrong. Invoke the gate exactly as the workflow does, flags and all.'). +doctrine(an_observation_correct_about_what_changed_can_be_wrong_about_when_it_is_read,'improvise-pdvm/75ld replaced a DECLARED classification with an OBSERVED one, which is right. The cost landed on WHEN the observation is read: comparing document_config once AROUND the batch is correct about what changed and wrong about ordering, because Save clears dirty INSIDE the loop, so [SetNumberFormat, Save] saved and then re-raised the flag. The fix is position, not suppression -- the comparison moved into the loop, using |= so it only ever sets. The asymmetry that decides it: nothing ever CLEARS layout_version, so an end-of-batch comparison is right there, while dirty has an effect that unsets it mid-batch. The discriminating probes rule out BOTH wrong fixes: [SetNumberFormat] alone must still be dirty, or LayoutGeometry has been demoted and the document never saves; and [Save, SetNumberFormat] must be dirty, or the fix is a blanket never-dirty-after-save.'). +doctrine(a_siblings_unmerged_pr_is_invisible_to_bd_search,'AGENTS.md requires a bd search before filing. In a parallel wave that is not sufficient: a fix living in a sibling agent UNMERGED PR is in no tracker state at all, so the search correctly returns nothing and the duplicate is filed anyway. Observed 2026-09-20 -- the tooling agent filed improvise-v5pc to restore a deleted help assertion while the harness agent was landing exactly that in PR #72. Neither agent erred. Check OPEN PRS as well as the tracker, and have the orchestrator reconcile filings against merged work at wave end rather than trusting each agent to have seen the others.'). +doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch,'The orchestrator wrote doctrines to kb.pl on local main, pushed each as a branch, then ran git reset --hard gh/main to resync -- twice -- and silently discarded six clauses that existed only in the unmerged local commits. Nothing failed: kb_consistent passed on the reduced file, the counts simply read lower, and the loss was found only by grepping for clause names remembered from the session. Two rules follow. Write a fact ONCE, on the branch that will carry it to main, not on a local main that is going to be resynced. And after any reset or force-move of a branch holding knowledge, re-count the predicates and diff clause NAMES against what you believe you wrote -- a total that merely looks plausible is the same non-evidence a passing test count is.'). 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.'). From 9976486d6945f3d05ff612c6d19afe6953bd7e0f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 19:55:31 -0700 Subject: [PATCH 6/6] docs: the help entry no longer contradicts itself Found by review on #75. Renaming the heading to the test PR #72 restored made check-evidence green, and left the body three paragraphs down still saying "The test no longer exists" and "nothing replaced the assertion" -- one entry making mutually exclusive claims about whether the coverage exists. The checker could not see it, and that is not a gap in the checker: it validates heading-to-test and says nothing about the prose beneath, which is what a human reads. A mechanical check licenses exactly the claim it makes and no neighbouring one. The paragraph now records the gap as history -- gone for a release, invisible because the checker then read only ### headings and only Rust names, restored by #72 with improvise-igxp's stronger assertions -- and points at help.spec.js's own entry rather than promising or denying coverage itself. doctrine(a_checker_that_reads_headings_cannot_see_a_contradicting_body): a rename is a read of the whole entry, not an edit of its first line. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH --- context/test-evidence/clients.md | 17 ++++++++++++----- prolog-sessions/kb.pl | 1 + 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 49880811..8ab53306 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -881,11 +881,18 @@ always open passes any check that only ever opens it. It runs against the **assembled** site like every other test here, so it covers `render.rs`, the serialized model, `web/app.js` and the CSS together. -**The test no longer exists.** `36ade35` moved the shell suite to node and -deleted it with the rest of `harness/tests/widgets.spec.js`; nothing replaced -the assertion, and the heading went on promising it for a whole release -- -which is the cost improvise-5ray names. The heading is demoted rather than -deleted so the injections above survive for whoever restores the coverage. +**It was gone for a release, and that is why the entry reads oddly.** `36ade35` +deleted it with the rest of `harness/tests/widgets.spec.js` and nothing replaced +it, so the heading promised coverage that did not exist -- exactly the cost +improvise-5ray names, and invisible because the evidence checker then read only +`###` headings and only Rust names. PR #69 taught it to see both; PR #72 +restored the coverage under the current name, with the stronger content +assertions improvise-igxp asked for. + +The injections above are the original two and still describe the property. The +present test is `harness/tests/help.spec.js`, and its own entry below records +what igxp added: the rows are read as `dt`/`dd` pairs and asserted as text, so +blanking `keys.textContent` fails it where `toBeVisible` passed. ### the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index 20e4217e..3d148bba 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -230,6 +230,7 @@ doctrine(run_the_gate_ci_runs,'A local gate differing from CI in its flags or it doctrine(an_observation_correct_about_what_changed_can_be_wrong_about_when_it_is_read,'improvise-pdvm/75ld replaced a DECLARED classification with an OBSERVED one, which is right. The cost landed on WHEN the observation is read: comparing document_config once AROUND the batch is correct about what changed and wrong about ordering, because Save clears dirty INSIDE the loop, so [SetNumberFormat, Save] saved and then re-raised the flag. The fix is position, not suppression -- the comparison moved into the loop, using |= so it only ever sets. The asymmetry that decides it: nothing ever CLEARS layout_version, so an end-of-batch comparison is right there, while dirty has an effect that unsets it mid-batch. The discriminating probes rule out BOTH wrong fixes: [SetNumberFormat] alone must still be dirty, or LayoutGeometry has been demoted and the document never saves; and [Save, SetNumberFormat] must be dirty, or the fix is a blanket never-dirty-after-save.'). doctrine(a_siblings_unmerged_pr_is_invisible_to_bd_search,'AGENTS.md requires a bd search before filing. In a parallel wave that is not sufficient: a fix living in a sibling agent UNMERGED PR is in no tracker state at all, so the search correctly returns nothing and the duplicate is filed anyway. Observed 2026-09-20 -- the tooling agent filed improvise-v5pc to restore a deleted help assertion while the harness agent was landing exactly that in PR #72. Neither agent erred. Check OPEN PRS as well as the tracker, and have the orchestrator reconcile filings against merged work at wave end rather than trusting each agent to have seen the others.'). doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch,'The orchestrator wrote doctrines to kb.pl on local main, pushed each as a branch, then ran git reset --hard gh/main to resync -- twice -- and silently discarded six clauses that existed only in the unmerged local commits. Nothing failed: kb_consistent passed on the reduced file, the counts simply read lower, and the loss was found only by grepping for clause names remembered from the session. Two rules follow. Write a fact ONCE, on the branch that will carry it to main, not on a local main that is going to be resynced. And after any reset or force-move of a branch holding knowledge, re-count the predicates and diff clause NAMES against what you believe you wrote -- a total that merely looks plausible is the same non-evidence a passing test count is.'). +doctrine(a_checker_that_reads_headings_cannot_see_a_contradicting_body,'check-evidence validates that a heading names a test that exists. It says nothing about the PROSE under the heading, which is what a human actually reads. Demonstrated on this repo 2026-09-20: renaming a retired entry to the test that had been restored made the checker green while the body three paragraphs down still said \"The test no longer exists\" and \"nothing replaced the assertion\" -- one entry making mutually exclusive claims, and the gate could not see it because the gate was never about that. Found by review, not by the checker. The general form is that a mechanical check licenses exactly the claim it makes and no neighbouring one, so a rename is a read of the whole entry rather than an edit of its first line -- a_fact_stated_twice_is_changed_by_grep applied WITHIN a document rather than across them.'). 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.').