kb: normalize to MCP canonical form, assert beads-maintenance facts
kb.pl is maintained through the prolog MCP, not by hand. Re-did this session's facts via loadSession/loadProgram/saveSession and saved the canonical form: 563 -> 391 lines. Comments and the discontiguous directives do not survive a round trip, so they are gone; every clause does survive, and kb_consistent still passes under trealla. Checked before normalizing that no substantive prose existed only as a comment -- the cycle-safe closure, MCP leniency, the symbol-existence check and fault injection were all already asserted. Section headers were the only loss. New facts: four under beads-maintenance-2026-09-19 (export drops memories without --include-memories, git hooks bypass the shrink guard, dependency ordering is nondeterministic, flake pin verified against nix build) and three under kb-normalization-2026-09-19, including that loadProgram REPLACES a predicate rather than appending, so a partial payload silently drops the rest. saveSession re-added the MCP's own host_rpc shims, as kb_issue_found(host_rpc_shims) says it would; stripped again per that fact's own instruction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+264
-442
@@ -1,35 +1,16 @@
|
||||
% Improvise project knowledge base.
|
||||
%
|
||||
% Facts are appended chronologically as work happens, so most predicates are
|
||||
% spread through the file rather than grouped; these declarations keep that
|
||||
% shape without warnings on every consult.
|
||||
:- discontiguous(crate/3).
|
||||
:- discontiguous(layer/2).
|
||||
:- discontiguous(location/3).
|
||||
:- discontiguous(invariant/2).
|
||||
:- discontiguous(doctrine/2).
|
||||
:- discontiguous(work_done/3).
|
||||
:- discontiguous(work_followup/3).
|
||||
:- discontiguous(decision/3).
|
||||
:- discontiguous(wire_carries/3).
|
||||
|
||||
crate(improvise_core,'crates/improvise-core','Model, View, Workbook, formatting; no UI/IO').
|
||||
crate(improvise_formula,'crates/improvise-formula','formula parser, AST, parse_formula').
|
||||
crate(improvise_io,'crates/improvise-io','improv save/load, CSV/JSON import; no UI/commands').
|
||||
crate(improvise_command,'crates/improvise-command','command/effect layer + semantic App state; no ratatui').
|
||||
crate(improvise_main,src,'ratatui TUI rendering, draw loop, clap CLI').
|
||||
crate(improvise_protocol,'crates/improvise-protocol','wire vocabulary + portable session slice: Command, ViewState, RenderCache, reduce_view').
|
||||
layer(model,improvise_core).
|
||||
layer(view,improvise_core).
|
||||
layer(formula,improvise_formula).
|
||||
layer(io,improvise_io).
|
||||
layer(command,improvise_command).
|
||||
layer(rendering,improvise_main).
|
||||
forbidden(improvise_core,ui).
|
||||
forbidden(improvise_core,io).
|
||||
forbidden(improvise_io,ui).
|
||||
forbidden(improvise_io,commands).
|
||||
forbidden(improvise_command,ratatui).
|
||||
forbidden(improvise_command,crossterm).
|
||||
layer(protocol,improvise_protocol).
|
||||
location(add_keybinding,'crates/improvise-command/src/command/keymap.rs','default_keymaps()').
|
||||
location(add_command,'crates/improvise-command/src/command/cmd/registry.rs','default_registry()').
|
||||
location(add_effect,'crates/improvise-command/src/ui/effect.rs','impl Effect').
|
||||
@@ -51,13 +32,16 @@ location(save_effect,'crates/improvise-command/src/ui/effect.rs','Save, save_to_
|
||||
location(abort_chain,'crates/improvise-command/src/ui/effect.rs','AbortChain').
|
||||
location(apply_effects,'crates/improvise-command/src/ui/app.rs','App::apply_effects').
|
||||
location(sequence_dispatch,'crates/improvise-command/src/command/keymap.rs','Keymap::dispatch Binding::Sequence').
|
||||
pipeline_step(1,keypress,keymap_lookup).
|
||||
pipeline_step(2,keymap_lookup,cmd_execute).
|
||||
pipeline_step(3,cmd_execute,effect_list).
|
||||
pipeline_step(4,effect_list,effect_apply).
|
||||
pure(cmd_execute).
|
||||
mutating(effect_apply).
|
||||
only_mutation_site('Effect::apply(&mut App)').
|
||||
location(text_entry_builder,'crates/improvise-command/src/command/keymap.rs','TextEntry, buffer_of').
|
||||
location(panel_keymap_builder,'crates/improvise-command/src/command/keymap.rs','PanelKeymap, bind_panel_toggles').
|
||||
location(records_layout,'crates/improvise-core/src/view/layout.rs','RecordRow, RecordCol, build_records_mode, records_display').
|
||||
location(storage_trait,'crates/improvise-io/src/storage.rs','Storage, PhysicalFs, MemoryFs').
|
||||
location(effect_kind,'crates/improvise-command/src/ui/effect.rs','EffectKind, ModelChange, Effect::kind').
|
||||
location(prefix_resolution,'crates/improvise-command/src/command/keymap.rs','KeymapSet::prefix_keymap, SetTransientPrefix').
|
||||
location(wasm_ci,'.github/workflows/ci.yml','wasm job').
|
||||
location(view_state_membership,'crates/improvise-command/src/ui/app.rs','ViewState doc comment states both membership tests; App carries the session-local residue tag').
|
||||
location(axis_presentation,'crates/improvise-core/src/view/axis.rs','plain_label/glyph/short_label/initial/color/next on Axis; AxisColor semantic palette').
|
||||
location(axis_color_resolution,'src/draw.rs','axis_fg maps AxisColor to a ratatui Color, beside minibuf_fg which does the same for MinibufColor').
|
||||
invariant(cellkey_sorted,'CellKey coords always sorted by category; build via CellKey::new/with/without').
|
||||
invariant(display_rounding,'format_f64 is render-only; never feed rounded values back into eval').
|
||||
invariant(fixedpoint_eval,'recompute_formulas iterates to stability; circular -> CellValue::Error(\"circular\")').
|
||||
@@ -70,12 +54,119 @@ invariant(wizard_done_is_confirm,'NameModel is terminal for advance; Enter there
|
||||
invariant(failed_save_aborts_batch,'Save sets app.abort_effects on failure (no file path, or write error), so effects chained after it -- the Quit in wq/ZZ -- are skipped and the error stays on screen').
|
||||
invariant(sequence_one_batch,'Binding::Sequence flattens all step effects into one Vec applied by a single apply_effects call, so abort_effects carries across sequence steps').
|
||||
invariant(minibuffer_spine,'Seven text-entry modes built by TextEntry builder in keymap.rs; SearchMode hand-written').
|
||||
invariant(records_mode_all_none,'ToggleRecordsMode puts every regular category on Axis::None, so EditOrDrill regular_none is always true in records mode and the edit branch depends entirely on is_synthetic').
|
||||
invariant(buffer_name_single_source,'the buffer a text-entry mode types into is named once, in that mode AppMode constructor MinibufferConfig; TextEntry reads it off the mode so the prompt and the keystrokes cannot address different buffers').
|
||||
invariant(panel_keymap_spine,'the three panel modes share a spine built by PanelKeymap::new(panel): Esc/Tab to Normal, arrows and j/k to move-panel-cursor for that panel, every panel toggle key, and : for command mode. Content keys come through chained .bind/.bind_args/.delete because they vary in shape not just command name').
|
||||
invariant(panel_name_matches_parser,'Panel::name() must spell what parse_panel in cmd/core.rs accepts; pinned by panel_toggle_keys_dispatch_to_real_effects, since dispatch swallows an unresolvable command with .ok()?').
|
||||
invariant(records_rename_spans_row,'editing a category column in wide records mode moves every measure cell in the row; moving only the cell under the cursor would tear the row apart').
|
||||
invariant(records_dim_is_column_index,'the synthetic records CellKey stores the column INDEX in _Dim, not a label, because a category and a measure item may share a name; resolve via GridLayout::record_col').
|
||||
invariant(storage_is_two_ops,'persistence only ever reads or writes one whole document, so the Storage trait is read/write bytes and nothing else; a backend that is not a filesystem implements it without directories, metadata or seeking').
|
||||
invariant(gzip_is_a_name_property,'the .gz suffix selects gzip in save_to/load_from, so it is a property of the document name and not of the storage backend').
|
||||
invariant(effect_kind_no_default,'Effect::kind has no default impl, so a new effect cannot forget to classify itself; the failure mode would be silent (a mis-declared ViewOnly edit never reaches connected clients)').
|
||||
invariant(view_lives_in_model_state,'the View is stored inside model_state.workbook, so touching model_state does NOT imply a model change: axis/page/format are layout_geometry, cursor and scroll are per-client view_only, Save/SaveAs/MarkDirty are bookkeeping').
|
||||
invariant(view_state_is_a_projection,'the server does NOT serialize ViewState to maintain a client mirror -- it applies the same commands through reduce_view (improvise-cqq). Steady-state traffic is Commands only; ViewState is serialized solely for the bootstrap Snapshot').
|
||||
invariant(bootstrap_excludes_host_state,'because the only serialization point is the on-connect Snapshot, host-only state must not live in ViewState: a fresh client should not inherit a half-open import wizard, an armed prefix key, or another session staged drill edits').
|
||||
invariant(prefix_is_an_address,'ViewState::transient_prefix stores the (KeyCode,KeyModifiers) that opened a prefix; KeymapSet::prefix_keymap resolves it, walking parents. Assumes prefixes are FLAT -- guarded by prefix_keymaps_are_flat').
|
||||
invariant(compile_check_is_not_runtime_proof,'the wasm CI gate is cargo check, so it catches nothing that merely panics at runtime: Instant and std::fs both COMPILE for wasm32-unknown-unknown').
|
||||
invariant(effects_never_touch_fs,'every document read or write in the command layer goes through App::storage; production code in improvise-command contains no std::fs').
|
||||
invariant(io_keeps_path_wrappers,'improvise-io keeps path-taking wrappers (save, load, export_csv, parse_csv_with_warnings) delegating to _to/_from/_bytes variants, so the native CLI is unchanged while the byte-level path is backend-agnostic').
|
||||
invariant(view_state_send,'ViewState: Send is asserted at compile time in crates/improvise-command/src/ui/app.rs tests. Send is a proxy for portable/1 that only catches the shared-pointer clause; it sees neither the origin-crate restriction nor snapshot_safe/1, so those stay hand-checked against view_state_admits/1.').
|
||||
invariant(no_borrowed_static_in_view_state,'no type reachable from ViewState may hold a borrowed-\'static field; Deserialize cannot produce one. Guarded by view_state_survives_a_json_round_trip in crates/improvise-command/src/ui/app.rs.').
|
||||
invariant(semantic_palette_at_the_core_boundary,'a core type that needs a color names a semantic enum, never a ratatui Color; the rendering layer owns the one mapping. Precedents: MinibufColor -> minibuf_fg, AxisColor -> axis_fg. This is what forbidden(improvise_core,ui) requires in practice.').
|
||||
invariant(filter_outside_rotation,'Axis::next maps Filter to itself and no navigable axis rotates into it, so tile select and cycle-axis cannot hand a user a Filter assignment. Guarded by filter_is_outside_the_rotation in crates/improvise-core/src/view/axis.rs.').
|
||||
doctrine(bug_first,'write a failing test demonstrating the bug before fixing it').
|
||||
doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e').
|
||||
doctrine(suite_speed,'workspace suite must stay under 2 seconds').
|
||||
doctrine(coverage,'aim ~80% line/branch on logic; skip ratatui render paths').
|
||||
doctrine(test_contract,'test the observable contract, not the internal storage').
|
||||
doctrine(proptest_cases,'keep proptest at default 256 cases; constrain strategies instead of brute force').
|
||||
doctrine(binding_dump_diff,'when refactoring keymap construction, dump every (mode,key,modifier) lookup for all 16 modes before and after and diff: most bindings have no direct test, so a green suite is not evidence').
|
||||
doctrine(derive_then_verify,'when classifying many items from source, extract facts mechanically and reason in Prolog, but VERIFY the extraction: multi-line method chains and free-function helpers defeated naive regexes and mis-classified SetCell, SortData and ApplyAndClearDrill as view-only').
|
||||
doctrine(mentions_are_not_edges,'never bulk-add dependency edges from prose mentions; direction must be read per pair, and blind addition can invert real edges or create cycles').
|
||||
doctrine(kb_is_code,'the knowledge base is executable, so audit it like code: check rules actually reject what they should, and check location/3 symbols still exist in the tree').
|
||||
doctrine(check_the_checker,'prove a consistency check fires by injecting the fault it targets, then revert; otherwise it is indistinguishable from a check that always succeeds').
|
||||
doctrine(prove_by_compile_failure,'for a structural refactor with no behavioural delta, a static assertion that fails to compile before the change is the demonstration -- it names the offending type itself, and survives as a guard that the field cannot come back').
|
||||
doctrine(populate_before_roundtrip,'a serialization roundtrip test over a default-constructed value proves almost nothing; populate one non-default value per field family, especially the variants that carry payloads').
|
||||
doctrine(check_the_ticket_premise,'a ticket that states a fact about the system as the basis for its options is stating a hypothesis; test it before choosing among the options, because a false premise usually invalidates the option set rather than one option').
|
||||
doctrine(characterization_survives_the_move,'for a relocation refactor, write the behaviour test against the OLD location first and carry it across unchanged in meaning; if it needs new assertions to pass, the relocation changed behaviour').
|
||||
doctrine(two_questions_one_enum,'when a new enum variant shares one property with an existing variant but not another, name both properties as predicates (filters_data, is_user_assignable) and route every call site through the one it means; testing for the variant directly is what goes stale when a third variant arrives').
|
||||
doctrine(contract_test_for_the_unreachable_backend,'when one implementation of a trait cannot be exercised by the test harness, write the contract as a function over &dyn Trait and run it against the reachable implementations; it becomes the executable specification the unreachable one is held to').
|
||||
doctrine(derive_the_enum_from_the_effects,'when a wire vocabulary must mirror an existing mutation set, enumerate the mutations and let that be the enum; inventing variants ahead of the mutations produces a protocol nobody can implement against').
|
||||
doctrine(force_the_failure,'an unchecked status whose failure yields a usable-looking value is not a style nit: force the failure and look at what the next line does with it. Four on one branch -- bd init exits non-zero but still leaves an empty database behind, an unchecked mv lands the new tracker INSIDE the old directory and reports success, jq status swallowed by a process substitution reads as \"no inputs found\" and exits 0, and a failed mktemp turns \"$TMP/$NAME\" into an absolute path at the filesystem root. Every one looked like success at runtime, so reading the failure paths found none of them; stubbing mv and mktemp to fail is what exposed them. Note chmod a-w proves nothing when the tests run as root -- it returned 0 and looked like a pass.').
|
||||
doctrine(kb_is_maintained_through_the_mcp,'prolog-sessions/kb.pl is the MCP serialized clause database, not a hand-written source file. Add facts by loadSession, then loadProgram, then saveSession -- never by appending text to the file. Note loadProgram REPLACES a whole predicate rather than appending to it, so the program you load must carry every existing clause of every predicate it touches, or the rest are silently dropped. Verify counts with findall/length before saving.').
|
||||
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<String,String>; 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.').
|
||||
work_done('improvise-k8h',refactor,'TextEntry::new/with_parent now take the AppMode and read the buffer name off its MinibufferConfig, so the keymap no longer spells a buffer name. Added two guard tests in keymap.rs pinning mode<->keymap agreement and the completeness of the text-entry mode list.').
|
||||
work_done('improvise-4pg',refactor,'Extracted PanelKeymap builder for the three panel modes, following the TextEntry precedent. Panel::ALL/name()/key() drive the toggle keys for Normal and all three panels. Verified by diffing all 4065 (mode,key,modifier) lookups before and after: identical.').
|
||||
work_done('improvise-6mq',refactor,'persistence save/load routed through a Storage trait; save/load kept as PhysicalFs wrappers so all 83 persistence tests pass unchanged; MemoryFs gives a filesystem-free test fixture.').
|
||||
work_done('improvise-mae',refactor,'all 58 effects classified as ViewOnly or Model(CellData|LayoutGeometry|WholeWorkbook); kind() has no default so the compiler enforces completeness; three tests verify declarations empirically by applying effects and fingerprinting shared state.').
|
||||
work_done('improvise-jc8',refactor,'ViewState no longer holds Arc<Keymap>; four characterization tests written before the change pinned gg/ZZ/prefix-consumption/records-inheritance, plus two new guards for flatness and parent resolution.').
|
||||
work_done('improvise-ywd',audit,'added wasm32-unknown-unknown to the flake toolchain, which was the real blocker; all four bundle crates then checked clean with zero source changes. Swapped Instant for web_time and added the CI wasm job.').
|
||||
work_done('improvise-fuj',refactor,'App gained a storage backend; Save/SaveAs/ExportCsv/LoadModel/StartImportWizard and CSV import all route through it. Test drives save, export, load and wizard-seed over MemoryFs with no disk.').
|
||||
work_done('backlog-groom-2026-08-30',groom,'closed improvise-ont as obsolete; raised 7 priority inversions to P2; added 2 verified dependency edges out of 21 candidates; corrected stale refs on ai7/ct5/gdl and stale assumptions on jb3/git.').
|
||||
work_done('improvise-h0b',refactor,'wizard and drill_state moved from ViewState to App. Demonstrated first with a ViewState: Send static assertion that failed to compile, naming Rc<Vec<RecordRow>> exactly. Second test proves staged drill edits are unreachable from ViewState Debug output.').
|
||||
work_done('improvise-a2x',refactor,'serde added to improvise-command; Serialize+Deserialize derived on AppMode, MinibufferConfig, MinibufColor, ViewFrame, KeyCode, KeyModifiers and ViewState. buffer_key and TextEntry::buffer became owned Strings. Roundtrip test populates one field per family -- a default-value roundtrip would pass while proving nothing.').
|
||||
work_done('improvise-8uz',refactor,'View lost selected/row_offset/col_offset; ViewState gained cursors: HashMap<String,ViewCursor>, reached via App::cursor/cursor_mut/reset_cursor. Cursor resets moved from View::transpose_axes and View::cycle_axis into the TransposeAxes and CycleAxis effects. Workbook::normalize_view_state deleted as vacuous; the two effects that swap the whole document now clear the cursor map instead.').
|
||||
work_done('improvise-rml',refactor,'Axis gained next/color/plain_label/glyph/short_label/initial; Display is now plain_label+glyph. Four duplicate match sites replaced: tile_bar, category_panel, view_panel, cmd/tile.rs. View::cycle_axis now calls Axis::next. Characterization test pins all four label vocabularies verbatim.').
|
||||
work_done('improvise-dz8',feature,'Axis::Filter added with filters_data/1 and is_user_assignable/1; View gained filtering_cats and tileable_cats. DrillIntoCell fixes coordinates on Filter instead of Page. GridLayout and add-record-row switched to filtering_cats; the three tile-select sites to tileable_cats; page cycling deliberately left on categories_on(Axis::Page).').
|
||||
work_done('improvise-i34',feature,'Storage gained list/delete, implemented for PhysicalFs and MemoryFs with a shared storage_contract test both satisfy. OpfsFs added behind cfg(target_arch=wasm32) using web-sys sync access handles, with open/attach/refresh/commit_deletes as the async seam. Compile- and clippy-clean for wasm32.').
|
||||
work_done('improvise-cqi',feature,'crates/improvise-protocol created. ViewState, ViewCursor, ViewFrame, AppMode, Panel, MinibufferConfig, MinibufColor, HELP_PAGE_COUNT and the key types moved there; improvise-command re-exports them so no caller path changed. Command + reduce_view + RenderCache/CellDisplay/Snapshot added with 15 tests. Added to the CI wasm job.').
|
||||
work_done('improvise-sef',maintenance,'kb.pl audited and repaired (a406b52): may_depend_on/2 and before_fixing/2 were unsound, and sixteen lines of captured MCP runtime predicates were removed. kb_report/0 and kb_consistent/0 added (3a58d3c) over four error classes -- domain integrity, layering, rule soundness, functional keys -- and wired into CI; every class verified by fault injection. Backlog-grooming method recorded (3697171).').
|
||||
work_done('backlog-groom-2026-09-19',groom,'ran the five 2026-08-30 checks over 62 open/deferred issues. Cycles: none. Priority inversion: one (g0f P3 blocking cqq P2), raised. Unrecorded deps: 11 prose mentions, 1 real (lae blocks git, by the ticket own words), the rest analogues or siblings. Stale citations: 2 broken paths (nr5 doubled crate prefix, three gp-tree tickets pointing at examples/fit_csv.rs instead of crates/gp-tree/examples/), 9 line-number drifts corrected to symbol references. Dissolved premises: ede and toc closed, ete demoted to P4, 2 of 5 items struck from 7yx.').
|
||||
work_followup('improvise-8tk','improvise-lae','no-file-path UX: prompt for a path rather than only erroring. ExportPrompt is CSV-only, so a new SavePrompt TextEntry mode committing to SaveAs is the likely shape.').
|
||||
work_followup('improvise-6sq','improvise-ct5','i on an empty records grid reports \'cannot drill\', leaking a pivot-mode concept into records mode').
|
||||
work_followup('improvise-h0b','improvise-a2x','ViewState field types (AppMode, MinibufferConfig, ViewFrame, KeyCode, KeyModifiers) still derive no Serialize, and improvise-command depends only on serde_json. Deferred: nothing serializes ViewState until improvise-cqi builds a Snapshot, so the derives would have no consumer.').
|
||||
work_followup('improvise-i34',browser_verification,'OpfsFs is compile-checked only -- no browser in this environment. storage_contract in storage.rs is the sequence it must satisfy. User will verify at the web MVP.').
|
||||
decision('improvise-907',done_step,'chose to make Done a real confirm step rather than dropping the variant -- user decision. Warnings from build_model_with_warnings now render in the wizard panel instead of being crammed into a one-line status bar.').
|
||||
decision('improvise-907',back_key,'Left is the back key on every step. Backspace is taken by the name buffer and the formula buffer; h is a printable character NameModel needs. Left is the only key free everywhere.').
|
||||
decision('improvise-6sq',no_merge,'EditOrDrill reads ctx.display_value at Cmd time; EnterEditAtCursorCmd emits RebuildLayout then EnterEditAtCursor, which re-reads display_value_at_cursor after the rebuild. A merged command needs a rebuild_first/skip_drill flag -- the same fork renamed.').
|
||||
decision('improvise-6mq',narrow_trait_over_vfs_crate,'chose Option B (own two-method trait) over the ticket-recommended Option A (vfs crate): the real surface is read/write one document, and the ticket own con for A -- sync-only while OPFS is async -- means adopting A would mean doing B later anyway').
|
||||
decision('improvise-mae',cursor_is_per_client,'SetSelected/SetRowOffset/SetColOffset classed ViewOnly even though they write the shared persisted View, because one client scrolling must not move another viewport; the residual inconsistency is filed as improvise-8uz').
|
||||
decision('improvise-jc8',flip_the_arc,'Arc<Keymap> moved out of ViewState and back into KeymapSet, which already owns every keymap. Motivated by a hard dependency cycle -- Keymap is in improvise-command, which will depend on improvise-protocol once reduce_full lives there -- not by serde.').
|
||||
decision('improvise-i34',keep_storage_sync,'improvise-9x6 hosts App and persistence inside a browser worker, where FileSystemSyncAccessHandle is available. Async handle acquisition happens once at worker init; read/write are then synchronous, so the two-method sync trait stands and no async migration is needed. Caveat: asserted from API knowledge, untested here; the IndexedDB fallback IS async-only and would reopen the question.').
|
||||
decision('improvise-h0b',fields_on_app_not_a_wrapper,'The ticket sketched a HostState { view, wizard, drill_state } wrapper. Rejected: App already IS the host struct, with a documented residue taxonomy (derived cache / runtime metadata / transient / config). Added a fifth tag, session-local, and put the two fields directly on App. This is what jc8 actually did too -- it moved keymap ownership to KeymapSet on App rather than introducing a wrapper.').
|
||||
decision('improvise-a2x',buffer_key_becomes_string,'buffer_key: &\'static str -> String. Cow<\'static,str> would keep construction allocation-free but puts container plumbing in a public field, which AGENTS.md asks to keep out of logic. A BufferKey enum would close the name set but drags the buffers map key type, the script parser\'s buffer args (\"append-char formula\") and the hand-written \"search\" buffer with it -- out of scope. Mode construction is per-keypress, so seven small allocations are not a cost worth designing around.').
|
||||
decision('improvise-a2x',k8h_enum_rejection_does_not_bite,'improvise-k8h rejected a Buffer enum on the DRIFT argument: each side would still name a variant, so the two could still disagree. That objection is about who writes the name, not what type it has -- buffer_of/1 still reads it off the mode either way. So k8h does not forbid an enum here; scope does.').
|
||||
decision('improvise-8uz',none_of_the_three_options,'The ticket offered (a) move to ViewState and stop persisting, (b) View keeps the saved starting position and ViewState tracks the live cursor, (c) leave it. Scored against per_client + preserves_behaviour + no_dead_rationale, all three fail: (a) drops the per-view restore, (b) rests on a persistence that does not exist so its View field would be written and never read, (c) is not per-client at all. The synthesis (d) satisfies all three: View loses the fields outright, ViewState holds HashMap<view_name, ViewCursor>.').
|
||||
decision('improvise-8uz',exemption_deleted,'the view_only_effects_leave_shared_state_alone exemption for SetSelected/SetRowOffset/SetColOffset is deleted: they no longer touch anything shared, so every ViewOnly effect now clears the same bar with no special cases. That exemption was the ticket\'s original symptom.').
|
||||
decision('improvise-rml',semantic_color_not_ratatui,'the ticket specified display_short() -> (&\'static str, Color). Color is a ratatui type and Axis lives in improvise-core, so that signature is the layering violation forbidden(improvise_core,ui) names. Split into color() -> AxisColor plus short_label(), with draw.rs::axis_fg resolving the palette.').
|
||||
decision('improvise-rml',is_data_axis_omitted,'the ticket also asked for is_data_axis() -> bool. No caller wanted it, so it was left out rather than added speculatively.').
|
||||
decision('improvise-dz8',filter_persists_in_the_format,'added \"filter\" to axis_kind in improv.pest and to both sides of the writer/parser, even though Filter only ever occurs in the _Drill view which is never written. The alternative to supporting it was silently writing a Filter axis out as some other axis.').
|
||||
decision('improvise-i34',no_indexeddb_fallback,'user decision 2026-08-31: skip the IndexedDB fallback unless recent Safari needs it. It does not -- Safari 16.4+ has synchronous access handles -- so the fallback is out of scope and the async question it would have forced does not arise.').
|
||||
decision('improvise-i34',delete_is_deferred,'removeEntry is async and the trait is sync, so delete/1 drops the name from the index and queues the removal. A delete is therefore visible to read and list at once and lands in OPFS when commit_deletes is awaited. Invisible to a single worker; the alternative was making the whole trait async for one operation.').
|
||||
decision('improvise-cqi',command_set_derived_not_invented,'the client->server half of Command is exactly the set of EffectKind::ViewOnly effects whose target is the portable slice. Effects touching host-local state (wizard, drill_state, storage, layout cache) are absent by construction -- improvise-h0b had already moved those off ViewState, so the eviction did the enumeration.').
|
||||
decision('improvise-cqi',view_state_carries_its_own_active_view,'reduce_view runs on a client with no Workbook, and cursors is keyed by view name, so ViewState needs its own active_view. Kept identical to Workbook::active_view by App::switch_to_view and App::install_workbook; whether they MAY diverge is improvise-g0f.').
|
||||
decision('improvise-sef',retroactive_ticket,'The three kb commits landed with no beads issue at all. Filed and closed 2026-09-19 during a commit/tracker reconciliation rather than left untracked, because the CI job they add is a durable capability someone will later ask the provenance of. Every other commit back to cc3889d already named a ticket that is closed with a close_reason.').
|
||||
decision('backlog-groom-2026-09-19',render_cache_name_collision,'improvise-cqi landed RenderCache as a flat grid-only wire type, while six open migration tickets (35e, n10, pca, 764, jb3, edp) were written against a fat per-widget cache of the same name. Two objects, one name, and nothing in the tree complains until someone writes from_app. Filed improvise-rkeq to decide the shape and made it block 35e rather than silently rewriting six tickets to match whichever shape a groomer preferred.').
|
||||
decision('backlog-groom-2026-09-19',retroactive_tickets_for_untracked_merges,'PR #7 (cloud-session tooling) landed 18 commits with no beads issue, the same gap the kb commits had. Filed improvise-3qz2 closed. Rule being followed: work that adds a durable capability -- a CI job, a hook, a script someone will later ask the provenance of -- gets a ticket even after the fact; a chore commit does not.').
|
||||
decision('beads-maintenance-2026-09-19',export_drops_memories_by_default,'bd export omits the bd remember memories unless --include-memories (or --all) is passed, and .beads/issues.jsonl carries both. Regenerate it with: bd export --include-memories -o .beads/issues.jsonl. A plain bd export silently deletes every memory record from the tracked file and the diff reads as an ordinary re-export.').
|
||||
decision('beads-maintenance-2026-09-19',git_hooks_bypass_the_shrink_guard,'bd own auto-export refuses to shrink the file (auto-export shrink guard: refusing to overwrite ... contains N record(s) outside auto-export scope), but the beads-installed git hooks run the plain export and bypass that guard. Observed 2026-09-19: a git checkout of an unrelated file fired post-checkout, which rewrote AND staged .beads/issues.jsonl with all 15 memories removed. Filed as a P2 bug.').
|
||||
decision('beads-maintenance-2026-09-19',export_dependency_order_is_nondeterministic,'bd export emits each issue dependencies array in nondeterministic order, so a re-export of unchanged data still shows a ~23 line diff. Before committing, compare old against new with the dependencies arrays sorted; if nothing differs semantically, revert rather than commit the churn.').
|
||||
decision('beads-maintenance-2026-09-19',flake_beads_pin_verified,'flake.nix pins beads to github:gastownhall/beads/v1.3.0 (flake.lock rev f45b249ce6b40ba62aecc03949e6371e8f7c79d8). nix build of that ref yields beads-1.3.0, matching the local bd 1.3.0 that writes .beads/issues.jsonl, so the pinned tool and the tracked export agree on format.').
|
||||
decision('kb-normalization-2026-09-19',kb_normalized_to_mcp_canonical_form,'user decision 2026-09-19: kb.pl is maintained by the MCP, so it was saved in the MCP canonical form -- 563 lines to 399. Cost, measured by a no-op round-trip probe before committing: all 106 comment lines dropped, the 9 discontiguous directives dropped, multi-line rules reflowed onto one line, and anonymous _ arguments renamed to singleton variables (new trealla warnings, no semantic change). Every clause of every predicate survived and kb_consistent still passes. The chronological append shape that kb_issue_found(discontiguous) preserved is gone; clauses are now grouped by predicate, which is why the directives are no longer needed.').
|
||||
decision('kb-normalization-2026-09-19',important_knowledge_is_asserted_never_commented,'user rule 2026-09-19: anything important goes in a fact, not a comment. Checked before normalizing -- every substantive comment was already asserted somewhere (cycle-safe closure in kb_issue_found(cycle_unsafe_closure), MCP leniency in kb_issue_found(mcp_lenience), the symbol-existence check in kb_external_check/1, fault injection in doctrine(check_the_checker)), so only section headers were lost. Re-run that check before any future save: prose that exists ONLY as a comment will not survive one.').
|
||||
decision('kb-normalization-2026-09-19',audit_the_save_for_host_predicates,'saveSession serializes the whole clause database, including predicates the MCP runtime itself defines -- kb_issue_found(host_rpc_shims) records sleep/delay/console_log/js_eval/future/await_any/future_cancel being captured into this file that way once already. After every saveSession, grep the result for predicates that are not project knowledge and confirm the clause counts per predicate are unchanged, because loadProgram REPLACES a predicate rather than appending to it and a short payload silently drops the rest.').
|
||||
wire_carries(command,both_directions,'user intent up, projection commands down').
|
||||
wire_carries(snapshot,on_connect_only,'improvise-q08: full ViewState + render cache filled from current viewport').
|
||||
wire_carries(command,both_directions,'user intent client->server, projections server->client').
|
||||
wire_carries(snapshot,bootstrap_only,'the ONE point ViewState is serialized: a value for a fresh client to fold later commands onto').
|
||||
forbidden(improvise_core,ui).
|
||||
forbidden(improvise_core,io).
|
||||
forbidden(improvise_io,ui).
|
||||
forbidden(improvise_io,commands).
|
||||
forbidden(improvise_command,ratatui).
|
||||
forbidden(improvise_command,crossterm).
|
||||
forbidden(improvise_protocol,ui).
|
||||
forbidden(improvise_protocol,io).
|
||||
forbidden(improvise_protocol,commands).
|
||||
pipeline_step(1,keypress,keymap_lookup).
|
||||
pipeline_step(2,keymap_lookup,cmd_execute).
|
||||
pipeline_step(3,cmd_execute,effect_list).
|
||||
pipeline_step(4,effect_list,effect_apply).
|
||||
pure(cmd_execute).
|
||||
mutating(effect_apply).
|
||||
only_mutation_site('Effect::apply(&mut App)').
|
||||
tracker(beads,bd,'bd ready / bd show / bd update --claim / bd close; no TodoWrite or markdown TODOs').
|
||||
session_close(1,'file issues for remaining work').
|
||||
session_close(2,'run quality gates if code changed').
|
||||
@@ -84,24 +175,17 @@ session_close(4,'git merge origin/main, bd dolt push, git push').
|
||||
session_close(5,'clean up stashes and pruned branches').
|
||||
session_close(6,'verify committed AND pushed').
|
||||
session_close(7,'hand off context').
|
||||
crate_of_layer(Layer,Crate) :-
|
||||
layer(Layer,Crate).
|
||||
where(What,File,Symbol) :-
|
||||
location(What,File,Symbol).
|
||||
|
||||
% The concerns a crate is allowed to touch. `forbidden/2`'s second argument is
|
||||
% a CONCERN, not a crate -- the old may_depend_on/2 conflated the two and so
|
||||
% accepted any term at all for its second argument.
|
||||
crate_of_layer(A,B) :-
|
||||
layer(A,B).
|
||||
where(A,B,C) :-
|
||||
location(A,B,C).
|
||||
concern(ui).
|
||||
concern(io).
|
||||
concern(commands).
|
||||
concern(ratatui).
|
||||
concern(crossterm).
|
||||
|
||||
may_use(Crate,Concern) :-
|
||||
crate(Crate,_,_), concern(Concern), \+ forbidden(Crate,Concern).
|
||||
|
||||
% The real crate graph, read from the Cargo.toml files (2026-08-30).
|
||||
may_use(A,B) :-
|
||||
crate(A,C,D),concern(B), \+forbidden(A,B).
|
||||
crate_depends_on(improvise_core,improvise_formula).
|
||||
crate_depends_on(improvise_io,improvise_core).
|
||||
crate_depends_on(improvise_io,improvise_formula).
|
||||
@@ -112,187 +196,94 @@ crate_depends_on(improvise_main,improvise_core).
|
||||
crate_depends_on(improvise_main,improvise_formula).
|
||||
crate_depends_on(improvise_main,improvise_io).
|
||||
crate_depends_on(improvise_main,improvise_command).
|
||||
|
||||
% Cycle-safe on purpose: illegal_dependency/2 exists to detect an inverted
|
||||
% edge, and an inverted edge IS a cycle -- a naive transitive closure loops
|
||||
% forever on exactly the input this is meant to catch (found 2026-08-30 by
|
||||
% injecting the fault the check is for).
|
||||
depends_transitively(A,B) :- depends_via(A,B,[A]).
|
||||
|
||||
depends_via(A,B,_) :- crate_depends_on(A,B).
|
||||
depends_via(A,B,Seen) :-
|
||||
crate_depends_on(A,X), \+ memberchk(X,Seen), depends_via(X,B,[X|Seen]).
|
||||
|
||||
% A dependency edge that would invert the layering.
|
||||
crate_depends_on(improvise_protocol,improvise_core).
|
||||
crate_depends_on(improvise_command,improvise_protocol).
|
||||
depends_transitively(A,B) :-
|
||||
depends_via(A,B,[A]).
|
||||
depends_via(A,B,C) :-
|
||||
crate_depends_on(A,B).
|
||||
depends_via(A,B,C) :-
|
||||
crate_depends_on(A,D), \+memberchk(D,C),depends_via(D,B,[D|C]).
|
||||
illegal_dependency(A,B) :-
|
||||
crate_depends_on(A,B), depends_transitively(B,A).
|
||||
|
||||
before_fixing(Bug,Step) :-
|
||||
work_done(Bug,bug_fix,_), doctrine(bug_first,Step).
|
||||
|
||||
|
||||
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<String,String>; three tests in ui/effect.rs.').
|
||||
work_followup('improvise-8tk','improvise-lae','no-file-path UX: prompt for a path rather than only erroring. ExportPrompt is CSV-only, so a new SavePrompt TextEntry mode committing to SaveAs is the likely shape.').
|
||||
crate_depends_on(A,B),depends_transitively(B,A).
|
||||
before_fixing(A,B) :-
|
||||
work_done(A,bug_fix,C),doctrine(bug_first,B).
|
||||
work_unblocked('improvise-8tk','improvise-git','its stated blocker -- a Sequence has no way to stop between steps -- does not hold: abort_effects carries across a flattened Sequence.').
|
||||
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.').
|
||||
decision('improvise-907',done_step,'chose to make Done a real confirm step rather than dropping the variant -- user decision. Warnings from build_model_with_warnings now render in the wizard panel instead of being crammed into a one-line status bar.').
|
||||
decision('improvise-907',back_key,'Left is the back key on every step. Backspace is taken by the name buffer and the formula buffer; h is a printable character NameModel needs. Left is the only key free everywhere.').
|
||||
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.').
|
||||
decision('improvise-6sq',no_merge,'EditOrDrill reads ctx.display_value at Cmd time; EnterEditAtCursorCmd emits RebuildLayout then EnterEditAtCursor, which re-reads display_value_at_cursor after the rebuild. A merged command needs a rebuild_first/skip_drill flag -- the same fork renamed.').
|
||||
invariant(records_mode_all_none,'ToggleRecordsMode puts every regular category on Axis::None, so EditOrDrill regular_none is always true in records mode and the edit branch depends entirely on is_synthetic').
|
||||
work_followup('improvise-6sq','improvise-ct5','i on an empty records grid reports \'cannot drill\', leaking a pivot-mode concept into records mode').
|
||||
work_done('improvise-k8h',refactor,'TextEntry::new/with_parent now take the AppMode and read the buffer name off its MinibufferConfig, so the keymap no longer spells a buffer name. Added two guard tests in keymap.rs pinning mode<->keymap agreement and the completeness of the text-entry mode list.').
|
||||
invariant(buffer_name_single_source,'the buffer a text-entry mode types into is named once, in that mode AppMode constructor MinibufferConfig; TextEntry reads it off the mode so the prompt and the keystrokes cannot address different buffers').
|
||||
location(text_entry_builder,'crates/improvise-command/src/command/keymap.rs','TextEntry, buffer_of').
|
||||
work_done('improvise-4pg',refactor,'Extracted PanelKeymap builder for the three panel modes, following the TextEntry precedent. Panel::ALL/name()/key() drive the toggle keys for Normal and all three panels. Verified by diffing all 4065 (mode,key,modifier) lookups before and after: identical.').
|
||||
invariant(panel_keymap_spine,'the three panel modes share a spine built by PanelKeymap::new(panel): Esc/Tab to Normal, arrows and j/k to move-panel-cursor for that panel, every panel toggle key, and : for command mode. Content keys come through chained .bind/.bind_args/.delete because they vary in shape not just command name').
|
||||
invariant(panel_name_matches_parser,'Panel::name() must spell what parse_panel in cmd/core.rs accepts; pinned by panel_toggle_keys_dispatch_to_real_effects, since dispatch swallows an unresolvable command with .ok()?').
|
||||
doctrine(binding_dump_diff,'when refactoring keymap construction, dump every (mode,key,modifier) lookup for all 16 modes before and after and diff: most bindings have no direct test, so a green suite is not evidence').
|
||||
location(panel_keymap_builder,'crates/improvise-command/src/command/keymap.rs','PanelKeymap, bind_panel_toggles').
|
||||
|
||||
% ── Command-registry name-drift model (improvise-9cn / improvise-61f) ───────
|
||||
% How a registration gets the name it files a command under.
|
||||
name_source_kind(prototype_instance, safe, 'Cmd::name() is read off a real instance; the compiler fixes the type').
|
||||
name_source_kind(literal_string, unsafe, 'a hand-written string with nothing tying it to the type the closure builds').
|
||||
|
||||
% A command whose name() branches on a field: prototype and constructed value
|
||||
% share a TYPE but may differ in VARIANT, which no type check reaches.
|
||||
variant_named_cmd('ViewNavigate', forward).
|
||||
variant_named_cmd('TileAxisOp', axis).
|
||||
variant_named_cmd('ToggleGroupAtCursor', is_row).
|
||||
variant_named_cmd('CommitAndAdvance', advance).
|
||||
|
||||
% What each defence actually reaches.
|
||||
catches(prototype_instance, typo_in_name).
|
||||
catches(uniqueness_test, wrong_variant_prototype).
|
||||
catches(name_roundtrip_test, constructor_builds_other_name).
|
||||
catches(bound_names_test, keymap_references_missing_command).
|
||||
|
||||
% A wrong-variant prototype does NOT surface as a name mismatch: it collides
|
||||
% with its sibling, so every surviving entry still agrees with its own name
|
||||
% and the sibling's name disappears. Uniqueness is the check that reaches it.
|
||||
insufficient_for(name_roundtrip_test, wrong_variant_prototype).
|
||||
|
||||
needs_runtime_check(T) :- variant_named_cmd(T, _).
|
||||
|
||||
% ── Records mode: wide format (improvise-c5v) ───────────────────────────────
|
||||
record_row_identity(wide, coord_group_excluding_measure).
|
||||
record_column(wide, category_col, rename_coordinate, all_cells_in_row).
|
||||
record_column(wide, measure_col, write_cell_value, one_cell).
|
||||
record_column(wide, new_measure_col, create_measure, no_cells).
|
||||
% Both edit paths must implement every column kind.
|
||||
work_unblocked('improvise-rml','improvise-dz8','adding Axis::Filter now means filling in one file rather than finding four scattered exhaustive matches').
|
||||
work_unblocked('improvise-rml','improvise-dz8','adding the variant produced exactly ONE compile error outside axis.rs (the persistence writer), because rml had already collapsed the four display matches into one file. Evidence that the consolidation paid for itself.').
|
||||
work_unblocked('improvise-cqi','improvise-gxi','reduce_full can now live in improvise-command taking a protocol Command, with no cycle').
|
||||
name_source_kind(prototype_instance,safe,'Cmd::name() is read off a real instance; the compiler fixes the type').
|
||||
name_source_kind(literal_string,unsafe,'a hand-written string with nothing tying it to the type the closure builds').
|
||||
variant_named_cmd('ViewNavigate',forward).
|
||||
variant_named_cmd('TileAxisOp',axis).
|
||||
variant_named_cmd('ToggleGroupAtCursor',is_row).
|
||||
variant_named_cmd('CommitAndAdvance',advance).
|
||||
catches(prototype_instance,typo_in_name).
|
||||
catches(uniqueness_test,wrong_variant_prototype).
|
||||
catches(name_roundtrip_test,constructor_builds_other_name).
|
||||
catches(bound_names_test,keymap_references_missing_command).
|
||||
insufficient_for(name_roundtrip_test,wrong_variant_prototype).
|
||||
needs_runtime_check(A) :-
|
||||
variant_named_cmd(A,B).
|
||||
record_row_identity(wide,coord_group_excluding_measure).
|
||||
record_column(wide,category_col,rename_coordinate,all_cells_in_row).
|
||||
record_column(wide,measure_col,write_cell_value,one_cell).
|
||||
record_column(wide,new_measure_col,create_measure,no_cells).
|
||||
record_edit_path(plain_records).
|
||||
record_edit_path(drill_pending).
|
||||
record_obligation(P,C,A,S) :- record_edit_path(P), record_column(wide,C,A,S).
|
||||
% The case types cannot reach: a category edit spans the whole row.
|
||||
invariant(records_rename_spans_row,'editing a category column in wide records mode moves every measure cell in the row; moving only the cell under the cursor would tear the row apart').
|
||||
invariant(records_dim_is_column_index,'the synthetic records CellKey stores the column INDEX in _Dim, not a label, because a category and a measure item may share a name; resolve via GridLayout::record_col').
|
||||
location(records_layout,'crates/improvise-core/src/view/layout.rs','RecordRow, RecordCol, build_records_mode, records_display').
|
||||
|
||||
% ── Persistence storage abstraction (improvise-6mq) ─────────────────────────
|
||||
storage_op(read, 'whole document in').
|
||||
storage_op(write, 'whole document out').
|
||||
storage_backend(physical_fs, native, std_fs).
|
||||
storage_backend(memory_fs, tests, hashmap).
|
||||
invariant(storage_is_two_ops,'persistence only ever reads or writes one whole document, so the Storage trait is read/write bytes and nothing else; a backend that is not a filesystem implements it without directories, metadata or seeking').
|
||||
invariant(gzip_is_a_name_property,'the .gz suffix selects gzip in save_to/load_from, so it is a property of the document name and not of the storage backend').
|
||||
decision('improvise-6mq',narrow_trait_over_vfs_crate,'chose Option B (own two-method trait) over the ticket-recommended Option A (vfs crate): the real surface is read/write one document, and the ticket own con for A -- sync-only while OPFS is async -- means adopting A would mean doing B later anyway').
|
||||
location(storage_trait,'crates/improvise-io/src/storage.rs','Storage, PhysicalFs, MemoryFs').
|
||||
work_done('improvise-6mq',refactor,'persistence save/load routed through a Storage trait; save/load kept as PhysicalFs wrappers so all 83 persistence tests pass unchanged; MemoryFs gives a filesystem-free test fixture.').
|
||||
|
||||
% ── Effect classification for projection (improvise-mae) ────────────────────
|
||||
record_obligation(A,B,C,D) :-
|
||||
record_edit_path(A),record_column(wide,B,C,D).
|
||||
storage_op(read,'whole document in').
|
||||
storage_op(write,'whole document out').
|
||||
storage_op(list,'enumerate document names in a directory').
|
||||
storage_op(delete,'remove one document').
|
||||
storage_backend(physical_fs,native,std_fs).
|
||||
storage_backend(memory_fs,tests,hashmap).
|
||||
storage_backend(opfs_fs,'crates/improvise-io/src/opfs.rs','wasm32 only; Storage over the Origin Private File System').
|
||||
effect_kind_class(view_only,'never projected to other clients').
|
||||
effect_kind_class(cell_data,'cell values, categories, items').
|
||||
effect_kind_class(layout_geometry,'axis, page, hidden, grouping, format, active view').
|
||||
effect_kind_class(whole_workbook,'workbook replaced wholesale').
|
||||
invariant(effect_kind_no_default,'Effect::kind has no default impl, so a new effect cannot forget to classify itself; the failure mode would be silent (a mis-declared ViewOnly edit never reaches connected clients)').
|
||||
invariant(view_lives_in_model_state,'the View is stored inside model_state.workbook, so touching model_state does NOT imply a model change: axis/page/format are layout_geometry, cursor and scroll are per-client view_only, Save/SaveAs/MarkDirty are bookkeeping').
|
||||
decision('improvise-mae',cursor_is_per_client,'SetSelected/SetRowOffset/SetColOffset classed ViewOnly even though they write the shared persisted View, because one client scrolling must not move another viewport; the residual inconsistency is filed as improvise-8uz').
|
||||
doctrine(derive_then_verify,'when classifying many items from source, extract facts mechanically and reason in Prolog, but VERIFY the extraction: multi-line method chains and free-function helpers defeated naive regexes and mis-classified SetCell, SortData and ApplyAndClearDrill as view-only').
|
||||
location(effect_kind,'crates/improvise-command/src/ui/effect.rs','EffectKind, ModelChange, Effect::kind').
|
||||
work_done('improvise-mae',refactor,'all 58 effects classified as ViewOnly or Model(CellData|LayoutGeometry|WholeWorkbook); kind() has no default so the compiler enforces completeness; three tests verify declarations empirically by applying effects and fingerprinting shared state.').
|
||||
|
||||
% ── Unified client/server architecture: what actually crosses the wire ──────
|
||||
wire_carries(command, both_directions, 'user intent up, projection commands down').
|
||||
derived_by_fold(view_state, command_stream, reduce_view).
|
||||
derived_by_fold(render_cache, projection_commands, reduce_view).
|
||||
% The ONE exception: a client that never saw the stream needs a seed.
|
||||
wire_carries(snapshot, on_connect_only, 'improvise-q08: full ViewState + render cache filled from current viewport').
|
||||
invariant(view_state_is_a_projection,'the server does NOT serialize ViewState to maintain a client mirror -- it applies the same commands through reduce_view (improvise-cqq). Steady-state traffic is Commands only; ViewState is serialized solely for the bootstrap Snapshot').
|
||||
invariant(bootstrap_excludes_host_state,'because the only serialization point is the on-connect Snapshot, host-only state must not live in ViewState: a fresh client should not inherit a half-open import wizard, an armed prefix key, or another session staged drill edits').
|
||||
invariant(prefix_is_an_address,'ViewState::transient_prefix stores the (KeyCode,KeyModifiers) that opened a prefix; KeymapSet::prefix_keymap resolves it, walking parents. Assumes prefixes are FLAT -- guarded by prefix_keymaps_are_flat').
|
||||
decision('improvise-jc8',flip_the_arc,'Arc<Keymap> moved out of ViewState and back into KeymapSet, which already owns every keymap. Motivated by a hard dependency cycle -- Keymap is in improvise-command, which will depend on improvise-protocol once reduce_full lives there -- not by serde.').
|
||||
work_done('improvise-jc8',refactor,'ViewState no longer holds Arc<Keymap>; four characterization tests written before the change pinned gg/ZZ/prefix-consumption/records-inheritance, plus two new guards for flatness and parent resolution.').
|
||||
location(prefix_resolution,'crates/improvise-command/src/command/keymap.rs','KeymapSet::prefix_keymap, SetTransientPrefix').
|
||||
|
||||
% ── Wasm compatibility (improvise-ywd, audited 2026-08-30) ──────────────────
|
||||
derived_by_fold(view_state,command_stream,reduce_view).
|
||||
derived_by_fold(render_cache,projection_commands,reduce_view).
|
||||
derived_by_fold(view_state,command_stream,'both ends run reduce_view over the same commands, so the server mirror cannot drift from the client').
|
||||
derived_by_fold(render_cache,projection_commands,'local to each client; serialized only to ride in the bootstrap Snapshot').
|
||||
wasm_bundle_crate('improvise-core').
|
||||
wasm_bundle_crate('improvise-formula').
|
||||
wasm_bundle_crate('improvise-io').
|
||||
wasm_bundle_crate('improvise-command').
|
||||
not_in_wasm_bundle('improvise', 'terminal binary; depends on crossterm').
|
||||
wasm_checks_clean(all_bundle_crates, no_feature_flags_needed).
|
||||
% Hazards a compile check cannot see.
|
||||
wasm_runtime_hazard(instant_now, 'panics on wasm32-unknown-unknown', fixed_with(web_time)).
|
||||
wasm_runtime_hazard(std_fs, 'compiles on wasm but fails in a browser', tracked_as('improvise-fuj')).
|
||||
wasm_non_hazard(dirs, 'declared in root Cargo.toml but used nowhere').
|
||||
wasm_non_hazard(chrono_local_now, 'never called').
|
||||
wasm_non_hazard(std_env, 'only in a test').
|
||||
wasm_non_hazard(flate2, 'compiles for wasm as-is').
|
||||
invariant(compile_check_is_not_runtime_proof,'the wasm CI gate is cargo check, so it catches nothing that merely panics at runtime: Instant and std::fs both COMPILE for wasm32-unknown-unknown').
|
||||
work_done('improvise-ywd',audit,'added wasm32-unknown-unknown to the flake toolchain, which was the real blocker; all four bundle crates then checked clean with zero source changes. Swapped Instant for web_time and added the CI wasm job.').
|
||||
location(wasm_ci,'.github/workflows/ci.yml','wasm job').
|
||||
|
||||
% ── Storage injection (improvise-fuj) ───────────────────────────────────────
|
||||
storage_injection_point('App::storage', 'Box<dyn Storage>', 'chosen by the host at construction').
|
||||
storage_host(native_cli, physical_fs).
|
||||
storage_host(tests, memory_fs).
|
||||
storage_host(browser_worker, opfs, planned('improvise-i34')).
|
||||
invariant(effects_never_touch_fs,'every document read or write in the command layer goes through App::storage; production code in improvise-command contains no std::fs').
|
||||
invariant(io_keeps_path_wrappers,'improvise-io keeps path-taking wrappers (save, load, export_csv, parse_csv_with_warnings) delegating to _to/_from/_bytes variants, so the native CLI is unchanged while the byte-level path is backend-agnostic').
|
||||
% Why the sync trait survives contact with OPFS.
|
||||
opfs_api(promise_based, async, 'getDirectory, getFileHandle, createWritable').
|
||||
opfs_api(sync_access_handle, sync, 'createSyncAccessHandle; worker-only; read/write/flush/close are blocking').
|
||||
decision('improvise-i34',keep_storage_sync,'improvise-9x6 hosts App and persistence inside a browser worker, where FileSystemSyncAccessHandle is available. Async handle acquisition happens once at worker init; read/write are then synchronous, so the two-method sync trait stands and no async migration is needed. Caveat: asserted from API knowledge, untested here; the IndexedDB fallback IS async-only and would reopen the question.').
|
||||
work_done('improvise-fuj',refactor,'App gained a storage backend; Save/SaveAs/ExportCsv/LoadModel/StartImportWizard and CSV import all route through it. Test drives save, export, load and wizard-seed over MemoryFs with no disk.').
|
||||
|
||||
% ── Backlog grooming method (2026-08-30) ────────────────────────────────────
|
||||
wasm_bundle_crate(improvise_protocol).
|
||||
not_in_wasm_bundle(improvise,'terminal binary; depends on crossterm').
|
||||
wasm_checks_clean(all_bundle_crates,no_feature_flags_needed).
|
||||
wasm_runtime_hazard(instant_now,'panics on wasm32-unknown-unknown',fixed_with(web_time)).
|
||||
wasm_runtime_hazard(std_fs,'compiles on wasm but fails in a browser',tracked_as('improvise-fuj')).
|
||||
wasm_non_hazard(dirs,'declared in root Cargo.toml but used nowhere').
|
||||
wasm_non_hazard(chrono_local_now,'never called').
|
||||
wasm_non_hazard(std_env,'only in a test').
|
||||
wasm_non_hazard(flate2,'compiles for wasm as-is').
|
||||
storage_injection_point('App::storage','Box<dyn Storage>','chosen by the host at construction').
|
||||
storage_host(native_cli,physical_fs).
|
||||
storage_host(tests,memory_fs).
|
||||
storage_host(browser_worker,opfs,planned('improvise-i34')).
|
||||
opfs_api(promise_based,async,'getDirectory, getFileHandle, createWritable').
|
||||
opfs_api(sync_access_handle,sync,'createSyncAccessHandle; worker-only; read/write/flush/close are blocking').
|
||||
groom_check(cycles,'reaches/2 over blocks edges; found none').
|
||||
groom_check(priority_inversion,'a blocker with LOWER priority than what it blocks makes bd ready unworkable in priority order; rule = priority non-decreasing along dependency edges').
|
||||
groom_check(unrecorded_dep,'prose naming another issue is a CANDIDATE, never a fact: of 21 one-way mentions only 2 were real dependencies, several pointed the opposite way and were already recorded correctly').
|
||||
groom_check(stale_citation,'file:line references rot; resolve each against the tree and prefer symbol names when correcting').
|
||||
groom_check(dissolved_premise,'grep for the pattern an issue targets before working it -- improvise-ont described 11 call sites of a pattern that no longer occurs anywhere').
|
||||
doctrine(mentions_are_not_edges,'never bulk-add dependency edges from prose mentions; direction must be read per pair, and blind addition can invert real edges or create cycles').
|
||||
work_done('backlog-groom-2026-08-30',groom,'closed improvise-ont as obsolete; raised 7 priority inversions to P2; added 2 verified dependency edges out of 21 candidates; corrected stale refs on ai7/ct5/gdl and stale assumptions on jb3/git.').
|
||||
|
||||
% ── KB hygiene (audited 2026-08-30) ─────────────────────────────────────────
|
||||
groom_check(dissolved_premise_test_gap,'a test-gap ticket rots differently from a code ticket: the gap can be closed by someone else work without anyone touching the ticket. Re-run each item against the test names before working it -- 2 of 7yx five items were already covered, by jc8 and by an import fix.').
|
||||
kb_issue_found(host_rpc_shims,'sleep/delay/console_log/js_eval/future/await_any/future_cancel were the MCP runtime own predicates, captured into this file by a saveSession. Removed: not project knowledge, and the source of an overwriting warning on every consult. If saveSession re-adds them, strip them again.').
|
||||
kb_issue_found(may_depend_on_type_confusion,'may_depend_on(A,B) :- crate(A,C,D), \\+forbidden(A,B). C and D were singletons and B was never constrained, so may_depend_on(improvise_core, banana) succeeded. forbidden/2 second argument is a CONCERN, not a crate. Replaced by may_use/2 over an explicit concern/1, plus real crate_depends_on/2 facts read from the Cargo.toml files.').
|
||||
kb_issue_found(before_fixing_dead_guard,'before_fixing(A,B) :- A=C, doctrine(bug_first,B). A=C bound A to a fresh variable and constrained nothing, so the rule succeeded for any first argument. Now requires work_done(Bug,bug_fix,_).').
|
||||
kb_issue_found(discontiguous,'nine predicates are appended chronologically and so are scattered; added :- discontiguous declarations rather than reordering, because the chronological shape is the point.').
|
||||
doctrine(kb_is_code,'the knowledge base is executable, so audit it like code: check rules actually reject what they should, and check location/3 symbols still exist in the tree').
|
||||
|
||||
% ── Self-check ──────────────────────────────────────────────────────────────
|
||||
% Run `?- kb_report.` at the start of a session, or `?- kb_consistent.` for a
|
||||
% yes/no. Every violation is a kb_error/1 solution; kb_consistent succeeds
|
||||
% only when there are none.
|
||||
%
|
||||
% From a shell (this is what CI runs, see .github/workflows/ci.yml):
|
||||
%
|
||||
% nix shell nixpkgs#trealla --command tpl prolog-sessions/kb.pl \
|
||||
% -g "(kb_consistent -> halt(0) ; (kb_report, halt(1)))"
|
||||
%
|
||||
% Prefer the shell form when adding checks: the MCP server is more lenient
|
||||
% than trealla itself and silently accepted `:- discontiguous foo/1.`, which
|
||||
% trealla rejects (it wants the parenthesized form).
|
||||
%
|
||||
% What this CANNOT check, because Prolog cannot see the tree: whether
|
||||
% location/3 still names a symbol that exists. Do that from the shell —
|
||||
% see kb_external_check/1 for the command.
|
||||
|
||||
% Predicates whose first argument is a key that must map to ONE value.
|
||||
% Anything genuinely multi-valued (forbidden/2, record_column/4, decision/3,
|
||||
% which is keyed by issue AND topic) is deliberately absent.
|
||||
kb_issue_found(checker_never_failed,'a consistency check that has only ever reported success is untested. Inject one fault per kb_error class and confirm each is reported before trusting it.').
|
||||
kb_issue_found(cycle_unsafe_closure,'the first depends_transitively/2 was a naive transitive closure, so illegal_dependency/2 -- whose whole purpose is detecting an inverted edge -- looped forever on exactly that input. Found by fault injection; now carries a visited set.').
|
||||
kb_issue_found(mcp_lenience,'the prolog MCP server accepted \`:- discontiguous foo/1.\` which trealla rejects (needs parens). Validate kb.pl with trealla itself, not only through the MCP session.').
|
||||
kb_issue_found(static_str_blocks_deserialize,'a borrowed &\'static str field makes Deserialize unimplementable, and rustc reports it on every ENCLOSING enum variant rather than at the offending field -- five near-identical \"lifetime may not live long enough\" errors pointing at MinibufferConfig, none naming buffer_key. Read past the reported site to the borrowed field.').
|
||||
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. Two writers only (switch_to_view, install_workbook), guarded by the_two_active_view_names_never_diverge.').
|
||||
functional(invariant/2).
|
||||
functional(location/3).
|
||||
functional(doctrine/2).
|
||||
@@ -300,270 +291,101 @@ functional(groom_check/2).
|
||||
functional(kb_issue_found/2).
|
||||
functional(crate/3).
|
||||
functional(wasm_runtime_hazard/3).
|
||||
|
||||
% A sentinel that is deliberately not a member of any declared domain. A rule
|
||||
% that accepts it is under-constrained — this is how the two unsound rules of
|
||||
% 2026-08-30 would have been caught.
|
||||
kb_sentinel(zzz_not_a_real_term).
|
||||
|
||||
known_crate(C) :- crate(C,_,_).
|
||||
|
||||
% -- domain integrity ------------------------------------------------------
|
||||
kb_error(layer_names_unknown_crate(L,C)) :-
|
||||
layer(L,C), \+ known_crate(C).
|
||||
kb_error(forbidden_names_unknown_crate(C)) :-
|
||||
forbidden(C,_), \+ known_crate(C).
|
||||
kb_error(forbidden_names_unknown_concern(C,X)) :-
|
||||
forbidden(C,X), \+ concern(X).
|
||||
known_crate(A) :-
|
||||
crate(A,B,C).
|
||||
kb_error(layer_names_unknown_crate(A,B)) :-
|
||||
layer(A,B), \+known_crate(B).
|
||||
kb_error(forbidden_names_unknown_crate(A)) :-
|
||||
forbidden(A,B), \+known_crate(A).
|
||||
kb_error(forbidden_names_unknown_concern(A,B)) :-
|
||||
forbidden(A,B), \+concern(B).
|
||||
kb_error(depends_names_unknown_crate(A,B)) :-
|
||||
crate_depends_on(A,B), \+ ( known_crate(A), known_crate(B) ).
|
||||
crate_depends_on(A,B), \+ (known_crate(A),known_crate(B)).
|
||||
kb_error(layering_inverted(A,B)) :-
|
||||
illegal_dependency(A,B).
|
||||
|
||||
% -- rule soundness: these must REJECT the sentinel ------------------------
|
||||
illegal_dependency(A,B).
|
||||
kb_error(rule_too_permissive(may_use/2)) :-
|
||||
kb_sentinel(S), may_use(_, S).
|
||||
kb_sentinel(A),may_use(B,A).
|
||||
kb_error(rule_too_permissive(before_fixing/2)) :-
|
||||
kb_sentinel(S), before_fixing(S, _).
|
||||
kb_sentinel(A),before_fixing(A,B).
|
||||
kb_error(rule_too_permissive(crate_depends_on/2)) :-
|
||||
kb_sentinel(S), crate_depends_on(S, _).
|
||||
|
||||
% -- functional keys must not carry conflicting values ---------------------
|
||||
kb_error(conflicting_values(P/2, Key)) :-
|
||||
functional(P/2), call(P, Key, _),
|
||||
findall(V, call(P, Key, V), Vs), sort(Vs, Sorted), Sorted = [_,_|_].
|
||||
kb_error(conflicting_values(P/3, Key)) :-
|
||||
functional(P/3), call(P, Key, _, _),
|
||||
findall(A-B, call(P, Key, A, B), Vs), sort(Vs, Sorted), Sorted = [_,_|_].
|
||||
|
||||
% -- cross-references between recorded work --------------------------------
|
||||
kb_error(followup_from_unrecorded_work(I)) :-
|
||||
work_followup(I,_,_), \+ work_done(I,_,_).
|
||||
kb_error(unblocked_by_unrecorded_work(I)) :-
|
||||
work_unblocked(I,_,_), \+ work_done(I,_,_).
|
||||
|
||||
kb_consistent :- \+ kb_error(_).
|
||||
|
||||
kb_sentinel(A),crate_depends_on(A,B).
|
||||
kb_error(conflicting_values(A/2,B)) :-
|
||||
functional(A/2),call(A,B,C),findall(D,call(A,B,D),E),sort(E,F),F=[G,H|I].
|
||||
kb_error(conflicting_values(A/3,B)) :-
|
||||
functional(A/3),call(A,B,C,D),findall(E-F,call(A,B,E,F),G),sort(G,H),H=[I,J|K].
|
||||
kb_error(followup_from_unrecorded_work(A)) :-
|
||||
work_followup(A,B,C), \+work_done(A,D,E).
|
||||
kb_error(unblocked_by_unrecorded_work(A)) :-
|
||||
work_unblocked(A,B,C), \+work_done(A,D,E).
|
||||
kb_error(field_misplaced_in_view_state(A)) :-
|
||||
session_field(A,view_state,B), \+view_state_admits(A).
|
||||
kb_error(field_evicted_without_cause(A)) :-
|
||||
session_field(A,app,B),view_state_admits(A).
|
||||
kb_consistent :-
|
||||
\+kb_error(A).
|
||||
kb_report :-
|
||||
( setof(E, kb_error(E), Es)
|
||||
-> length(Es, N),
|
||||
format("kb: ~w problem(s)~n", [N]),
|
||||
forall(member(E, Es), format(" - ~w~n", [E]))
|
||||
; write('kb: consistent'), nl
|
||||
).
|
||||
|
||||
kb_external_check('grep -oE "^location\\([a-z_]+,''[^'']+'',''[^'']+''\\)" prolog-sessions/kb.pl # then confirm each file/symbol still exists').
|
||||
|
||||
|
||||
% Each check class is proven to FIRE, not merely to pass, by injecting the
|
||||
% fault it looks for; a checker that has only ever said "consistent" has
|
||||
% demonstrated nothing (see kb_issue_found(checker_never_failed, ...)).
|
||||
kb_issue_found(checker_never_failed,'a consistency check that has only ever reported success is untested. Inject one fault per kb_error class and confirm each is reported before trusting it.').
|
||||
kb_issue_found(cycle_unsafe_closure,'the first depends_transitively/2 was a naive transitive closure, so illegal_dependency/2 -- whose whole purpose is detecting an inverted edge -- looped forever on exactly that input. Found by fault injection; now carries a visited set.').
|
||||
kb_issue_found(mcp_lenience,'the prolog MCP server accepted `:- discontiguous foo/1.` which trealla rejects (needs parens). Validate kb.pl with trealla itself, not only through the MCP session.').
|
||||
doctrine(check_the_checker,'prove a consistency check fires by injecting the fault it targets, then revert; otherwise it is indistinguishable from a check that always succeeds').
|
||||
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% improvise-h0b: a decision procedure for ViewState membership.
|
||||
%
|
||||
% ViewState is serialized at exactly ONE point -- the Snapshot handed to a
|
||||
% newly-connected client (improvise-q08). So a field may live there only if
|
||||
% it is portable AND snapshot-safe. This is a rule, not a log: to place a new
|
||||
% session field, add a session_field/3 fact and ask view_state_admits/1.
|
||||
% ---------------------------------------------------------------------------
|
||||
|
||||
% Crates a portable type may be drawn from. improvise_io is absent on
|
||||
% purpose: it is the persistence/import layer, not wire data.
|
||||
setof(A,kb_error(A),B)->length(B,C),format("kb: ~w problem(s)~n",[C]),forall(member(A,B),format(" - ~w~n",[A]));write('kb: consistent'),nl.
|
||||
kb_external_check('grep -oE \"^location\\([a-z_]+,\'[^\']+\',\'[^\']+\'\\)\" prolog-sessions/kb.pl # then confirm each file/symbol still exists').
|
||||
portable_origin(improvise_core).
|
||||
portable_origin(improvise_formula).
|
||||
portable_origin(improvise_command). % crate-local plain-data types
|
||||
portable_origin(std_plain). % String, usize, bool, Vec, HashMap ...
|
||||
|
||||
% session_field(Name, Owner, TypeOrigin). Owner is view_state or app.
|
||||
session_field(mode, view_state, improvise_command).
|
||||
session_field(status_msg, view_state, std_plain).
|
||||
session_field(cursors, view_state, improvise_command).
|
||||
session_field(search_query, view_state, std_plain).
|
||||
session_field(search_mode, view_state, std_plain).
|
||||
session_field(formula_panel_open, view_state, std_plain).
|
||||
session_field(category_panel_open,view_state, std_plain).
|
||||
session_field(view_panel_open, view_state, std_plain).
|
||||
session_field(cat_panel_cursor, view_state, std_plain).
|
||||
session_field(view_panel_cursor, view_state, std_plain).
|
||||
session_field(formula_cursor, view_state, std_plain).
|
||||
session_field(yanked, view_state, improvise_core).
|
||||
session_field(tile_cat_idx, view_state, std_plain).
|
||||
session_field(view_back_stack, view_state, improvise_command).
|
||||
session_field(view_forward_stack, view_state, improvise_command).
|
||||
session_field(help_page, view_state, std_plain).
|
||||
session_field(expanded_cats, view_state, std_plain).
|
||||
session_field(buffers, view_state, std_plain).
|
||||
session_field(transient_prefix, view_state, improvise_command).
|
||||
session_field(wizard, app, improvise_io).
|
||||
session_field(drill_state, app, improvise_command).
|
||||
|
||||
% Disqualifiers observed in the source.
|
||||
% A shared-ownership container crosses neither a serialization nor a thread
|
||||
% boundary; AGENTS.md also asks that Rc/Box plumbing be split from logic.
|
||||
holds_shared_ptr(drill_state). % DrillState.records : Rc<Vec<RecordRow>>
|
||||
% Values a FRESH client must not inherit from whoever was here before.
|
||||
uncommitted_writes(drill_state). % DrillState.pending_edits
|
||||
half_open_dialog(wizard). % a partially-filled ImportWizard
|
||||
|
||||
portable(F) :-
|
||||
session_field(F,_,O), portable_origin(O), \+ holds_shared_ptr(F).
|
||||
snapshot_safe(F) :-
|
||||
session_field(F,_,_), \+ uncommitted_writes(F), \+ half_open_dialog(F).
|
||||
view_state_admits(F) :- portable(F), snapshot_safe(F).
|
||||
|
||||
kb_error(field_misplaced_in_view_state(F)) :-
|
||||
session_field(F,view_state,_), \+ view_state_admits(F).
|
||||
kb_error(field_evicted_without_cause(F)) :-
|
||||
session_field(F,app,_), view_state_admits(F).
|
||||
|
||||
invariant(view_state_send,'ViewState: Send is asserted at compile time in crates/improvise-command/src/ui/app.rs tests. Send is a proxy for portable/1 that only catches the shared-pointer clause; it sees neither the origin-crate restriction nor snapshot_safe/1, so those stay hand-checked against view_state_admits/1.').
|
||||
location(view_state_membership,'crates/improvise-command/src/ui/app.rs','ViewState doc comment states both membership tests; App carries the session-local residue tag').
|
||||
decision('improvise-h0b',fields_on_app_not_a_wrapper,'The ticket sketched a HostState { view, wizard, drill_state } wrapper. Rejected: App already IS the host struct, with a documented residue taxonomy (derived cache / runtime metadata / transient / config). Added a fifth tag, session-local, and put the two fields directly on App. This is what jc8 actually did too -- it moved keymap ownership to KeymapSet on App rather than introducing a wrapper.').
|
||||
work_done('improvise-h0b',refactor,'wizard and drill_state moved from ViewState to App. Demonstrated first with a ViewState: Send static assertion that failed to compile, naming Rc<Vec<RecordRow>> exactly. Second test proves staged drill edits are unreachable from ViewState Debug output.').
|
||||
work_followup('improvise-h0b','improvise-a2x','ViewState field types (AppMode, MinibufferConfig, ViewFrame, KeyCode, KeyModifiers) still derive no Serialize, and improvise-command depends only on serde_json. Deferred: nothing serializes ViewState until improvise-cqi builds a Snapshot, so the derives would have no consumer.').
|
||||
doctrine(prove_by_compile_failure,'for a structural refactor with no behavioural delta, a static assertion that fails to compile before the change is the demonstration -- it names the offending type itself, and survives as a guard that the field cannot come back').
|
||||
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% improvise-a2x: making ViewState actually serializable.
|
||||
% ---------------------------------------------------------------------------
|
||||
kb_issue_found(static_str_blocks_deserialize,'a borrowed &''static str field makes Deserialize unimplementable, and rustc reports it on every ENCLOSING enum variant rather than at the offending field -- five near-identical "lifetime may not live long enough" errors pointing at MinibufferConfig, none naming buffer_key. Read past the reported site to the borrowed field.').
|
||||
invariant(no_borrowed_static_in_view_state,'no type reachable from ViewState may hold a borrowed-''static field; Deserialize cannot produce one. Guarded by view_state_survives_a_json_round_trip in crates/improvise-command/src/ui/app.rs.').
|
||||
decision('improvise-a2x',buffer_key_becomes_string,'buffer_key: &''static str -> String. Cow<''static,str> would keep construction allocation-free but puts container plumbing in a public field, which AGENTS.md asks to keep out of logic. A BufferKey enum would close the name set but drags the buffers map key type, the script parser''s buffer args ("append-char formula") and the hand-written "search" buffer with it -- out of scope. Mode construction is per-keypress, so seven small allocations are not a cost worth designing around.').
|
||||
decision('improvise-a2x',k8h_enum_rejection_does_not_bite,'improvise-k8h rejected a Buffer enum on the DRIFT argument: each side would still name a variant, so the two could still disagree. That objection is about who writes the name, not what type it has -- buffer_of/1 still reads it off the mode either way. So k8h does not forbid an enum here; scope does.').
|
||||
work_done('improvise-a2x',refactor,'serde added to improvise-command; Serialize+Deserialize derived on AppMode, MinibufferConfig, MinibufColor, ViewFrame, KeyCode, KeyModifiers and ViewState. buffer_key and TextEntry::buffer became owned Strings. Roundtrip test populates one field per family -- a default-value roundtrip would pass while proving nothing.').
|
||||
doctrine(populate_before_roundtrip,'a serialization roundtrip test over a default-constructed value proves almost nothing; populate one non-default value per field family, especially the variants that carry payloads').
|
||||
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% improvise-8uz: the cursor was per-view state living in the shared document.
|
||||
% ---------------------------------------------------------------------------
|
||||
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.').
|
||||
decision('improvise-8uz',none_of_the_three_options,'The ticket offered (a) move to ViewState and stop persisting, (b) View keeps the saved starting position and ViewState tracks the live cursor, (c) leave it. Scored against per_client + preserves_behaviour + no_dead_rationale, all three fail: (a) drops the per-view restore, (b) rests on a persistence that does not exist so its View field would be written and never read, (c) is not per-client at all. The synthesis (d) satisfies all three: View loses the fields outright, ViewState holds HashMap<view_name, ViewCursor>.').
|
||||
work_done('improvise-8uz',refactor,'View lost selected/row_offset/col_offset; ViewState gained cursors: HashMap<String,ViewCursor>, reached via App::cursor/cursor_mut/reset_cursor. Cursor resets moved from View::transpose_axes and View::cycle_axis into the TransposeAxes and CycleAxis effects. Workbook::normalize_view_state deleted as vacuous; the two effects that swap the whole document now clear the cursor map instead.').
|
||||
decision('improvise-8uz',exemption_deleted,'the view_only_effects_leave_shared_state_alone exemption for SetSelected/SetRowOffset/SetColOffset is deleted: they no longer touch anything shared, so every ViewOnly effect now clears the same bar with no special cases. That exemption was the ticket''s original symptom.').
|
||||
doctrine(check_the_ticket_premise,'a ticket that states a fact about the system as the basis for its options is stating a hypothesis; test it before choosing among the options, because a false premise usually invalidates the option set rather than one option').
|
||||
doctrine(characterization_survives_the_move,'for a relocation refactor, write the behaviour test against the OLD location first and carry it across unchanged in meaning; if it needs new assertions to pass, the relocation changed behaviour').
|
||||
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% improvise-rml: axis presentation consolidated onto the Axis enum.
|
||||
% ---------------------------------------------------------------------------
|
||||
location(axis_presentation,'crates/improvise-core/src/view/axis.rs','plain_label/glyph/short_label/initial/color/next on Axis; AxisColor semantic palette').
|
||||
location(axis_color_resolution,'src/draw.rs','axis_fg maps AxisColor to a ratatui Color, beside minibuf_fg which does the same for MinibufColor').
|
||||
invariant(semantic_palette_at_the_core_boundary,'a core type that needs a color names a semantic enum, never a ratatui Color; the rendering layer owns the one mapping. Precedents: MinibufColor -> minibuf_fg, AxisColor -> axis_fg. This is what forbidden(improvise_core,ui) requires in practice.').
|
||||
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.').
|
||||
decision('improvise-rml',semantic_color_not_ratatui,'the ticket specified display_short() -> (&''static str, Color). Color is a ratatui type and Axis lives in improvise-core, so that signature is the layering violation forbidden(improvise_core,ui) names. Split into color() -> AxisColor plus short_label(), with draw.rs::axis_fg resolving the palette.').
|
||||
decision('improvise-rml',is_data_axis_omitted,'the ticket also asked for is_data_axis() -> bool. No caller wanted it, so it was left out rather than added speculatively.').
|
||||
work_done('improvise-rml',refactor,'Axis gained next/color/plain_label/glyph/short_label/initial; Display is now plain_label+glyph. Four duplicate match sites replaced: tile_bar, category_panel, view_panel, cmd/tile.rs. View::cycle_axis now calls Axis::next. Characterization test pins all four label vocabularies verbatim.').
|
||||
work_unblocked('improvise-rml','improvise-dz8','adding Axis::Filter now means filling in one file rather than finding four scattered exhaustive matches').
|
||||
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% improvise-dz8: Axis::Filter -- constrains the data without being navigable.
|
||||
% ---------------------------------------------------------------------------
|
||||
axis_property(row, navigable).
|
||||
axis_property(column, navigable).
|
||||
axis_property(page, navigable).
|
||||
axis_property(page, constrains).
|
||||
axis_property(filter, constrains).
|
||||
axis_property(none, neither).
|
||||
|
||||
% The two questions that must not be confused. Asking the wrong one is the
|
||||
% whole bug class this variant introduces.
|
||||
portable_origin(improvise_command).
|
||||
portable_origin(std_plain).
|
||||
session_field(mode,view_state,improvise_command).
|
||||
session_field(status_msg,view_state,std_plain).
|
||||
session_field(cursors,view_state,improvise_command).
|
||||
session_field(search_query,view_state,std_plain).
|
||||
session_field(search_mode,view_state,std_plain).
|
||||
session_field(formula_panel_open,view_state,std_plain).
|
||||
session_field(category_panel_open,view_state,std_plain).
|
||||
session_field(view_panel_open,view_state,std_plain).
|
||||
session_field(cat_panel_cursor,view_state,std_plain).
|
||||
session_field(view_panel_cursor,view_state,std_plain).
|
||||
session_field(formula_cursor,view_state,std_plain).
|
||||
session_field(yanked,view_state,improvise_core).
|
||||
session_field(tile_cat_idx,view_state,std_plain).
|
||||
session_field(view_back_stack,view_state,improvise_command).
|
||||
session_field(view_forward_stack,view_state,improvise_command).
|
||||
session_field(help_page,view_state,std_plain).
|
||||
session_field(expanded_cats,view_state,std_plain).
|
||||
session_field(buffers,view_state,std_plain).
|
||||
session_field(transient_prefix,view_state,improvise_command).
|
||||
session_field(wizard,app,improvise_io).
|
||||
session_field(drill_state,app,improvise_command).
|
||||
holds_shared_ptr(drill_state).
|
||||
uncommitted_writes(drill_state).
|
||||
half_open_dialog(wizard).
|
||||
portable(A) :-
|
||||
session_field(A,B,C),portable_origin(C), \+holds_shared_ptr(A).
|
||||
snapshot_safe(A) :-
|
||||
session_field(A,B,C), \+uncommitted_writes(A), \+half_open_dialog(A).
|
||||
view_state_admits(A) :-
|
||||
portable(A),snapshot_safe(A).
|
||||
axis_property(row,navigable).
|
||||
axis_property(column,navigable).
|
||||
axis_property(page,navigable).
|
||||
axis_property(page,constrains).
|
||||
axis_property(filter,constrains).
|
||||
axis_property(none,neither).
|
||||
answers(filtering_cats,'what data is visible',constrains).
|
||||
answers(categories_on_page,'what the user can page through',navigable).
|
||||
answers(tileable_cats,'what the user can reassign',navigable).
|
||||
|
||||
consumer(grid_layout,filtering_cats).
|
||||
consumer(add_record_row,filtering_cats).
|
||||
consumer(page_cycling,categories_on_page).
|
||||
consumer(tile_bar,tileable_cats).
|
||||
consumer(move_tile_cursor,tileable_cats).
|
||||
consumer(tile_axis_op,tileable_cats).
|
||||
|
||||
invariant(filter_outside_rotation,'Axis::next maps Filter to itself and no navigable axis rotates into it, so tile select and cycle-axis cannot hand a user a Filter assignment. Guarded by filter_is_outside_the_rotation in crates/improvise-core/src/view/axis.rs.').
|
||||
decision('improvise-dz8',filter_persists_in_the_format,'added "filter" to axis_kind in improv.pest and to both sides of the writer/parser, even though Filter only ever occurs in the _Drill view which is never written. The alternative to supporting it was silently writing a Filter axis out as some other axis.').
|
||||
work_done('improvise-dz8',feature,'Axis::Filter added with filters_data/1 and is_user_assignable/1; View gained filtering_cats and tileable_cats. DrillIntoCell fixes coordinates on Filter instead of Page. GridLayout and add-record-row switched to filtering_cats; the three tile-select sites to tileable_cats; page cycling deliberately left on categories_on(Axis::Page).').
|
||||
work_unblocked('improvise-rml','improvise-dz8','adding the variant produced exactly ONE compile error outside axis.rs (the persistence writer), because rml had already collapsed the four display matches into one file. Evidence that the consolidation paid for itself.').
|
||||
doctrine(two_questions_one_enum,'when a new enum variant shares one property with an existing variant but not another, name both properties as predicates (filters_data, is_user_assignable) and route every call site through the one it means; testing for the variant directly is what goes stale when a third variant arrives').
|
||||
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% improvise-i34: OPFS-backed Storage. The trait stayed synchronous.
|
||||
% ---------------------------------------------------------------------------
|
||||
storage_op(list,'enumerate document names in a directory').
|
||||
storage_op(delete,'remove one document').
|
||||
storage_backend(opfs_fs,'crates/improvise-io/src/opfs.rs','wasm32 only; Storage over the Origin Private File System').
|
||||
|
||||
% Which OPFS operations are genuinely async, and how each is reconciled with
|
||||
% a synchronous trait. This is the design, not a note about it.
|
||||
opfs_async(get_directory, worker_init, 'OpfsFs::open').
|
||||
opfs_async(get_file_handle, per_document, 'OpfsFs::attach').
|
||||
opfs_async(create_sync_access_handle, per_document, 'OpfsFs::attach').
|
||||
opfs_async(directory_iteration, refreshed, 'OpfsFs::refresh populates the index list/1 answers from').
|
||||
opfs_async(remove_entry, deferred, 'delete/1 queues it; OpfsFs::commit_deletes drains at end of batch').
|
||||
opfs_async(get_directory,worker_init,'OpfsFs::open').
|
||||
opfs_async(get_file_handle,per_document,'OpfsFs::attach').
|
||||
opfs_async(create_sync_access_handle,per_document,'OpfsFs::attach').
|
||||
opfs_async(directory_iteration,refreshed,'OpfsFs::refresh populates the index list/1 answers from').
|
||||
opfs_async(remove_entry,deferred,'delete/1 queues it; OpfsFs::commit_deletes drains at end of batch').
|
||||
opfs_sync(read).
|
||||
opfs_sync(write).
|
||||
opfs_sync(truncate).
|
||||
opfs_sync(flush).
|
||||
opfs_sync(close).
|
||||
|
||||
browser_fact(sync_access_handle_safari,'Safari 16.4+ supports createSyncAccessHandle, and all current browsers implement the handle methods synchronously despite an early spec that wrongly called them async. Checked 2026-08-31, so the IndexedDB fallback improvise-i34 called for is not needed.').
|
||||
browser_fact(sync_access_handle_worker_only,'available only in dedicated Web Workers, and exclusive per file entry: a second handle on the same file fails until the first is closed. Hence attach closing what it replaces, and detach existing at all.').
|
||||
decision('improvise-i34',no_indexeddb_fallback,'user decision 2026-08-31: skip the IndexedDB fallback unless recent Safari needs it. It does not -- Safari 16.4+ has synchronous access handles -- so the fallback is out of scope and the async question it would have forced does not arise.').
|
||||
decision('improvise-i34',delete_is_deferred,'removeEntry is async and the trait is sync, so delete/1 drops the name from the index and queues the removal. A delete is therefore visible to read and list at once and lands in OPFS when commit_deletes is awaited. Invisible to a single worker; the alternative was making the whole trait async for one operation.').
|
||||
work_done('improvise-i34',feature,'Storage gained list/delete, implemented for PhysicalFs and MemoryFs with a shared storage_contract test both satisfy. OpfsFs added behind cfg(target_arch=wasm32) using web-sys sync access handles, with open/attach/refresh/commit_deletes as the async seam. Compile- and clippy-clean for wasm32.').
|
||||
work_followup('improvise-i34','browser_verification','OpfsFs is compile-checked only -- no browser in this environment. storage_contract in storage.rs is the sequence it must satisfy. User will verify at the web MVP.').
|
||||
doctrine(contract_test_for_the_unreachable_backend,'when one implementation of a trait cannot be exercised by the test harness, write the contract as a function over &dyn Trait and run it against the reachable implementations; it becomes the executable specification the unreachable one is held to').
|
||||
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% improvise-cqi: the improvise-protocol crate.
|
||||
% ---------------------------------------------------------------------------
|
||||
crate(improvise_protocol,'crates/improvise-protocol','wire vocabulary + portable session slice: Command, ViewState, RenderCache, reduce_view').
|
||||
layer(protocol,improvise_protocol).
|
||||
crate_depends_on(improvise_protocol,improvise_core).
|
||||
crate_depends_on(improvise_command,improvise_protocol).
|
||||
forbidden(improvise_protocol,ui).
|
||||
forbidden(improvise_protocol,io).
|
||||
forbidden(improvise_protocol,commands).
|
||||
wasm_bundle_crate(improvise_protocol).
|
||||
|
||||
% What travels, and what merely folds. Confusing these inverts the design.
|
||||
wire_carries(command,both_directions,'user intent client->server, projections server->client').
|
||||
wire_carries(snapshot,bootstrap_only,'the ONE point ViewState is serialized: a value for a fresh client to fold later commands onto').
|
||||
derived_by_fold(view_state,command_stream,'both ends run reduce_view over the same commands, so the server mirror cannot drift from the client').
|
||||
derived_by_fold(render_cache,projection_commands,'local to each client; serialized only to ride in the bootstrap Snapshot').
|
||||
|
||||
decision('improvise-cqi',command_set_derived_not_invented,'the client->server half of Command is exactly the set of EffectKind::ViewOnly effects whose target is the portable slice. Effects touching host-local state (wizard, drill_state, storage, layout cache) are absent by construction -- improvise-h0b had already moved those off ViewState, so the eviction did the enumeration.').
|
||||
decision('improvise-cqi',view_state_carries_its_own_active_view,'reduce_view runs on a client with no Workbook, and cursors is keyed by view name, so ViewState needs its own active_view. Kept identical to Workbook::active_view by App::switch_to_view and App::install_workbook; whether they MAY diverge is improvise-g0f.').
|
||||
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. Two writers only (switch_to_view, install_workbook), guarded by the_two_active_view_names_never_diverge.').
|
||||
work_done('improvise-cqi',feature,'crates/improvise-protocol created. ViewState, ViewCursor, ViewFrame, AppMode, Panel, MinibufferConfig, MinibufColor, HELP_PAGE_COUNT and the key types moved there; improvise-command re-exports them so no caller path changed. Command + reduce_view + RenderCache/CellDisplay/Snapshot added with 15 tests. Added to the CI wasm job.').
|
||||
work_unblocked('improvise-cqi','improvise-gxi','reduce_full can now live in improvise-command taking a protocol Command, with no cycle').
|
||||
doctrine(derive_the_enum_from_the_effects,'when a wire vocabulary must mirror an existing mutation set, enumerate the mutations and let that be the enum; inventing variants ahead of the mutations produces a protocol nobody can implement against').
|
||||
|
||||
% --- 2026-09-19: reconcile recent commits against the tracker ---
|
||||
work_done('improvise-sef',maintenance,'kb.pl audited and repaired (a406b52): may_depend_on/2 and before_fixing/2 were unsound, and sixteen lines of captured MCP runtime predicates were removed. kb_report/0 and kb_consistent/0 added (3a58d3c) over four error classes -- domain integrity, layering, rule soundness, functional keys -- and wired into CI; every class verified by fault injection. Backlog-grooming method recorded (3697171).').
|
||||
decision('improvise-sef',retroactive_ticket,'The three kb commits landed with no beads issue at all. Filed and closed 2026-09-19 during a commit/tracker reconciliation rather than left untracked, because the CI job they add is a durable capability someone will later ask the provenance of. Every other commit back to cc3889d already named a ticket that is closed with a close_reason.').
|
||||
|
||||
% ── Cloud-session tooling (PR #7) ───────────────────────────────────────────
|
||||
doctrine(force_the_failure,'an unchecked status whose failure yields a usable-looking value is not a style nit: force the failure and look at what the next line does with it. Four on one branch -- bd init exits non-zero but still leaves an empty database behind, an unchecked mv lands the new tracker INSIDE the old directory and reports success, jq status swallowed by a process substitution reads as "no inputs found" and exits 0, and a failed mktemp turns "$TMP/$NAME" into an absolute path at the filesystem root. Every one looked like success at runtime, so reading the failure paths found none of them; stubbing mv and mktemp to fail is what exposed them. Note chmod a-w proves nothing when the tests run as root -- it returned 0 and looked like a pass.').
|
||||
|
||||
% ── Backlog grooming (2026-09-19) ───────────────────────────────────────────
|
||||
work_done('backlog-groom-2026-09-19',groom,'ran the five 2026-08-30 checks over 62 open/deferred issues. Cycles: none. Priority inversion: one (g0f P3 blocking cqq P2), raised. Unrecorded deps: 11 prose mentions, 1 real (lae blocks git, by the ticket own words), the rest analogues or siblings. Stale citations: 2 broken paths (nr5 doubled crate prefix, three gp-tree tickets pointing at examples/fit_csv.rs instead of crates/gp-tree/examples/), 9 line-number drifts corrected to symbol references. Dissolved premises: ede and toc closed, ete demoted to P4, 2 of 5 items struck from 7yx.').
|
||||
decision('backlog-groom-2026-09-19',render_cache_name_collision,'improvise-cqi landed RenderCache as a flat grid-only wire type, while six open migration tickets (35e, n10, pca, 764, jb3, edp) were written against a fat per-widget cache of the same name. Two objects, one name, and nothing in the tree complains until someone writes from_app. Filed improvise-rkeq to decide the shape and made it block 35e rather than silently rewriting six tickets to match whichever shape a groomer preferred.').
|
||||
decision('backlog-groom-2026-09-19',retroactive_tickets_for_untracked_merges,'PR #7 (cloud-session tooling) landed 18 commits with no beads issue, the same gap the kb commits had. Filed improvise-3qz2 closed. Rule being followed: work that adds a durable capability -- a CI job, a hook, a script someone will later ask the provenance of -- gets a ticket even after the fact; a chore commit does not.').
|
||||
groom_check(dissolved_premise_test_gap,'a test-gap ticket rots differently from a code ticket: the gap can be closed by someone else work without anyone touching the ticket. Re-run each item against the test names before working it -- 2 of 7yx five items were already covered, by jc8 and by an import fix.').
|
||||
|
||||
% ── Beads tracker maintenance (2026-09-19) ──────────────────────────────────
|
||||
decision('beads-maintenance-2026-09-19',export_drops_memories_by_default,'bd export omits the bd remember memories unless --include-memories (or --all) is passed, and .beads/issues.jsonl carries both. Regenerate it with: bd export --include-memories -o .beads/issues.jsonl. A plain bd export silently deletes all 14 memory records from the tracked file and the diff reads as an ordinary re-export. bd own auto-export refuses for the same reason, with an explicit shrink guard.').
|
||||
decision('beads-maintenance-2026-09-19',export_dependency_order_is_nondeterministic,'bd export emits each issue dependencies array in nondeterministic order, so a re-export of unchanged data still shows a ~23 line diff. Before committing, compare old against new with the dependencies arrays sorted; if nothing differs semantically, revert rather than commit the churn.').
|
||||
decision('beads-maintenance-2026-09-19',flake_beads_pin_verified,'flake.nix pins beads to github:gastownhall/beads/v1.3.0 (flake.lock rev f45b249ce6b40ba62aecc03949e6371e8f7c79d8). nix build of that ref yields beads-1.3.0, matching the local bd 1.3.0 that writes .beads/issues.jsonl, so the pinned tool and the tracked export agree on format.').
|
||||
decision('beads-maintenance-2026-09-19',prolog_save_session_is_destructive,'mcp saveSession to kb.pl rewrites the file from the in-memory clause database: it drops every comment and section header and reorders clauses, turning a 3-line append into a 708-line diff. Do NOT use saveSession on kb.pl -- append facts with a text edit and verify with: nix shell nixpkgs#trealla --command tpl prolog-sessions/kb.pl -g "kb_report, kb_consistent, halt".').
|
||||
|
||||
Reference in New Issue
Block a user