Commit Graph

1091 Commits

Author SHA1 Message Date
Edward Langley 585f25bf78 Invalidate the cursor by observing the grid's frame, not by a per-effect table
improvise-ni6k. Twelve effects called `app.reset_cursor()`; that table is
the shape gotcha 24 names as the disease, and it had a hole the day it
was written. The observation replaces it: `view_frames` fingerprints
every view's drawn frame, `apply_effects` diffs it per model effect, and
the cursor resets when the view it is in reframes. No effect declares
anything, and `adopt_cursor_epoch` reads the same set.

FOUR THINGS THE TICKET DID NOT PREDICT, each one a fixture or a design
question rather than a line:

1. The frame fingerprint and `layout_version` answer DIFFERENT questions,
   so they are two observations and not one. `AddCategory` of an empty
   category moves no row or column but does change the coordinates a
   client's CellKey hangs off — layout_version must bump, the cursor need
   not. `view_shape`/`last_reshaped_views` (config) is untouched and still
   drives `layout_version`; `view_frame`/`last_reframed_views` (drawn) is
   new and drives the cursor. Unifying them broke two tests in both
   directions, which is how the distinction was found.

2. The observation must run INSIDE the effect loop, not after it. At
   end-of-batch it clobbered deliberate cursor placement in the same batch
   — `[AddItem(measure), <place cursor>]` sent the cursor home, failing
   three records-mode tests. In-loop, a later effect wins, which is the
   same ordering property gotcha 45 gives `document_config`.

3. `GridLayout::new` does NOT apply `prune_empty`; `with_frozen_records`
   does. A frame hash built on `new` cannot see a pruned row, which is
   exactly the class improvise-7kvw is in.

4. Two per-effect no-op guards were carrying the whole weight and are now
   orphaned: `effective_items` and `item_is_drawn` are deleted, along with
   six inline `moved`/`drawn`/`real`/`existed` predicates.

COST, measured as the ticket required, `benches/reshape_observation.rs`
(new), one SetCell batch, same fixtures as `grid_projection`:

    typical   400 cells   172µs -> 206µs   (+20%)
    large    9.6k cells  3.70ms -> 4.17ms  (+13%)
    18k cells            7.09ms -> 8.41ms  (+19%)
    pathological 90k    35.2ms -> 39.8ms   (+13%)

The batch is dominated by `rebuild_layout`/`recompute_formulas`, not by
the fingerprint.

WHAT CLOSES AS A CONSEQUENCE: improvise-7kvw, asserted by
`paging_across_a_prune_that_drops_a_row_invalidates_the_cursor`.
AddFormula/RemoveFormula join the shared `reshaping_commands` table (a
formula target is a `_Measure` item, so it is a column — gotcha 7), each
proved reshaping by the table's own `assert_ne!` on the grid shape.

WHAT DOES NOT CLOSE, and this is the honest part: `SortData`. A pure
record reorder changes no label and no count, and records-mode `row_items`
are positional, so a row_items fingerprint cannot see it. I wrote the
mechanism (hashing `layout.records`' coords) and then REMOVED it, because
I could not build a fixture where `SortData` actually reorders anything —
records came back in key order whatever order the cells went in. Shipping
a confident comment over a mechanism I could not demonstrate is this
repo's named defect, so it is reported instead. See the hand-back.

Negative arms unchanged and still passing, including the refused-command
arm and `SetNumberFormat`; disabling the observation fails the whole
reshaping table, which is the proof it is what does the work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:45:34 -07:00
Edward Langley 05a28b0d45 Point the geometry test at the production hash, and test both abort arms
improvise-1xf4: `layout_geometry_effects_actually_change_view_config`
hand-listed 6 of the 13 LayoutGeometry effects and measured them against
`view_config_fingerprint`, a THIRD fingerprint existing only in test code
beside the production `document_config` and `view_shape`. Since PR #70
decides `model_state.dirty` for LayoutGeometry solely from
`document_config`, an effect writing something the test-only hash sees
and `document_config` does not would pass and never mark the document
dirty — i.e. never be saved.

It is now `layout_geometry_effects_actually_change_document_config`,
measured against `document_config` itself and listing all 13, counted off
the `impl Effect` blocks rather than trusted. Two fixture facts the
rewrite exposed and the old six never could: `ShowItem` on a visible item
and `ToggleGroup` on a nonexistent group are no-ops, so `grouped_app`
supplies a hidden item, a real group and a second view.

`view_config_fingerprint` SURVIVES, deliberately, and is not a third
derivation any more: it is used only by the NEGATIVE arm
(`view_only_effects_leave_shared_state_alone`), where a hash stricter
than `document_config` is what that test wants. The doc comment says so.

improvise-2avl: PR #70 moved the `layout_version` bump out of the effect
loop. Gotcha 40 records the in-loop position as load-bearing, and the
argument that end-of-batch is safe for `layout_version` specifically was
written into context/code/command.md and never tested. Both arms now are:
a batch that reshaped before aborting must bump, one that aborted before
reshaping must not. Proved independently — bumping unconditionally fails
only the second, skipping `observe_reshape` on abort fails only the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:38:35 -07:00
Edward Langley bc77e68f40 One classifier for a typed string, one exhaustive walk over Rejection
improvise-j7v6: the rule turning a typed string into a `CellValue` was
written twice — `resolve::typed` (already moved into improvise-protocol
since the ticket was filed) and `commit_regular_cell_value`'s
if/else-if/else on the host's key path. The host now calls
`improvise_protocol::typed`, which is `pub` for that reason.

Per doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished),
`a_typed_string_is_classified_as_the_host_classifies_it` did not survive
as a comparison: type identity replaced its claim. It is re-scoped to the
thing this crate still owes — the FRAME the string composes — and renamed
`a_typed_string_composes_the_frame_that_carries_it`.

The ticket's flagged risk (gotcha 34: a shared classifier that disagrees
with the persistence layer is a third answer) is now asserted rather than
argued: tests/typed_string_agreement.rs round-trips what `typed` calls
text and what it calls a number through format_md/parse_md. Proved
discriminating: dropping `pipe_quote` from the writer fails it.

improvise-8uj8: `every_rejection_message_says_the_edit_did_not_land`
drove a hand-written five of seven `Rejection` variants while claiming
the fact held "over all of them". `Rejection::all` (the same cyclic walk
`AppMode::all` uses) and `Rejection::refused_an_edit` (exhaustive) now
drive it, and its claim is re-scoped to what is true: a rejection that
refused an EDIT says nothing was applied, and the two Save answers —
which refused no edit — say the document was not written. Renamed
`every_rejection_message_says_what_it_refused`.

Proved both ways: wording SaveFailed as "hmm" fails the test, which the
old list could not have seen; adding a Rejection variant fails the build
at basis.rs:101 and :126.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:36:51 -07:00
Edward Langley e89b6467e1 Derive the AppMode lists from AppMode, and close minibuffer()'s wildcard
improvise-e1z5 (half) and improvise-g9jb.

`AppMode::minibuffer()` ended in `_ => None` where its sibling `panel()`
is exhaustive, so a text-entry mode could be added and be invisible to
`EnterMode`'s clear and to the `TextEntry` builder. It is now exhaustive.

`AppMode::all()` and `AppMode::text_entry()` are the one enumeration.
`all()` walks a total, cyclic `after()` rather than listing: with no
`None` arm, the only way to answer for a new variant is to name another,
which is what splices it into the walk. `text_entry()` filters `all()`
by `minibuffer().is_some()`, so the set is derived from the thing that
makes a mode text-entry rather than restated.

Four hand-written lists removed, enumerated by grep before the claim:

  1. protocol/src/help.rs::all_modes()                 17  removed
  2. command/src/ui/app.rs::tests::all_modes()         16  removed — it was
     missing SavePrompt, latently
  3. protocol/src/resolve.rs::tests::text_entry_modes() 8  removed
  5. wasm-client/tests/lockstep.rs::text_entry_modes()  7  removed — also
     missing SavePrompt, latently

One list survives and is not a copy: command/src/command/keymap.rs's
`text_entry_modes_with_exits` carries commit/leave KEYS, which are not
derivable from AppMode. Its mode set now comes from `AppMode::text_entry()`
and only the keys are stated, in a match with no wildcard.

e1z5's second half needs no change: `strands_the_user` was deleted by
improvise-rmvx and replaced by name-driven `declined`. Only a stale doc
reference to it survives, at resolve.rs:1948.

Verified by injecting a throwaway `AppMode` variant: the build fails at
lib.rs:220 (`minibuffer`) and lib.rs:266 (`after`), neither of which
could have failed before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:35:01 -07:00
Edward Langley 922eb82d6b One home for the published basis, one fold for a mirror's active view
improvise-23qd: `ClientSession::basis_update` and the `#[cfg(test)]`
`server_truth` each derived "which records does this client's grid name"
from the same filter, and `server_truth` is the yardstick the fold tests
measure against — so a change to which records get published moved the
thing and its measure together. Both now call `published_basis`, and the
property has a yardstick that is spelled out rather than derived:
`the_published_basis_names_the_records_the_grid_draws_and_no_others`
names North/South/East by hand and asserts an aggregate grid publishes
none. Proved discriminating by injection: `.take(2)` on the one surviving
filter fails that test and nothing else in the crate.

improvise-w3bf: the four writers of `ClientSession::view.active_view` —
`apply_switch`, `adopt_deleted_views`, and `reseed`/`connect` through
`fresh_view` — each reproduced `reduce_view`'s one-line `SwitchView` arm.
All four now run that arm. Proved by injecting a second statement into
the arm and watching a probe read "now Default" from a pre-fix
`apply_switch` and "now Second" after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:32:49 -07:00
Ed Langley 8a559b2686 Merge pull request #75 from fiddlerwoaroof/docs/orchestrator-batch
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
docs: the wave skill, the Copilot gate, and six doctrines a hard reset discarded
2026-09-20 20:01:19 -07:00
Edward Langley 9976486d69 docs: the help entry no longer contradicts itself
Found by review on #75. Renaming the heading to the test PR #72 restored made
check-evidence green, and left the body three paragraphs down still saying
"The test no longer exists" and "nothing replaced the assertion" -- one entry
making mutually exclusive claims about whether the coverage exists.

The checker could not see it, and that is not a gap in the checker: it
validates heading-to-test and says nothing about the prose beneath, which is
what a human reads. A mechanical check licenses exactly the claim it makes and
no neighbouring one.

The paragraph now records the gap as history -- gone for a release, invisible
because the checker then read only ### headings and only Rust names, restored
by #72 with improvise-igxp's stronger assertions -- and points at
help.spec.js's own entry rather than promising or denying coverage itself.

doctrine(a_checker_that_reads_headings_cannot_see_a_contradicting_body):
a rename is a read of the whole entry, not an edit of its first line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 19:55:31 -07:00
Edward Langley d2a54ac320 kb: restore six doctrines a hard reset discarded, and record how
Consolidates four doc branches and restores what resyncing lost.

I wrote doctrines to kb.pl on local main, pushed each as a branch, then ran
`git reset --hard gh/main` twice to resync -- discarding six clauses that
existed only in the unmerged local commits. Nothing failed: kb_consistent
passed on the reduced file, the counts simply read lower, and the loss was
found only by grepping for clause names I happened to remember writing.

That is the same shape as everything else this wave found. A count that looks
plausible is not evidence, and the check that would have caught it -- diff the
clause NAMES, not the total -- is the one this repo already applies to tests
after a merge.

doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch) states
the two rules: write a fact once, on the branch that will carry it to main, not
on a local main that is going to be resynced; and after any reset or force-move
of a branch holding knowledge, re-count and diff clause names.

Restored: every_wave_agent_gets_its_own_worktree,
the_orchestrator_delegates_substantial_work,
a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary,
run_the_gate_ci_runs,
an_observation_correct_about_what_changed_can_be_wrong_about_when_it_is_read,
a_siblings_unmerged_pr_is_invisible_to_bd_search.

Also carries: the wave skill, the Copilot-gate correction to AGENTS.md, the
help evidence rename, and the code-review skill's missing -D warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 19:40:33 -07:00
Edward Langley 3d34b32085 Merge remote-tracking branch 'gh/docs/help-evidence-rename'
# Conflicts:
#	prolog-sessions/kb.pl
2026-09-20 19:40:02 -07:00
Edward Langley 65e8f2fb42 Merge remote-tracking branch 'gh/docs/copilot-gate' 2026-09-20 19:40:02 -07:00
Edward Langley 3c5836c9d9 Merge remote-tracking branch 'gh/docs/wave-skill'
# Conflicts:
#	prolog-sessions/kb.pl
2026-09-20 19:39:51 -07:00
Edward Langley debad21556 docs: run the gate CI runs, and bindings.rs is run
Two one-line corrections the wave's last review surfaced, both about a claim
being stated in more than one place.

.claude/skills/code-review/SKILL.md told every reviewer and every wave agent to
run `cargo clippy --workspace --all-targets` with no `-D warnings`. Clippy exits
0 on warnings, so the committed local gate was green on exactly the input CI is
red on. This is not one person's shell mistake, it is the instruction: a
reviewer following the skill as written would report clean and be wrong, which
happened on PR #71. Also adds --no-fail-fast, since fail-fast hides blast radius.

README.md was a sixth copy of "bindings.rs is never run", which PR #70 corrected
in five places -- its grep missed README, and PR #72 then rewrote the sentence
four lines above and carried the wrong half forward. Neither PR is at fault
individually; it is an interaction between two branches, and it is the exact
failure the corrected fact is a monument to.

Plus doctrine(a_weak_check_and_a_strong_one_agree_until_the_defect): five
instances this wave, one mistake -- a check written against the failure you
imagined rather than the outcome the user sees. The weak and strong checks agree
on every input anyone tried and diverge only on the input that is the defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 17:24:17 -07:00
Ed Langley 279bf3a848 Merge pull request #71 from fiddlerwoaroof/wave/browser-keys
fix(web): an advertised key is never silent, a formula is visible, and +cat commits
2026-09-20 17:23:23 -07:00
Edward Langley df8ee0d135 fix(protocol): Keymap is a test-only import, and my clippy check was not CI's
CI's `cargo clippy --workspace --all-targets -- -D warnings` failed on an
unused `Keymap` import: `bound_commands` takes a `&Keymap` and lives in the
test module, so the non-test lib build has no use for it. Moved into
`mod tests`.

Worth recording WHY I did not catch it locally: I ran clippy without
`-D warnings` and grepped its output for `^(error|warning)\[`, which matches a
lint with a NAME in brackets and misses a bare `warning:` — which is exactly
what `unused_imports` emits. A check whose filter cannot see the failure reads
as a clean run, which is the shape this repo keeps finding. Run the gate CI
runs, not one that looks like it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 17:13:38 -07:00
Edward Langley 5fa554d712 docs: the help evidence heading names a test that exists again
PR #61 deleted the help spec; PR #69 demoted its evidence heading to prose as
Retired; PR #72 restored the coverage under a DIFFERENT name. So main briefly
carried a heading whose claim was false in both directions -- the prose said
retired while the test was back, and restoring the heading verbatim would have
named a test that does not exist.

Renamed to #72's actual test. check-evidence: 535 entries, all resolve; 162
tests cite the file and all have one.

Worth noting the sequencing, because it is the interesting part: #69 is the PR
that TAUGHT the checker to see #### headings and JS test names at all, so it is
simultaneously the thing that would catch this and the thing whose demotion kept
the merged tree green. The demotion was correct when written and stale by the
time it merged.

Plus doctrine(a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary):
asserting WHICH refusal fired is only as strong as that message being unique
across every refusal the code emits, so enumerate the whole vocabulary rather
than the line you are looking at -- and where no message can discriminate,
assert the outcome, and guard the backstop separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 17:11:44 -07:00
Ed Langley fe25952aed Merge pull request #69 from fiddlerwoaroof/wave/tooling-gates
fix(tooling): six checks that did not check (vz54, 5ray, lyyc, x4wv, 2f97, dj1l)
2026-09-20 17:11:04 -07:00
Edward Langley be01339039 Merge gh/main (4e48a6e, PRs #70 and #72) into wave/browser-keys
Two doc conflicts, both pure appends at the same place, both resolved by
keeping BOTH sides:

- `context/code/clients.md`'s boundary table: #72 added the help-overlay row
  where this branch added the key→screen row. Six became seven, not six with
  one of them replaced.
- `context/test-evidence/clients.md`: both branches appended entries.

`README.md`, `harness/build.sh`, `harness/playwright.config.js` and
`.gitignore` are byte-identical to main's — this branch never touched them, so
#72's measured engine-coverage claims are intact rather than restored stale.

The check that matters here is NOT the count. #72 added three spec files while
this branch added one, so a total would have masked a loss. Diffed harness test
NAMES across the merge instead: 16 before, 23 after, **nothing lost**.

1398 + main = 1405 Rust passing; harness 28 passed + 3 skipped; node 26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 17:07:39 -07:00
Edward Langley cd094b6dfb fix(kb): the dj1l cases discriminate, and a replaced rule cannot silently become an add
Five findings from the second review round, and the first is this PR's own
standard applied to its own P1.

**The two fixes improvise-dj1l names had no discriminating test.** Reverting
`has_key` to unification, or dropping `canon_key` from the duplicate gate, left
kb-test at 27 passed. Case 11 was carried by `check_total` refusing with a
DIFFERENT message, and case 10 by the other half of the fix. Both now assert
which refusal fired (`said()`), plus two cases aimed at each half: a rm key with
a variable in a non-final position must be refused as ABSENT rather than
matching everything, and a keyed FACT whose key holds a variable must ADD
without excluding the siblings it would unify with. Each proved by reverting
exactly one fix and watching exactly that case fail.

**Keying a rule on its whole clause made `replace` on an edited rule silently
become `add`** -- both arms live, the stale one still firing, and `check_total`
cannot see it because `NO + NF` is correct arithmetic for an add. Reachable on
the 15 `kb_error/1` arms and the `kb_text/2` rules, so editing the check
improvise-2f97 adds is the first thing to hit it. `check_rule_replace` refuses
and names the route (rm, then add); the negative arm -- replace of a rule
present verbatim -- is tested, or the refusal would just be "replace never
works on rules".

**The `decision(` arm was dead code.** `cited_name` took the first field, and
the repo spells `decision(<ticket>, <name>)`, whose ticket id fails
`name_shaped/1` on the hyphen -- so that marker could never fire for a real
citation. A second `cited_name` clause takes the second field. All three
markers are now exercised.

Minor, from the same round: `_JS_TEST` no longer accepts `test.only`, which
disables every other test in its file, with a case asserting a heading cannot
be satisfied by one; and `node-test-test.sh`'s last case no longer duplicates
an earlier one.

Filed rather than fixed: improvise-w8ih, extracting `web/assemble.sh`'s gate so
its own absence is visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 17:01:33 -07:00
Ed Langley 4e48a6ecb4 Merge pull request #70 from fiddlerwoaroof/wave/effect-classification
fix(command): a counter is bumped on an observed change, not a declared intent
2026-09-20 16:59:18 -07:00
Edward Langley 4aa4e88ce4 fix(protocol): Ignored is a black hole too, and a decline cannot shadow working code
PR #71 review, blocking finding — and it was demonstrated rather than argued.

`KeyOutcome` has TWO silent variants and the new guard watched one. It asserted
`resolve(..) == Upstream(event)`, but `KeyOutcome::of(vec![])` returns
`Ignored`, which puts nothing on the screen either. Degrading only the three
declines no browser spec presses — add-record-row, delete-formula-at-cursor,
view-back — to `Step::Declined(Vec::new())` left 1397 Rust, 19 harness and 26
node tests passing. Three of the six commands this change exists to make
audible could go completely silent with the whole evidence base green.

The generalisation is the part to keep. `KeyOutcome::of` turning an empty
`Local` into an honest `Ignored` is good design and also a LAUNDERING step:
every wrong answer that composes nothing arrives wearing the variant that is
usually innocent. A guard's discriminator has to be STATE — did anything the
user can see change — never the shape of the return value. That is why the
`+cat` injection failed in `both_ends`, which compares documents and cursors,
and the decline injection passed everywhere, where nothing compared a screen.

Widening it costs ten entries, all one boundary case: `move-panel-cursor`
mirrors a `Cmd` that returns nothing when the cursor does not move. `furnished`
now carries three of everything with all three panel cursors seated at index 1,
so the residue is zero rather than parked in the named list. Seating them at 0
and calling the `Up` results expected would have hidden the real defect in the
same list as the boundary case.

`nothing_declined_is_also_composed` closes the other direction, which was a doc
comment. `step` reaches `declined` first, so an arm in `composed` for a declined
name is unreachable — a future ticket implementing `view-back` would leave the
browser saying "not available yet" over working code with nothing noticing. It
is derived from `declined` itself over every `(name, args)` bound in every mode,
parents and prefixes and sequence steps included, so a seventh decline needs no
edit here. It found a real overlap on its first run: `enter-mode` is answered by
both. Rather than move the check, both halves now hold the rule — `composed`
returns `None` for a stranded mode — so removing one fails towards `Upstream`
rather than towards a dead room.

`Keymap::parent()` added so a test can walk the fallback chain.

1397 → 1398 Rust; harness 19 + 3 skipped; node 26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:55:04 -07:00
Edward Langley 14757a753f fix(command): the observation goes in the loop, or Save cannot clear it
Review of PR #70 found a regression the 1400-test suite did not name.

`observe_reshape` ran AFTER the effect loop and re-raised model_state.dirty
from the document_config comparison, while `Save` clears it INSIDE the loop.
So gotcha 45's ordering property -- "Save clears it in loop order, so a batch
that edits and then saves ends clean" -- survived for CellData/WholeWorkbook
and was broken for LayoutGeometry.

    [SetNumberFormat, Save]  -> reported a successful save, document dirty
    [SetCell, Save]          -> fine on both

Reachable from `:format ,.2f . w` and from any Binding::Sequence pairing a
geometry effect with a save. The TUI draws "Saved to ..." while :q refuses;
the worker, whose persist trigger IS the flag, pays a whole-document OPFS
rewrite. The exact failure class this PR set out to close, one level up.

The fix is position, not logic: the comparison is taken after each
LayoutGeometry effect against the value held after the previous one.
`layout_version` stays after the loop, which is not the same hazard -- nothing
clears it, and an aborted batch's effects never ran, so an end comparison sees
only what did. Neither arm of that is tested; filed.

Two more review findings:

a_panel_command_writes_that_panel_and_no_other asserted THROUGH the mapping it
tests -- zeroing via panel_open_mut and reading via panel_open, the same four
accessors. A consistent permutation of all four is a self-consistent bijection
and passed. It now reads the three field pairs by name once per panel, and the
consistent permutation fails it. It had been catching only a non-injective
mapping, which is precisely the injection the evidence recorded.

And improvise-56ek's claim of "no second copy left to agree with" was false
when written: resolve.rs carried a fourth copy of the same Panel -> ViewState
match, in the same crate on the same type. Deleted; its three call sites go
through ViewState::panel_open. The claim is corrected rather than overstated --
registry.rs maps Panel to CmdContext fields, a different type, and the
Panel -> (cursor, length) pairs are a different derivation and lockstep's
subject.

1401 tests pass. Both new guards proved by injection: reinstating the
after-loop comparison fails the ordering test by name with 454 others passing;
a consistent Formula<->Category permutation across all four accessors fails the
panel test, where before it did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:54:57 -07:00
Ed Langley 40fc0c7dc7 Merge pull request #72 from fiddlerwoaroof/wave/harness-coverage
test(harness): the help overlay, a grouped fixture, the landing page, and Firefox
2026-09-20 16:51:22 -07:00
Edward Langley 67ce2df421 docs: the Copilot gate is gone, and AGENTS.md told agents to wait for it
Measured: PRs #12, #21 and #27 carry 2, 8 and 4 Copilot reviews; #61, #66, #68
and #70 carry none. Both request paths report success and register nothing --
the GraphQL mutation returns clientMutationId null, the REST call returns 200
with requested_reviewers empty, and no review_requested event appears.

So "merge only once Copilot passes" was an instruction to wait forever on a
review that was never requested. An absent review and an unrequested one look
identical from outside, which is review_is_requested_not_inherited seen from the
requester's side. Review is now an adversarial agent the orchestrator dispatches,
naming the head SHA it reviewed; the Copilot mechanics are kept for the day it
returns.

Found by the PR #70 agent, which noticed its own review request had produced
nothing rather than assuming the review was queued.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:43:35 -07:00
Edward Langley 0b7a2351b5 fix(tooling): a barren file is refused by node's own report, not by grepping for test(
Two findings from the PR #69 review, one class: a lexical `test(` scan is
satisfied by things that execute no assertion -- a commented-out call, the
words in a string, `test.skip`/`test.todo`, and an empty `test.describe`.

scripts/node-test.sh drops the scan and reads node's report instead. `# pass`
is 0 for a skip-only file, a todo-only file and an empty suite; and a file that
registered no test is reported as one passing test WHOSE NAME IS THE FILE PATH,
which is both how a barren file is detected and why `# tests` alone reads as
the file count. That naming convention is an assumption about node, so the
self-test pins it: a node that stops doing it fails the case rather than
letting the guard go quiet. Six cases added, including a barren file hiding
among passing ones.

--check-evidence had the parallel hole: _JS_TEST accepted `.skip`, `.todo` and
`.describe`, so a quoted heading could be satisfied by disabled coverage or by
an empty suite. Now runnable leaves only (`.only` included), with three cases
asserting that a skipped, todo or suite-only name leaves the heading orphaned
while a test nested in a suite still satisfies one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:41:09 -07:00
Edward Langley 5c3c796308 docs: a wave skill, so the brief stops being retyped
Four agents were dispatched without worktree isolation and all edited the same
working tree: one git status held one agent's effect.rs, another's
harness/build.sh and a third's scripts/node-test.sh. Any commit would have swept
up three units' half-finished work.

The brief had carried that rule before and lost it, which is what a retyped brief
does. It is now a skill: worktree isolation first, partition by crate rather than
ticket count, the base commit by name, the baseline, the never-write list, the
injection discipline, pr-watch --expect, and attribution.

Two doctrines: every_wave_agent_gets_its_own_worktree records the tell and the
per-agent recovery (copy only what an agent can PROVE it touched -- two units can
legitimately both be in improvise-protocol); the_orchestrator_delegates_substantial_work
records the user's rule and the non-obvious half of it, that orchestrator work
lands in the shared primary tree and collides with agents who own that ground.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:40:32 -07:00
Edward Langley d1ce2ecbc3 fix(web): an advertised key is never silent, a formula is visible, and +cat commits
improvise-rmvx, improvise-fdt6, improvise-gcvb — three faces of the same
thing: a browser offering something and then doing nothing.

rmvx. `the_command_names_that_still_need_the_host_are_these` was built with
`in_mode(AppMode::Normal)`, so it only ever asked about Normal-mode bindings:
`add-record-row` (RecordsNormal) and `delete-formula-at-cursor` (FormulaPanel)
were outside its reach entirely and could never have failed it. A named list
that silently scopes itself to one mode is a guard that looks broader than it
is. `every_advertised_command_answers_in_its_own_mode` crosses
`help::advertised` — command NAMES, so no hint string is tokenised — against
`resolve` in every mode, over a fixture furnished enough that an `Upstream` can
only mean an unhandled name. It found a seventh instance the ticket had not
predicted: `view-back` is advertised in RecordsNormal.

Six names now decline out loud, each with its own true subject, out of one
`declined` table; `stranding` returns the subject rather than a bool, so `:`
says the command line is unavailable instead of doing nothing. A declining step
takes its whole `Binding::Sequence` with it, which is what keeps `o` ("add a
record row, then edit it") from opening an editor on a row nobody created.

fdt6. `commit-buffer formula` already resolved; what did not exist was any way
to SEE the typing. `RenderModel::editing` is scoped to the two cell-editing
modes and reads the "edit" buffer by name, so FormulaEdit, CategoryAdd, ItemAdd
and the three prompts opened and swallowed every character. `RenderModel::
minibuffer` is `AppMode::minibuffer()` plus the buffer that config names, read
through `improvise_protocol::read_buffer` so gotcha 11 keeps its one home, and
`web/app.js` paints it.

gcvb. The records-mode early return was broader than its reason. A
`CellId::Create` commits the typed NAME as one existing `ModelCommand`, reads
no `record_col` and writes no cell, so the return narrows rather than
disappears. `+cat` must NOT advance: an inserted column renumbers every index
to its right and the advance was computed from the pre-commit layout, so a
client that advanced anyway would land one column off, silently. Both halves
proved by injection against `CommitAndAdvance` in `tests/lockstep.rs`.

Every new guard was proved by injecting the fault it names. The one worth
repeating: `web/tests/render_surface.test.js` went green over a page that
painted nothing, because the string `model.minibuffer` was inside a function
nothing called. The static check is a completeness check, never an execution
check — `harness/tests/keys.spec.js` caught it in Chromium on the first run.

1394 → 1397 Rust tests; harness 17 → 19 passed, 3 skipped; node 26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:36:03 -07:00
Edward Langley f5198c9142 test(harness): gate the grouped reads on a painted head
Self-review under the code-review skill: the three grouped tests read the head
and the row labels in one shot, which cannot wait, while the grid is painted
from the first projection and reframed by the viewport the page then announces.
openGrouped now gates on the head's shape, its first group and the first row's
innermost label with retrying assertions. 15/15 under --repeat-each=3, and all
five injections re-run afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:35:47 -07:00
Edward Langley f9580a33af docs: what the harness now reaches, and the evidence for each assertion
The engine-coverage and measurement passages in context/code/clients.md, the
boundary table (four boundaries become six), and README's caveat -- which
named the grouped and multi-level DOM as never executed, and it now is. A fact
stated in more than one place is a grep, not an edit (gotcha 48).

context/test-evidence/clients.md records, per test, what fault was injected to
prove it can fail: the blanked help keys, the five grid-frame injections, the
404'd casts and the dead front-door link, and the mis-wired item-row click --
plus the two-command test-name diff that found that last one.

Playwright: 26 passed + 3 skipped, 34.4s / 33.7s (was 17 + 3, 15.1s); the
landing page's cast playback is most of the difference. node --test web/tests
26, cargo test --workspace 1394, both unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:34:31 -07:00
Edward Langley ab96f3651f test(harness): Firefox runs the standalone deployment, and only that
improvise-bck's criterion (1) reads "Chrome and Firefox" and the standalone
deployment was the evidence offered for it -- but shell.spec.js ran on
Chromium alone (improvise-zkkw).

The recorded reason is that routeWebSocket is slower and flakier across three
engines. That argument is about the four mocked-peer tests and was never made
about the two beside them: the standalone block mocks nothing, and the
engine's OPFS is already exercised by opfs.spec.js.

So the mocked-peer describe is tagged and the firefox project widens its
testMatch with grepInvert: /@mocked-peer/ -- a tag, not a third copy of the
file. Measured: 2.0s for the pair, 10/10 green under --repeat-each=5. Nothing
was quieted; had it flaked, the answer would have been to leave it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:34:31 -07:00
Edward Langley b748bb680c test(harness): the landing page is fetched, played and followed
docs/index.html is copied to the site root by web/assemble.sh, so the harness
has served it all along with nothing asserted about it (improvise-bgtf).

Counting the four .ap-player elements proves nothing: autoPlay is false, so no
cast is fetched until play is pressed, and a page whose every cast path 404s
constructs four players and throws nothing. The spec asserts that no .cast has
been requested BEFORE pressing anything, then presses play on each of the four
and waits for rendered lines and the recorded session's own banner -- and
separately that four DISTINCT casts answered 200, which the rendering cannot
say. Re-injecting ./casts/ -> ./cats/ fails it at the first player.

The second test follows the footer link rather than asserting its text:
whether ./app/ resolves is a fact about the assembled tree. Injecting a typo
into the href fails it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:34:25 -07:00
Edward Langley 09ada9c1b5 test(harness): a grouped, multi-level fixture, and the first browser run of anr9
web/demo.improv has no groups and one category per visible axis, deliberately
(gotcha 46), so the multi-level header, the group span, the collapse indicator
and the inline group row -- exactly the code improvise-anr9 added -- had never
been rendered anywhere a test could see (improvise-plcx).

harness/fixtures/grouped.improv is served beside the shipped demo by
harness/build.sh; web/demo.improv is untouched. Two categories on each visible
axis, three row groups with one collapsed, two column groups.

A missing seed is NOT an error -- web/worker.js answers a 404 with an empty
model -- so every assertion is about labels the fixture really carries, never
that a grid exists.

Five injections, five distinct failures, patched in harness/serve so no
rebuild stands between the fault and the run: slash-joining the level tuples;
colSpan = 1 on the group headers; a stray cell on the inline group row;
indicator(false) hard-coded; and the collapse removed from the document.

One thing the ticket did not predict: the row frame's FIRST group header is
never drawn, so the spec asserts two group rows where the document has three,
with improvise-0o6x named on the line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:34:24 -07:00
Edward Langley ba5c0ee059 test(harness): the help overlay is asserted as content, and a lost click guard returns
PR #61 deleted "? opens help, and its rows are the shared ones" and nothing
replaced it (improvise-78v6): paintHelp, help, help_page and help_titles
reached no assertion at all. What contract.spec.js carried instead said only
that a dt was VISIBLE, and an empty dt is a visible dt (improvise-igxp).

help.spec.js reads the <dl> as the pairs it is and asserts text: the five
titles, Welcome's prose rows (which carry no dd at all, by design), and three
key/description pairings off the keymap. The second test asks whether the
string is TRUE rather than present -- it reads the key for "Jump to last row"
out of the DOM, closes the overlay, presses it and asserts the selection, so
the spec holds no second copy of the keymap.

contract.spec.js's two help lines are strengthened from toBeVisible to text.
Injecting the fault igxp names -- blanking keys.textContent in paintHelp --
left all three contract tests passing before; it now fails that file and both
tests here.

The test-name diff 78v6 asks for finds a fourth loss: "a click on an item row
filters, as Enter does". Only #status distinguishes the item arm of
click_category_row from the category arm, so a click wired to the wrong one
passed the whole file. Restored, and proved by injecting the mis-wiring.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:34:18 -07:00
Edward Langley beef2587ad fix(command): a counter is bumped on an observed change, not a declared intent
`EffectKind` says what an effect *means* to do. `layout_version` and
`model_state.dirty` were believing it, and failed in both directions.

improvise-pdvm: AddItem/AddItemInGroup/RemoveItem declare CellData and
reshape the grid. Since improvise-9mo9 they call reset_cursor, so
cursor_epoch moved while layout_version did not -- one event moving one of
two counters that answer the same question, and a client told its cursor is
invalid while its layout basis tier still reads current.

improvise-75ld: a refused AddCategory (MAX_CATEGORIES) and a *duplicate* one
both bumped layout_version and raised dirty. The duplicate is the reachable
silent case -- Model::add_category returns Ok for a name it already has, so
it does not even abort -- and it refused every other client's next batch with
Rejection::LayoutMoved and made the worker rewrite the whole OPFS document.

Both are answered the way record_versions and cursor_epoch already are:
observe. Two fingerprints are taken around a batch that contains any
EffectKind::Model(_).

  view_shape   one view's DRAWN shape (axes, effective items minus this
               view's hidden ones, collapsed groups with something drawn
               under them, page selection, prune_empty). Per view name, so
               it answers both "did anything move" and "which views moved".
  document_config  everything a LayoutGeometry effect can write that the
               file would carry. Raises dirty. A different hash on purpose:
               a number format and a hidden ghost are bytes on disk and not
               shape.

No `Effect::kind()` declaration changed, so Dirty::of (gotcha 35) and
record_versions (gotcha 40) are untouched. What moved is which reader
believes the declaration: CellData and WholeWorkbook still raise dirty off
it, in an exhaustive match so a fourth ModelChange must answer for itself.

improvise-nynk: Sessions::adopt_cursor_epoch reset every mirror and returned
one Broadcast::ToAll, which improvise-jytm made wrong -- a reshape of view A
threw away the cursor of a client on view B. The trigger stays the epoch
moving (gotcha 24); only the audience narrows, to the observed reshaped-view
set, as one Broadcast::To per affected client. An empty set means "not
known", never "nobody", and cursor_invalidated is a named function with both
arms tested so that fail-safe cannot be tidied away.

improvise-56ek: SetPanelOpen and SetPanelCursor matched a Panel to a field
twice, in two crates. One home now -- ViewState::panel_open_mut /
panel_cursor_mut and their read halves -- and no compatibility test between
them, because there is no second copy to agree with.

1400 tests pass (baseline 1394: +7 guards, -1 replaced). Every guard proved
by injecting the fault it names; the injections and what each one broke are
in context/test-evidence/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:33:56 -07:00
Edward Langley 50de0a8fd2 fix(kb): keys are compared as variants, and a cited clause cannot be silently retired (improvise-dj1l, improvise-2f97)
dj1l (P1, found by review of PR #68 after it merged). kb-merge.pl matched
clause keys by UNIFICATION, which both lost and corrupted clauses.
check_self_consistent compared keys with ==, but findall hands back fresh
variables, so two clauses with variant heads were never == and passed the
duplicate gate; foldl_merge then matched by unification and the second removed
the first. Reproduced: 536 before, 2 in delta, 537 after. Worse, adding
`probe_fact(X) :- doctrine(X,_).` over a kb holding probe_fact(a) and
probe_fact(b) deleted probe_fact(a) AND wrote `probe_fact(a):-doctrine(a,A)` --
the match bound X, so the clause written was not the clause supplied. The
round-trip verify cannot see that: it compares the file against an intended
list already corrupted by the same unification.

same_key/2 (copy_term + numbervars) in has_key, conflicts and absent;
canonicalize before duplicate detection; a RULE keys on the whole clause, since
one head shape with different bodies is ordinary Prolog; and check_total/2
asserts the arithmetic the tool already PRINTED beside the delta size -- "2 in
delta, 537 after" was visible in its own output twice and nothing compared them.

2f97. kb_error(dangling_citation/3): a name cited inside another clause's text
must resolve as a doctrine/2, invariant/2 or decision/3, so retiring a clause
can no longer dangle every clause naming it. The token rule is narrow on
purpose -- a name-shaped token directly inside doctrine(, invariant( or
decision( -- so prose cannot false-positive. kb_error/1 is a gate predicate,
so this was written with KB_ALLOW_GATE=1 deliberately: the refusal exists so a
write cannot disarm its own gate, and an intentional change to the gate is
supposed to be a greppable act rather than an impossible one.

Seven cases added to kb-test.sh, each proved by injection: two variant-headed
rules both land; a non-ground rule deletes nothing and is written as supplied;
a duplicate key is still refused; an injected dropping fold is caught by the
total; a cited clause cannot be retired; and the citation arm fires on a name
that resolves to nothing without firing on one that resolves. scripts/kb gains
a KB_MERGE seam so a broken merge can be injected. One pre-existing case moved
off doctrine(bug_first), which the new arm correctly refuses to retire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:31:02 -07:00
Edward Langley 3bb484b1f3 fix(web): the stamp guard names every missing stamp, not the first (improvise-x4wv)
`stamped` exited on the first failure, so breaking two substitutions named
app.js and never reached worker.js -- and the comment ("names itself when it is
the one missing") read as though it did not. It fails safe, which is why it was
filed rather than fixed in PR #47; the code is fixed rather than the comment
because the diagnostic is paid for in wasm-pack rebuilds, the slowest loop in
this repo.

Each stamp is still verified on its own -- the property PR #24's finding bought
-- but `stamped` records and a gate after the four calls fails the build.
Proved by injection both ways: two broken sedi patterns now name both files and
exit 1, and deleting the gate prints both failures while exiting 0, which is
the check-that-does-not-check the comment now warns about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:30:55 -07:00
Edward Langley 2eb46122d3 fix(tooling): --check-evidence sees #### headings, JS tests, and the reverse direction (improvise-5ray, improvise-lyyc)
Two blind spots with one consequence: an evidence file could promise a guard
that was not there, and a test could point at prose that did not exist.

5ray: headings were collected with `startswith("### ")` and resolved against
Rust fn names only. context/test-evidence/clients.md uses `#### "name"` for
every browser test precisely BECAUSE a `###` naming a Playwright test would
have been reported as an orphan -- so the convention that kept the checker
quiet was the same one that made it blind. Now `###` and `####` alike, decided
by SHAPE: a bare identifier is a Rust test, a quoted string is a JS test
(harness/tests and web/tests), anything else is prose naming nothing.

lyyc: the check was one-directional. A test whose doc comment names
context/test-evidence must now appear there. Deliberately only that narrower
obligation -- most tests have no evidence and should not be forced to. Neither
direction can see a STALE entry; that stays discipline.

Each half found a real instance on its first run. A `#### "? opens help..."`
heading outlived the test 36ade35 deleted, and is demoted to prose with the
coverage filed as improvise-v5pc. `an_entry_that_holds_no_document_is_seeded_over`
cited the file and had no entry; one is written from the test.

The checker's --self-test covered the comment scanner and nothing else, which
is how both survived. It grows ten evidence cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:30:54 -07:00
Edward Langley ea347ccab4 fix(ci): a node test step with an unmatched glob is a gate that gates nothing (improvise-vz54)
Under bash an unmatched glob survives as a literal; node 22+ globs the literal
itself, matches nothing, reports 0 tests and exits 0. Both CI node steps -- the
`js` job and the browser job's bundle suite -- were exposed: rename or move
either directory and the step goes green having asserted nothing. Reproduced
under `bash -e`, which is the shell CI names; zsh ABORTS on an unmatched glob,
so the failure is invisible from a developer's shell.

The glob becomes the script's rather than the shell's. scripts/node-test.sh
expands it, refuses an empty match, and also refuses a matched file that
declares no test -- checked LEXICALLY, because node reports such a file as one
passing test named after the file, so the `# tests N` a minimum-count assertion
would read is the FILE count and is satisfied by the offender. Measured on node
24.20.

scripts/node-test-test.sh proves each refusal by injecting its fault, including
that the wrapper does not buy its refusals by swallowing a real failure, and
runs in the `js` job: a check wired into nothing is a convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:30:47 -07:00
Ed Langley 00f7678822 Merge pull request #68 from fiddlerwoaroof/kb/tool
kb: a knowledge base you can write to cheaply, and design against
2026-09-20 16:21:20 -07:00
Edward Langley fc6d229dda kb: design against the model before implementing, and fix the key rule it exposed
AGENTS.md has always said Prolog is a design tool for reasoning about tickets;
it was the least-used line in the file, because kb.pl was only ever written
retrospectively. `kb check --with <design.pl>` merges proposed facts into a
throwaway candidate and runs kb_consistent over the union, so a design can be
refused before any code exists. Nothing is written either way.

The model already derives rather than records -- illegal_dependency, may_use,
depends_transitively, portable, snapshot_safe, view_state_admits,
record_obligation, and eleven kb_error classes. Three proposals exercised it in
one sitting: one added dependency edge produced SIX layering violations through
the transitive closure; a proposal to declare decision/3 functional was refused,
correctly, because improvise-907 carries two decisions; and a mis-ordered
layer/2 argument was named rather than accepted.

Trying to model a design found a real defect in the tool. Keys were the functor
plus all arguments but the last, which is right for doctrine(Name,Text) and
wrong for any RELATION: crate_depends_on(A,B) keyed on A, so a crate depending
on two crates read as one key twice and was unaddable. It cannot be read off the
KB's functional/1 -- that keys an arity-3 predicate on its first argument, right
for crate/3 and wrong for decision/3 -- so key_arity/2 is a table, and anything
unlisted keys on the whole head, which is the safe direction.

kb-test.sh is 20 cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:16:31 -07:00
Edward Langley eaaf9d935a kb: a write cannot disarm its own gate, and cannot silently lose another's
Second review pass on PR #68. All three findings real; the first two reproduced
by injection before fixing.

The candidate is validated by CONSULTING it, so the thing being checked got to
influence the check. Three ways through, all demonstrated:

- `:- halt(0).` as a delta -- fixed last round by refusing directives
- REMOVING kb_consistent: it then throws existence_error, and tpl exits 0 when a
  goal throws before reaching halt, so the gate passed by not existing. The
  error was printed and the tool still reported success.
- REPLACING it with the fact `kb_consistent.`, which passes forever after.

CHECK_GOAL now catches, so a throw exits non-zero, and writes touching the gate
predicates are refused unless KB_ALLOW_GATE makes it deliberate. That list is
hand-maintained and a new gate predicate is unprotected until named -- stated
rather than hidden.

Writes were read/validate/mv with nothing in between, so two agents -- ordinary
here, waves run in parallel -- would both read the same file, both validate, and
the later mv would discard the earlier addition. The lost-update class this tool
exists to remove, reintroduced in the tool. Closed by stamping content at read
time and refusing to commit if it moved, plus an mkdir lock. The stamp is the
part that matters; the lock only makes the collision rare.

AGENTS.md's top-level instruction still said to save facts through the MCP. The
wave section had been fixed; the authoritative line had not.

kb-test.sh is 17 cases. The race is deterministic via KB_TEST_RACE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:11:24 -07:00
Edward Langley 7fd57c06b3 docs: say what to do; attribute what agents post
Two user rulings.

Documentation should not spend itself on history -- that is bloat. Prose docs
now lead with the rule and keep only the line that stops it looking arbitrary;
the worked examples and the how-we-found-it move into doctrine/2 text, which is
what that predicate is for and where a reader goes deliberately rather than
while looking for a command. Trimmed: both skills, context/code/kb-tool.md, and
the kb paragraph in AGENTS.md.

Anything an agent posts to GitHub goes out under the owner's account, so every
comment, review and issue body now opens with a line naming the model and that
an agent wrote it. A PR body's generated-with footer already did this; comments
carried nothing, and an unattributed one reads as his own words to every reader
-- including a reviewer deciding how much weight to give it. The reply on PR #68
has been amended in place.

improvise-xfsb files the structural fix: a GitHub App or machine user so agents
post under their own identity. An attribution line is a convention an agent can
forget; a separate identity cannot be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:04:33 -07:00
Edward Langley 661ca76e6d kb: close the directive bypass, and package both tools as skills
Review on PR #68 found two real holes, both reproduced by injection before
fixing and both now regression-tested:

- A DIRECTIVE is callable, so the callable check let one through -- and the
  candidate is validated by CONSULTING it, so a delta of `:- halt(0).` exited
  the check with success before kb_consistent ran. The gate passed by never
  running, and the directive landed in kb.pl. Directives and queries are now
  refused by shape.
- Keys were checked only against the EXISTING file, so two clauses sharing a
  previously-unused key clashed with nothing and the merge dropped the first --
  add discarding part of its own payload. Visible in the tool's own output ("2
  in delta" against one clause added) and nothing compared those numbers.

Also from the review: nothing ran kb-test.sh. A suite that gates the only writer
of kb.pl but is invoked by no workflow is doctrine(an_advisory_gate_is_not_a_gate)
in the PR that adds it. Both suites now run in the kb job.

AGENTS.md now leads with the rule instead of describing the retired procedure
first -- the review read against the earlier head, but it was half right at the
later one too: a skimming reader followed the batching paragraph.

Both tools are packaged as skills, because the failure is not that the scripts
are missing, it is that agents do not know they exist and write their own. Three
hand-rolled PR watchers in one session all reported green while wrong, which is
what scripts/pr-watch and its eight fixture cases exist to stop. One of those
cases -- a head that moves mid-wait -- was untestable until a subshell bug in the
fixture seam was fixed: fetch ran inside "$(...)", so its counter never advanced
and every poll re-read the first fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:02:57 -07:00
Edward Langley 805bae7a15 kb: a clause can be retired, and the docs stop naming the MCP
Three gaps in the tool as first written, each found by asking what it could not
do rather than by running it:

- There was no way to REMOVE a clause. Every correction could add or overwrite,
  so a fact that went false could only be rewritten, never dropped. `kb rm`
  takes keys and refuses when any is absent -- a typo'd key would otherwise
  report success and leave the clause in place.
- `doctrine(kb_is_maintained_through_the_mcp)` is retired for
  kb_is_maintained_by_scripts_kb. The NAME was the claim that went false, which
  is the repair opfs_is_run_but_bindings_is_not needed for the same reason. The
  replacement records why nobody should go back, and that READING through the
  MCP is unaffected.
- AGENTS.md said kb.pl was "MCP-maintained, whole-predicate replacement" and
  told agents to strip host_rpc shims after each save. Both described the
  retired mechanism. This is a_fact_stated_twice_is_changed_by_grep landing
  inside the PR that depends on that doctrine being true.

Also fixes a printed value that was an unevaluated expression: `rm` reported
"530-529 removed" rather than 1.

kb-test.sh is 11 cases now, both new ones proved by injection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 15:58:34 -07:00
Edward Langley 3405fc4def kb: retire the Prolog MCP for a tool that costs the clause, not the predicate
loadProgram REPLACES a whole predicate, so adding one clause meant re-sending
every existing clause of it through an agent's context window. Measured on the
wave-3 batch: ~113KB read and ~113KB written to deliver ~25KB of new text. That
sets the behaviour -- writing becomes a thing you batch and do rarely -- and the
batch is where a short payload silently drops the rest of a predicate, which has
cost this repo 22 decisions and 19 doctrines once already.

scripts/kb reads clauses as DATA and never consults them, so the cost of adding
a clause is the clause. Canonical form is read_term in, portray_clause out --
measured, not designed: all 495 existing facts round-trip byte-identically and
the form is a fixed point in one pass. Rules move to one line, which is why the
reformat lands here rather than earlier: the MCP would re-expand them on its
next save and the two would thrash.

trealla moves into the devShell. Reaching it through `nix shell` per invocation
took the tool's first real use past a two-minute timeout for a write that takes
0.42s with tpl on PATH.

Four guards, each added because injecting its fault showed the tool accepting
what it should refuse. None was found by reading:

- an unparseable delta dropped trealla into its REPL, which HANGS with a
  terminal and EXITS 0 without one -- a refusal that reads as acceptance
- `-g main,halt` exits 0 on a throw AND on a failure, because a goal that fails
  never reaches the halt beside it; halt(2) alone exits 2, so the default is the
  hazard. The goal now guarantees a halt on every path (0/2/1)
- `42.` parses, is not callable, and landed as a fact whose head is a number;
  kb_consistent has no opinion about stray terms
- fmt discarded the merge's exit code and would have committed a failed
  canonicalization

Plus the two that were designed in: the result is read back and required to be
the clauses intended (variant equality -- == compares variable identity and
reports every rule as different), and kb_consistent runs on the CANDIDATE, so a
bad delta leaves the knowledge base untouched rather than needing a revert.

scripts/kb-test.sh proves all nine by injection. context/code/kb-tool.md carries
the argument, including what it does not reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 15:54:39 -07:00
Edward Langley 2d22b65d1f kb: a Record is the settable arm, not the unsettable one
invariant(shipped_demo_cells_are_settable_or_derived) described the
CellId::Record arm as "editable false". The boolean in the cited test is
expected_derived, not editability, and the assertion message beside it says
the two raw columns "must be Records the page offers" -- so a Record is
precisely the SETTABLE arm and the clause stated the inverse permission.

That is the failure doctrine(a_stale_gotcha_asserts_the_negation) names: a
wrong doc does not merely omit, it tells the reader not to assume the thing
that is true. Costly here because ADR 002 makes permission a fact about the
CellId variant, so a reader trusting this clause had both permissions backwards.

The replacement names the error rather than only avoiding it, so it cannot
come back quietly. Verified against tests/web_demo_model.rs, not from memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 15:54:00 -07:00
Ed Langley 555b3e8944 Merge pull request #66 from fiddlerwoaroof/chore/refresh-beads-export-wave3
chore(bd): refresh the issues export after wave 3
2026-09-20 15:43:53 -07:00
Edward Langley feb83a78e2 Merge branch 'main' into chore/refresh-beads-export-wave3 2026-09-20 15:38:15 -07:00
Edward Langley 206c12b98f chore: update issues.jsonl 2026-09-20 15:33:18 -07:00
Edward Langley 9fdf39dda2 kb: the wave-3 batch, and three corrections to clauses that had gone false
Applied through the MCP cycle (loadSession, four loadProgram calls, saveSession),
not by editing the file. Counts verified against the live file immediately before
writing, because the first read of them came from a working tree six merges behind
and would have deleted three clauses while reporting a larger total.

  doctrine   56 -> 95   (+39)
  invariant  51 -> 64   (+13, one of them a rename)
  decision   48 -> 64   (+16)
  work_done  20 -> 23   (+3)

Verified: kb_consistent passes (exit 0, the exact CI invocation); unique names
equal totals for all three keyed predicates; a name-by-name diff against
01ad91a reports exactly one pre-existing name absent -- the deliberate rename --
and no decision lost.

Three clauses were corrected rather than reproduced verbatim, each verified
against the tree first:

- invariant(shipped_demo_cells_are_records) was FALSE and is renamed
  shipped_demo_cells_are_settable_or_derived. Both its guards said "every cell
  is a Record" and passed while that was false for a third of the landing-page
  grid (improvise-ir7l).
- decision(improvise-bck, demo_built_against_an_assertion) carried the same
  stale wording.
- doctrine(a_fact_stated_twice_is_changed_by_grep) gained the observation that
  the commit correcting one copy is the one most likely to miss the others.

One further amendment, disclosed rather than silent: decision(improvise-9x6,
worker_persists_on_its_own_behalf) asserted MarkDirty still exists and that the
worker triggers on the command variant. improvise-gv9r removed MarkDirty and
made the trigger the flag, so the clause contradicted
invariant(the_persist_trigger_is_an_observation) in the same file. It now says
so and points at the invariant.

The MCP re-added its eight host_rpc shims on save; stripped. Note AGENTS.md
enumerates seven -- js_eval_json is missing from that list, so a grep built from
it is one case short.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 15:15:47 -07:00
Edward Langley 8b8df8c947 chore(bd): refresh the issues export after wave 3
Regenerated with `bd export -o .beads/issues.jsonl` (351 issues). Dolt is the
source of truth and has been pushed; this file is the passive export and is
carried on its own branch rather than on any feature branch.

New since the last refresh, all filed during wave 3's reviews:

- improvise-vz54 (P1) `node --test` with a glob that matches nothing exits 0
  having run zero tests, under the bash CI uses. Both the `js` and `browser`
  steps are exposed.
- improvise-x7cq  the `kb` job is absent from `pages-deploy`'s needs, so a
  knowledge-base inconsistency can still reach the published site.
- improvise-669b  the harness spec and the page can still disagree about
  HANDSHAKE_TIMEOUT_MS through a stale assemble.

plus the follow-ups agents filed against PRs #60, #62 and #65.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 15:05:07 -07:00