Commit Graph

40 Commits

Author SHA1 Message Date
Edward Langley 3f97dfae8e Diagnose improvise-864a: the reaper is another run's SUCCESSFUL deploy
No code fix. The ticket asked that any fix first establish why two of three
green uploads had no artifact at deploy time, and the answer is neither of the
two directions it offered.

Measured over all 100 push-to-main CI runs from 2026-09-19T22:02Z to
2026-09-21T05:02Z, via the run/job/step/log APIs -- job metadata and logs
outlive artifacts, which expire in a day and are all 404 now, so this is
reproducible where the ticket's own artifact-count table no longer is:

  12 artifact-not-found failures -- 12 (100%) had another run's SUCCESSFUL
     deploy complete inside their own upload->deploy window
  56 successes                   --  2 (3.6%) did
   4 other failures              --  0; all four are the unrelated "Create Pages
     site failed: Resource not accessible by integration", pre-improvise-3gy

Direction: the oldest run of the 17:47Z batch failed at 17:47:23Z, before the
next deploy in the batch started (17:47:31Z) and before any Pages deployment
existed at all, since deploy-pages fetches artifact metadata BEFORE creating a
deployment. So it cannot have been caused by its own deploy, by supersession or
by a concurrent one. The artifact was already gone.

Scoped by artifact NAME, not by run: run 35560726456 finalized harness-serve,
wasm-bundles and github-pages seconds apart, and 2m21s later its own lookup
reported "Found 2 artifact(s)" with no github-pages. Same run, same token, same
call, same retention -- which rules out service flakiness, eventual consistency,
retention, token scope and a v3/v4 backend mismatch in one observation, since
any of them would have taken the siblings too.

One clause of the ticket's own correction is falsified: 52507a6 did not fail
"anyway" -- 5f871f7's deploy completed four seconds before its own began. Fewer
successful deploys is the right lever, so PR #46 aimed at the right quantity for
the wrong reason. It still should not ship: the association is not a mechanism
and the mechanism is not deterministic (5f871f7 had a foreign deploy inside its
own window and survived), so shipping it would be exactly
doctrine(measure_the_obvious_fix_before_reporting_it_as_the_fix).

Also corrects the pages-deploy comment in ci.yml, which stated the superseded-run
mechanism this evidence refutes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 22:14:48 -07:00
Edward Langley 3542d6f717 fix(tooling): a leaf is not an identity, and a convenience flag was a bypass
Both blockers from the adversarial review of #83, both in the mode this PR
rewrote, both producing a pass while the gate did not do what its prose said.

1. cargo_test_names reduced every listed path to its last segment. Six leaf
names collide in this workspace and two are cited in evidence, so a heading was
satisfiable by a test in another module answering another question -- renaming
ui::project::tests::a_load_that_fails_is_not_a_rebase, the one its entry
describes, left the checker at exit 0 with the ui::host namesake answering for
it. Paths are kept whole; a bare leaf resolves only when unique, and an
ambiguous one is REFUSED with both candidates named. _RUST_NAME accepts `::`, so
a qualified heading is a claim rather than prose, and a citation is matched on
the leaf so qualifying a heading cannot orphan its own test. The two colliding
entries are qualified. Neither half suffices alone: rename one of a colliding
pair and the leaf becomes unique again, which is why qualification is the fix
and the refusal is what forces it.

2. --rust-tests called cargo_test_names with one argument, so the --ignored
subtraction never ran, and it printed a byte-identical success line. It now
requires --rust-tests-ignored (one alone exits 2) and the line names its source.

Also, from the same review: a missing context/test-evidence/ exited 0 while 170
citations pointed at nothing (early return, now gone); the doc-site "runs last"
check accepted a trailing `echo` that merely NAMED the generator, so it asks for
an invocation; bundle_freedom gains an arm for a transitive `import "../app.js"`,
which reaches the bundle while naming nothing forbidden; and two prose overclaims
are corrected -- the prose-heading count is a convenience rather than a guard
(git diff is the mechanism), and the ratio rule says "above the run MEDIAN",
which is defensible where "above the others" is false against second place.

scripts/check-ci-graph.py makes claim 6's rule a gate: pages-deploy must `needs`
every other job in ci.yml. The rule was stated in the last commit and a
zz-new-check: job added during review was still ungated with nothing noticing.
README now states the rule and lists no jobs, the second copy being what went
stale twice.

Every fault re-injected and observed; figures in context/test-evidence/tooling.md
rather than only here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:42:07 -07:00
Edward Langley 4552165a93 fix(ci): pages-deploy needs every check job, and README names them all (improvise-x7cq)
kb was the one job absent from the needs list, on the unstated ground that
kb.pl is not shipped by the site. harness-lint's crate is not shipped either and
was in the list, so the ground proved too much. A failing knowledge-base
consistency check means main is broken, and publishing from a broken main is
what the list exists to stop. The rule that does not go stale is "every check
job", so that is what the comment says.

README named three of the seven, which read as a promise the other four were
ungated -- and it is the shape gotcha 48 is about: a fact stated in two places
where changing one is an edit rather than a grep. It now names the rule and the
list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:13:07 -07:00
Edward Langley 8af37d44a1 feat(ci): two conventions become gates (improvise-w8ih, improvise-4ktt)
The stamp gate is scripts/assemble-stamp.sh, taking FILE PATTERN pairs, and
scripts/assemble-stamp-test.sh is its check_the_checker. Deleting the final
`test -z "$missing"` makes four of its eight cases fail; before the extraction
that same deletion was invisible, both misses printing while assemble.sh exited
0. It is a separate script because exercising it inside assemble.sh would need a
fabricated web/pkg, which is a second answer to "what does the site assemble
from" (improvise-vvl3). Wired into the `build` job, first, because it needs
nothing and guards a step in that job.

web/tests/bundle_freedom.test.js refuses a flat web/tests/ file that names the
bundle directory -- the constraint keeping the `js` job at seconds rather than
~4m30s, stated in prose and checked by nothing. It scans its own filename too,
so it cannot become the place a violation hides, and its companion asserts
web/tests/bundle/ still holds a test that DOES use the bundle: a scoping rule is
uninteresting if nothing falls outside it.

Both faults were injected and watched to fail; evidence in
context/test-evidence/tooling.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:11:08 -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
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 d239a16245 Merge remote-tracking branch 'gh/main' into feat/doc-site
# Conflicts:
#	.github/workflows/ci.yml
2026-09-20 14:14:10 -07:00
Edward Langley 4f4c9642f2 Merge remote-tracking branch 'gh/main' into ci/build-once
# Conflicts:
#	.github/workflows/ci.yml
2026-09-20 13:55:12 -07:00
Edward Langley f30d512169 Merge remote-tracking branch 'gh/main' into feat/doc-site 2026-09-20 13:50:42 -07:00
Edward Langley 3c9668f9e2 ci: build the bundles once, and take the harness lint off the critical path
Measured on run 35535225637 (push to main, caches warm). The critical path
was the browser job at 263s plus an 11s deploy; everything else finished
inside it. Its decomposition: harness lint 37s, Playwright browser download
50s, `./harness/build.sh` 110s, `npx playwright test` 50s.

Three wasm-pack builds ran in that 110s, and two of them — the client and
the worker bundle — were the same two `pages-build` ran in its own 68s step,
in a parallel job with a separate rust-cache key (`add-job-id-key` defaults
on). Same work, twice per run.

- One `build` job now runs `./harness/build.sh` (all three bundles plus the
  `harness/serve` tree) and then `./web/assemble.sh site $GITHUB_SHA` from
  the pkg dirs it just produced. It uploads `harness/serve`, the two raw
  bundles, and the Pages artifact. `pages-build` is folded into it.
- `browser` downloads `harness/serve` whole and needs no Rust toolchain and
  no wasm-pack. Downloading the tree rather than reassembling it keeps one
  answer to "what does the harness serve" (improvise-vvl3): `harness/build.sh`.
  Verified locally that the two assemblies differ in exactly the four
  cache-bust-stamped files and nothing else — the .wasm bytes are identical.
- `harness-lint` is its own job. It gated nothing the harness needs and cost
  37s of the critical path, and `cargo fmt --all` run from inside
  `harness/opfs-contract` formats the WHOLE outer workspace (114 files,
  measured) despite that crate's empty `[workspace]` table, so a rustfmt
  violation anywhere in the repo failed the *Browser harness* job.
  `-p opfs-contract` scopes it to the one file it is about.
- Playwright's browser download is cached on the resolved package version.
- rust-cache in `build` and `harness-lint` covers `harness/opfs-contract`'s
  own target dir, which the default single-workspace config never did.

No gate is weakened. `pages-deploy` still needs every check that could
previously fail it; `harness-lint` is named explicitly because it used to be
a step inside `browser`. `kb`, `wasm` and `test` gain no dependency on
`build` — none of them consumes an artifact.

On improvise-864a: the gap between the Pages artifact upload and the deploy
narrows from ~182s to ~115s on this run's shape, so the window in which a
superseding run can expire it is smaller. The race is not closed, and not
widened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:40:08 -07:00
Edward Langley f1011d7f6f feat(docs): publish the context/ prose as a site folded into Pages
The landing page is the site root and the app is /app/; the prose now sits
at /docs/, assembled by the same web/assemble.sh the Pages job and the
browser harness both run, so what ships and what is tested are one tree.

Published: design-notes, design-principles, repo-map, the three ADRs, the
seven context/code/ arguments, and the 501 test-evidence entries as their
own clearly-labelled section. Not published: context/plan.md, which is an
agent prompt about a launch rather than a description of the program, and
context/cloud-agents.md, which is addressed to nobody who visits a website.
Neither is referenced by a published document outside a code fence, so
leaving them out dangles nothing. The reasoning is context/code/doc-site.md,
which is itself published.

Two things the corpus needed that a generator would not have given it. The
~190 improvise-xxxx citations resolve to a generated ticket index built from
the already-public .beads/issues.jsonl rather than to an invented URL that
would 404; an id the export does not yet carry stays plain text. And a code
span that is wholly a repo path links to GitHub at the assembled ref, but
only when the file exists on disk, so a stale citation degrades to text.

The generator link-checks its own output and exits non-zero on a break, and
assemble.sh runs under set -eu, so a dead cross-reference fails the assembly
rather than shipping. It earned that on its first two runs: it caught a
fence-close regex comparing a three-character marker to a one-character
string, which swallowed every document from its first code block onward, and
a list parser that ended the gotcha list at the first blank line, emitting 1
of the 48 anchors the jump strip links to. Proved by injection afterwards --
renaming the anchors reports 48 missing anchors, pointing the ticket links at
an ungenerated page reports 763 missing pages.

No framework, no node_modules for the site, no build step beyond one
zero-dependency node script, matching the landing page's posture.

improvise-djoo

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:37:20 -07:00
Edward Langley 36ade35240 test(web): a Node suite between the boundaries, Playwright only on them
E2E and integration tests are rare and pin boundary contracts; unit tests
assume those contracts and test between them (user, 2026-09-20). The browser
half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS —
and everything between them needed no browser at all.

`web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A
DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every
question `gridTable` used to answer while creating elements — the data-row
index across group headers, what a group header spans, a null group label
against an empty one, an empty edit buffer against no editor — and `app.js` is
now a loop that builds elements from it. Painting stays untested by Node; what
to paint stopped being untestable.

26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle,
no npm install), plus three in `web/tests/bundle/` that read the generated
.d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to
16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone,
replaced by `contract.spec.js`, which asserts that EVERY field the render model
projects reaches the screen. The four widgets those specs were written for were
one failure at one boundary, and one assertion catches the fifth.

It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element
(improvise-5jna, already open). They are named in `UNPAINTED` with the ticket,
so the list is the debt written down rather than an exemption.

Thirteen faults injected into the Node suite and eight into the served page,
each watched to fail the test it names; the last of those was strengthened
because the first version passed. `web/assemble.sh` grew an import-completeness
guard whose own first version was blind to the multi-line import it exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:33:49 -07:00
Edward Langley 0a9e342d2e ci: run the checker's self-test before the checks that depend on it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:05:32 -07:00
Edward Langley 10bb66b082 ci: gate the comment cap, which has been advisory since it was written
scripts/check-comment-runs.py was invoked by no workflow. The rule seven
agents spent a wave enforcing, and that carried seven silent successes of
its own, gated nothing.

Held back deliberately until every unit of the comment wave had landed: a
gate that passes before the work is done would be the same class of lie the
checker itself carried. Both checks pass against main as of 52507a6 —
"comment runs: none longer than 2 lines" and "451 entries, all name a test
that exists".

Closes improvise-9c8c.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:47:54 -07:00
Edward Langley ecce5df30f ci: stop running every job twice on PR branches
`on: push` unrestricted plus `on: pull_request` means a branch with an open PR
fires BOTH triggers, so every job ran twice — visible as the (push) and
(pull_request) pairs in the checks list, twelve successful checks where six
would do. Across this campaign's twelve PRs that is a lot of wasted runner time,
and the browser harness job alone is four minutes each.

Restricting push to main keeps exactly one run per commit: a PR branch is
covered once by pull_request, main once by push. pages-deploy is unaffected —
it already gates on `github.event_name == 'push' && github.ref == 'refs/heads/main'`.

Checked the other workflows: claude.yml is event-driven and release.yml already
scopes push to tags, so ci.yml was the only one doubling.

Deliberately does not touch claude-code-review.yml, which PR #35 is changing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:42:32 -07:00
Edward Langley 47ef287349 chore: comment out nix test CI 2026-09-20 10:34:45 -07:00
Edward Langley 3224991f4e chore: more disk for nix 2026-09-20 10:31:31 -07:00
Edward Langley 75a4ff2052 fix: remove nonexistent dirs 2026-09-20 10:24:43 -07:00
Edward Langley a47663a735 chore: more disk space? 2026-09-20 10:07:26 -07:00
Edward Langley 67ca8a6aa8 fix: regenerate Cargo.nix, checkout in nix-test 2026-09-20 10:02:37 -07:00
Edward Langley 55b92606ec chore: add nix ci for build 2026-09-20 09:58:10 -07:00
Edward Langley 67949a0881 chore: update ci, use nix 2026-09-20 09:36:26 -07:00
Edward Langley 3fcf05208d chore: remove useless claude code review workflow 2026-09-20 09:25:02 -07:00
Edward Langley 3c36a3b57f test(harness): Firefox runs the contract too, and test.fail stops hiding setup
Two Copilot findings on PR #24, both correct, both applied.

**`test.fail` was masking setup failures.** Applied before navigation, it
made any earlier failure — the page not loading, the script not starting,
`#status` never updating — an *expected* failure, so CI stayed green through
it. That is precisely the check-the-checker defect these tests exist to
catch, wearing the name of its cure. Both tests now assert their setup
first and apply `test.fail` immediately before the one assertion the known
bug owns. Verified by breaking the setup (making `paintStatus` a no-op):
before, 2 passed; after, **2 failed**.

**Firefox now runs the whole battery, and it passes.** improvise-xf1's
acceptance criterion names Chrome, Firefox and Safari, and the reason is
real rather than ceremonial: `createSyncAccessHandle` is the one API this
design rests on (gotcha 28) and each engine implemented it separately, so
Chromium alone was one engine's opinion. `storage_contract` against
`OpfsFs` now passes on **two** engines.

**Safari is not covered, and the criterion is met for two of three.**
Playwright's WebKit build provides no OPFS at all —
`navigator.storage.getDirectory()` itself rejects with `UnknownError` — so
there is nothing there for `OpfsFs` to be right or wrong about. Rather than
delete the project and quietly narrow the claim, it is kept and the spec
skips on a **runtime probe**: the day that build gains OPFS the checks start
running with no CI change. The skip is conditional on the probe and not on
the browser name, and an unexpected skip on any other engine **fails** with
"that is a regression, not a known gap" — a silently skipped check being the
failure this harness exists to prevent.

The shell spec stays Chromium-only on purpose: it is about `web/app.js`'s
own logic, which is engine-independent, and running `routeWebSocket` plus
the worker handshake three times is slower and flakier to no benefit.

14 passing, 3 skipped, 36s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:23:44 -07:00
Edward Langley ee65cb2815 ci(browser): name clippy and rustfmt, which this job actually uses
The browser job lints harness/opfs-contract because it is not a workspace
member and the `test` job's workspace-wide fmt and clippy cannot reach it.
It was relying on the toolchain action's defaults for the two components it
runs. Named, like the `test` job does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:48:47 -07:00
Edward Langley 29962b113f test(harness): name the root check for what it asserts, and lint the crate
`open_and_refresh_see_an_empty_origin_root` claimed more than it checked:
since the previous commit an empty *entry* is not a document, so `list`
being empty means "no documents", not "no entries". A check whose name
overstates it is the shape this repo keeps finding — a green tick against a
claim nobody verified. Renamed, and `wipe`'s doc now says out loud that it
cannot clear a zero-byte entry, and why that costs nothing.

`harness/opfs-contract` is not a workspace member, so the `test` job's
workspace-wide `cargo fmt --check` and clippy never reach it. The browser
job now runs both against it — it was, in fact, unformatted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:46:19 -07:00
Edward Langley 7e991360df test(harness): a headless browser, and the first execution of OpfsFs
improvise-vvl3, absorbing improvise-xf1. The condition the user set when
deferring browser testing — "WE HAVE TO HAVE SOMETHING TO TEST" — has been
met: after improvise-9x6, djm, hwpi and the demo model there is a complete
self-contained application whose entire untested surface is what a harness
covers.

`python3 -m http.server` plus one Playwright project, in `harness/`:

  harness/opfs-contract/   a wasm crate that runs
                           `improvise_io::storage::contract::storage_contract`
                           against `OpfsFs` — the shared function, not a
                           transcription of it
  harness/www/opfs/        the page and the dedicated Web Worker it runs in;
                           `createSyncAccessHandle` exists nowhere else
  harness/tests/           two specs
  harness/build.sh         site + harness bundle -> harness/serve

It lives at the repo root rather than under `web/`, because `web/` is the
*source of what ships* and every file in it is copied to the site. The
harness serves the **assembled** tree (`web/assemble.sh`, extracted from
the CI workflow in this commit so there is one answer to "how is the site
arranged" rather than two) plus its own `/opfs/` — a harness that runs
against a different arrangement of files is testing something nobody
deploys. `harness/opfs-contract` is deliberately not a workspace member: it
only builds for wasm32, so a member would break `cargo test --workspace`.

What this runs that nothing has ever run:

- `storage_contract` against `OpfsFs`, at `""` **and** at `"docs"`. Two
  bugs fell straight out — improvise-vhcz's `list` path shape, whose fix
  the `"docs"` run guards (reverting it fails with `lists the one
  document`), and the zero-byte entry fix in the previous commit, which
  this found by failing on `starts empty`.
- The `keys()` async-iterator walk in `refresh`, hand-rolled through
  `js_sys::Reflect` and named by xf1 as the least certain code in the file.
  Three entries written out of order, so one passing by accident cannot
  carry it.
- `FileSystemReadWriteOptions`' `at` on read and write: a long write then a
  short one, which is where an append or a non-zero start shows up.
- `commit_deletes`, both halves — queued but still in OPFS before, gone
  after — and **across a reload**, which is the only way to ask whether
  OPFS persisted. That reload is also improvise-bck's persistence criterion
  at the storage layer.
- improvise-hwpi's three one-assertion shell tests, which that ticket said
  it could not write: status after a bad frame, status with no peer, one
  repaint on commit. Plus the first-frame watchdog, which was found by
  reading and only because someone went looking.

Check the checker, throughout, because a harness that passes against a stub
— or that silently skips because the worker never started — is exactly this
repo's characteristic defect wearing a green tick:

- `run_canary` deliberately fails, through the *same* reporting path every
  real check uses, and the spec asserts it is reported as a failure with its
  message intact.
- The spec asserts the report contains exactly the expected check names, in
  order, so a battery that quietly shrinks is a failure.
- Every shell assertion was injected and watched to fail: removing the
  bad-frame catch, the watchdog's `setTimeout`, the socket `error` listener
  and the `transport.send(frame)` on commit each fails its own test and
  only its own.
- The commit test types `20000` and asserts `20,000`, so a page that echoed
  what was typed instead of drawing what the host returned would fail.

One live bug is pinned rather than fixed. `a peer that cannot be reached`
asserts the *history* of `#status`, because improvise-m18f is real and
reproduced here: the socket's `close` listener overwrites the precise
"cannot reach" with "disconnected". That is filed and deliberately not the
same fix as the watchdog's (gotcha 48), so the test asserts both halves and
will speak up whichever one moves.

CI gets a `browser` job that can fail the build, and `pages-deploy` now
needs it. Playwright's Chromium download is ~80 MB and well under a minute
on ubuntu-latest, next to the two wasm-pack builds the job already needs —
so "impractical in this CI" does not arise. A harness nobody runs is worse
than none, because it reads as coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:45:03 -07:00
Edward Langley 94d26d8705 fix(web): default the socket to TLS, and cache-bust the deployed bundles
?ws= now takes a host:port and the page supplies the scheme: wss:// when it
is itself served over https, ws:// when over http. An explicit ws:// from an
https page is UPGRADED rather than passed through, because an https origin
refuses it as mixed content -- so passing it through can only produce a
failure that reads like 'the server is not running'. The scheme is not the
caller's to get wrong.

And the deploy now stamps ?v=<sha> onto both bundles. Pages serves everything
max-age=600 and the wasm-pack filenames carry no content hash, so for ten
minutes after a deploy a returning visitor runs the PREVIOUS build -- which
made a shipped fix look like it had not shipped, twice, during the first real
browser debugging session.

BOTH halves are stamped, because a fresh glue file does not pull a fresh
.wasm: the glue locates it with new URL('..._bg.wasm', import.meta.url), and
a URL built against a base does not inherit the base's query. Stamping only
the import specifier would refresh the wrapper and keep the stale binary --
exactly the failure this is here to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 21:17:08 -07:00
copilot-swe-agent[bot] 3706f33bd6 ci: set configure-pages enablement:false to fix Pages deploy failure
The pages-deploy job was failing with:
  Create Pages site failed. Error: Resource not accessible by integration

`actions/configure-pages@v5` with `enablement: true` calls the GitHub API to
create/enable the Pages site, which requires admin-level permissions the
GITHUB_TOKEN doesn't have. Changing to `enablement: false` makes the step only
configure the deployment environment URL without touching the site API.

GitHub Pages must be enabled once in the repository settings
(Settings → Pages → Source: GitHub Actions) for the deploy to publish a URL,
but the job will no longer fail on every run.

Co-authored-by: fiddlerwoaroof <808745+fiddlerwoaroof@users.noreply.github.com>
2026-09-20 03:41:50 +00:00
Edward Langley a550201fae feat(web): the page spawns the worker, opens a demo, and reads a key
Three pieces of the standalone deployment, all in web/.

improvise-djm — app.js picks its peer instead of assuming a socket. The
wasm bundle is the same artifact either way (improvise-xks6), so the
difference is confined to one factory: `?ws=` selects the websocket, its
absence spawns `new Worker('./worker.js?doc=&seed=', {type:'module'})`.
The websocket path is KEPT, because improvise-ws-server is how a
TUI-hosted document is shared and nothing else can reach it; the presence
of the parameter is the whole selection mechanism, since a second knob
naming the same fact is a second thing to get wrong.

improvise-bck — web/demo.improv, built against an assertion rather than
by flipping an axis. examples/demo.improv leaves Customer/Date on None,
so every cell is an Aggregate and nothing is editable. The new demo puts
exactly one category on each visible axis (Product on row, _Measure on
column, Quarter on page) and carries Profit = Revenue - Cost, so a
visitor sees the formula move beside the cell they typed in, with no
keystroke and no scrolling. Two tests hold it: tests/web_demo_model.rs
projects the whole grid through grid_of and asserts every CellId is
Record (ADR 002 — that is what `editable` means), and post_message.rs
drives the shipped bytes through the real seed -> VFS -> open -> Snapshot
chain. The first was proved discriminating by pointing it at
examples/demo.improv, where it names the aggregate at (0,0).

One category per axis is asserted too: GridCache slash-joins a
multi-level tuple (gotcha 44), so a second category on an axis puts that
known gap on the front page.

improvise-hwpi — all four. A keydown listener on window feeds
Client::on_key_event, so the classifier is reachable at last; a frame
that will not fold now writes to #status instead of only the console; the
socket has an error listener, so "not running" no longer reads like
"still connecting"; and close() has a re-entry guard. Plus one the ticket
could not have predicted: a worker whose start() rejects is caught inside
worker.js and fires no `error` on the main thread, so the same silence
was about to be rebuilt in the new path — a first-frame watchdog covers
both deployments, because "the peer never answered" is the one condition
that means the same thing on each.

Verified: cargo test --workspace 1159 passing (1155 + 4), clippy clean,
fmt clean, actionlint clean, ./web/build.sh end to end (345 KB / 123 KB
gzipped client, 1.33 MB / 498 KB gzipped worker), every call present in
the generated .d.ts with the right arity, every relative reference in the
assembled site resolving. NOT verified: there is no browser here. No line
of JavaScript in this commit has ever run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 19:28:27 -07:00
Edward Langley bdce1636bb feat(worker-server): the whole App in a Web Worker, over postMessage + OPFS
improvise-9x6: the server half of the standalone static-web deployment
(epic improvise-tm6). `improvise-worker-server` hosts the authoritative
`App`, the command pipeline, the projection layer and VFS persistence behind
`self.onmessage`/`self.postMessage`.

It is small because everything it needs already existed: the frames are
`improvise_protocol::frame`'s (improvise-xks6), the session loop is
`Host` (previous commit), the basis check is `Sessions::apply`/`Checked`
(improvise-ondj), and persistence is `Storage` + `OpfsFs` (improvise-6mq,
i34). What is genuinely new is three things.

`vfs.rs` — how a document gets INTO a browser's empty filesystem. Loading is
unchanged: it is `persistence::load_from` on a `Storage`, the same call the
native binary makes. Seeding is the new problem, and it is a
`Storage::write`, so a fetched demo, an upload and a saved document take the
same parse path and a seed never clobbers an edit. `list` is the document
picker a browser has no OS file manager for.

`worker.rs` — the loop: one port, one client, the `Viewport` handshake
(dropped rather than closed, since there is no connection to close), and the
filter from `Addressed`. Every decision is here and every test points at it.

`bindings.rs` — `cfg(wasm32)`, decision-free, and the only part no test here
can reach: it awaits `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` and
forwards strings.

Two gaps found and reported rather than papered over. No client on any
transport can ask its host to save — `effect::Save` is `ViewOnly`, so the
wire vocabulary derived from the model effects cannot name it — and a wire
`ModelCommand::SetCell` never sets `model_state.dirty`, because dirtiness is
raised by a separate `MarkDirty` effect the key path emits. So the worker
persists on its own behalf, keyed off the command variant rather than off a
flag that is never set; `touches_document` carries the argument.

`ClientFrame::Key` is still answered with nothing. A worker CAN call
`reduce_key` — it holds the `App` — but nothing in the projection stream
carries the host's own `ViewState`, so a half-wired arm diverges rather than
under-delivers. `a_key_resolved_on_the_host_moves_state_no_client_can_hear`
demonstrates it against a real `App` and `Sessions` (improvise-bpq2).

22 tests, all host-side: 16 in the crate and 6 in `tests/post_message.rs`,
which drives the worker with nothing but the JSON a `MessagePort` carries.
Bundle: 1,334,892 bytes raw, 496,965 gzipped — 485 KB against a 3 MB budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 19:08:19 -07:00
Edward Langley 188f3e94ef ci(pages): build the wasm bundle and publish web/ to GitHub Pages
improvise-d31's finished artifact depends on improvise-9x6 (the worker-hosted
server) through improvise-djm. The *pipeline* does not, and building it now is
what stops the delivery half from being a mystery when the worker lands: the
swap from websocket transport to worker transport then changes what is
bundled, not how it is built and shipped.

Two jobs in ci.yml rather than a second workflow. `pages-build` runs on every
push and pull request, so a change that breaks `web/build.sh` or the bundle
fails on the PR that made it. `pages-deploy` runs only on a push to main and
`needs: [test, wasm, pages-build]`, so nothing that failed `cargo test` or the
existing wasm32 gate can reach the published site — the deploy depends on
those checks instead of repeating them.

Deliberately not path-filtered. The bundle is improvise-wasm-client plus
improvise-protocol, improvise-core and improvise-formula, so a filter meaning
"skip when the bundle cannot have changed" would have to name most of the
workspace, and getting it wrong leaves a stale site with nothing saying so.

wasm-pack is a pinned release tarball, v0.15.0 — the version `nix develop`
now supplies — so the local run that produced 346 KB of wasm (120 KB gzipped)
is evidence about the CI run. The devShell was missing wasm-pack entirely:
the `wasm` job only runs `cargo check`, which does not need it, so nothing had
ever required the one tool that turns a passing check into a loadable page.

What this cannot do yet, said on the page itself rather than routed around:
web/app.js is a thin client that holds no model, so a copy served from a
static host paints an empty grid until it is pointed at an improvise-ws-server
with ?ws=. The notice is static HTML, not something app.js decides, because it
is true on every load and a page that cannot reach a server cannot be trusted
to explain why. README says the same and names the deployed URL.

Verified: cargo test --workspace 1134 passed / 0 failed, clippy clean, fmt
clean, actionlint clean, ./web/build.sh succeeds and the assemble step
produces a 388 KB site whose every reference resolves inside it. NOT verified:
the workflow has never run — GitHub Pages is not enabled on the repository
(GET /repos/fiddlerwoaroof/improvise/pages is 404), which is improvise-3gy.
The deploy job asks configure-pages to enable it; if the token may not, that
step is where the run fails and the fix is the repository setting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 18:50:15 -07:00
Edward Langley 30c4bb6629 feat(wasm-client): the browser-side fold and key classifier (improvise-gsw)
A new crate `improvise-wasm-client`: a `ViewState` plus one widget cache
each, moved by folding the same `Command` stream the host folds, with a
wasm-bindgen surface over JSON.

The ticket's premise does not survive contact with the architecture
(`doctrine(check_the_ticket_premise)`). It asks `on_key_event` to
"resolve via the keymap and dispatch through reduce_view", but a binding
names an *intent* resolved against a `CmdContext` — a `Model`, a
`GridLayout`, an `App` — while a `Command` is the *resolved mutation*
(gotcha 38, improvise-gxi). A thin client holds none of that, so keys
travel upstream unresolved and `improvise-command` is a dev-dependency
rather than a dependency: the keymap could not be used here even if the
bundle could afford `App`, `Workbook`, persistence and the wizard.

The one exception is minibuffer text entry, whose target buffer is
`AppMode::minibuffer().buffer_key` — the same field the host's
`TextEntry` builder reads (gotcha 11), so the local rule is derived from
the same fact rather than copied. `tests/lockstep.rs` drives the
authoritative host and the client over the same keypress in all seven
text-entry modes and asserts the two `ViewState`s agree.

Rejected for local resolution, with reasons in `keys.rs`: cursor
movement (`GridCache` is viewport-indexed, so a client cannot clamp),
panel cursors (the flattening depends on `expanded_cats`), anything
modified (`AnyChar` matches under any modifier on the host), and
CommandMode's Backspace (the one `TextEntry::rebind`, which leaves the
mode).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 16:35:04 -07:00
Edward Langley b7b2776290 ci: gate improvise-gp in the wasm job (improvise-z5eg)
Verified clean by compiling for wasm32-unknown-unknown before adding.
Not in the browser bundle yet, but a candidate, and staying clean is free
now and expensive to retrofit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 14:15:51 -07:00
Edward Langley d25f3187d3 feat(protocol): extract improvise-protocol crate
The crate the browser epic hangs off: Command (the wire format),
ViewState, RenderCache/CellDisplay/Snapshot, and reduce_view. It depends
on improvise-core and serde and nothing else -- no IO, no command layer,
no terminal -- and improvise-command now depends on it.

ViewState, ViewCursor, ViewFrame, AppMode, Panel, MinibufferConfig,
MinibufColor, HELP_PAGE_COUNT and the key types move here. Everything
that moved is re-exported from ui::app and command::key, so no existing
path changed. The three tickets before this one are what made the move
mechanical: h0b evicted the unportable fields, a2x added the derives,
8uz took the cursor out of the shared View.

Command's client->server half is not invented. It is exactly the set of
EffectKind::ViewOnly effects whose target is the portable slice -- h0b
had already sorted the portable state from the host-local, so the
enumeration was done. Effects touching the wizard, staged drill edits,
storage or the layout cache are absent by construction. The other half
carries projections: already-formatted display values and their labels.

One thing had to be added rather than moved. reduce_view runs on a client
that holds no Workbook, and ViewState::cursors is keyed by view name, so
ViewState needs its own active_view. That makes two copies of "which view
is active"; App::switch_to_view and App::install_workbook are the only
writers, and the_two_active_view_names_never_diverge guards it. The 8uz
characterization test caught the desync the moment it appeared, because
it was calling workbook.switch_view directly.

Whether the two are *allowed* to diverge -- two clients on different
views -- is improvise-g0f, deliberately left open. It changes the shape
of improvise-cqq, and nothing here forecloses either answer.

reduce_view has 12 tests including a determinism check: folding the same
stream twice gives the same state, which is what stops the server's
mirror drifting from the client it mirrors. Every Command round-trips
through JSON. The crate is in the CI wasm job.

Closes improvise-cqi

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V69PGQfMJuGRmtSWpfVAqn
2026-09-01 02:03:45 -07:00
Edward Langley 3a58d3c1e8 feat(kb): add a maintained consistency check, and fix what it found
kb_report/0 and kb_consistent/0 enumerate kb_error/1 solutions across four
classes: domain integrity (layers, concerns and dependency edges name real
crates), layering (no inverted crate dependency), rule soundness (rules must
REJECT a sentinel drawn from no declared domain), and functional keys (a
predicate declared functional/1 must not carry conflicting values for one
key), plus cross-references between recorded work.

Wired into CI as a third job, since kb.pl is executable knowledge and
deserves checking like code.

Proving the checker fires found two bugs in it. depends_transitively/2 was a
naive transitive closure, so illegal_dependency/2 — whose entire purpose is
detecting an inverted edge — looped forever on exactly that input; it now
carries a visited set. And the :- discontiguous declarations added in the
previous commit are a syntax error in trealla, which wants the parenthesized
form; the MCP server had silently tolerated them, so validating kb.pl through
the session alone had hidden it.

Every check class is now verified by injecting the fault it targets and
confirming it is reported, then reverting. A consistency check that has only
ever said "consistent" has demonstrated nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V69PGQfMJuGRmtSWpfVAqn
2026-08-30 15:10:14 -07:00
Edward Langley 69e77d6823 ci: add wasm32 to the toolchain and gate the bundle crates (improvise-ywd)
The audit could not start because the Nix toolchain shipped no wasm32 std and
there is no rustup here. Adding targets = ["wasm32-unknown-unknown"] to the
flake's rustToolchain was the whole blocker: all four bundle crates —
improvise-core, improvise-formula, improvise-io, improvise-command — then
check clean on the first try with no feature flags and no source changes.

The root improvise crate does not build for wasm because crossterm does not.
That is correct: it is the terminal binary, not part of the browser bundle,
so the CI job names the four bundle crates rather than checking the workspace.

Adds .github/workflows/ci.yml with that wasm job plus a host job running fmt,
clippy -D warnings and the test suite — there was no test or lint CI before,
only cargo-dist's release workflow.

A check gate cannot catch what merely panics at runtime, and the audit's real
finding was that two of the ticket's six named hazards are real while four are
not. Instant::now() panics on wasm yet compiles, so improvise-command now uses
web-time, which re-exports std::time off-wasm. std::fs likewise compiles and
then fails in a browser; StartImportWizard and LoadModel still bypass the
Storage trait, filed as improvise-fuj because the fix needs a decision on how
a wasm build injects its backend.

Verified by compilation on both targets and by the native suite. Nothing here
has been run on a wasm host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V69PGQfMJuGRmtSWpfVAqn
2026-08-30 09:51:41 -07:00
Edward Langley b2d30d5a20 feat: cargo dist info 2026-04-11 00:15:42 -07:00
Ed Langley 3bb0977e18 "Claude Code Review workflow" 2026-04-11 00:06:47 -07:00
Ed Langley 32fe988d64 "Claude PR Assistant workflow" 2026-04-11 00:06:47 -07:00