Compare commits
38 Commits
4ee5d4b197
...
8220a06581
| Author | SHA1 | Date | |
|---|---|---|---|
| 8220a06581 | |||
| c355e5e844 | |||
| 645cff9a94 | |||
| 2a1b8c067c | |||
| 303a0388fb | |||
| e2d5d95980 | |||
| b872923331 | |||
| 90869e1d96 | |||
| 08a1b108d9 | |||
| ad1032d35f | |||
| c6c8c1fb7f | |||
| 0b84ed10d7 | |||
| 58cc47c480 | |||
| 114bdd11e0 | |||
| 190b330c4e | |||
| ec30c8a7de | |||
| 51b5b7f009 | |||
| b0c4105be3 | |||
| 3104e89ae5 | |||
| 03e46591bd | |||
| 106d4fd3ac | |||
| e0f04ee334 | |||
| 3116d524fb | |||
| 8dcbfbfc77 | |||
| 6da6a0401d | |||
| 7ddc50ac62 | |||
| 62066e1e1c | |||
| 0ae25b99c5 | |||
| bca936bd05 | |||
| 8d40b875c5 | |||
| f155d12afe | |||
| b155ce9f0e | |||
| 43b221d394 | |||
| 6c652c2ed3 | |||
| 689480bf89 | |||
| a2ddb06e9a | |||
| 5cf070ff98 | |||
| e56148fa64 |
@@ -5,13 +5,19 @@ description: Wait for a GitHub PR's checks to finish before merging or reporting
|
||||
|
||||
# Waiting on a PR
|
||||
|
||||
"Watching a PR" means tracking **both** CI checks **and** GitHub comments/reviews.
|
||||
A PR is not ready to merge just because checks are green — review feedback must be
|
||||
read and answered first.
|
||||
|
||||
## CI Checks
|
||||
|
||||
```
|
||||
scripts/pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>]
|
||||
scripts/pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>] [--reviews]
|
||||
```
|
||||
|
||||
```
|
||||
exit 0 every check COMPLETED, none failed, and MERGEABLE with a state of
|
||||
CLEAN, UNSTABLE or HAS_HOOKS
|
||||
exit 0 every check COMPLETED, none failed, MERGEABLE with a state of
|
||||
CLEAN, UNSTABLE or HAS_HOOKS, and reviews fetched (if --reviews)
|
||||
exit 1 a check failed (under any mergeability, BLOCKED included), or the
|
||||
PR is not mergeable (CONFLICTING, DIRTY, BEHIND, DRAFT, or a state
|
||||
it does not know)
|
||||
@@ -21,21 +27,70 @@ exit 3 timed out with checks still running, mergeability still UNKNOWN,
|
||||
exit 4 usage or environment error
|
||||
exit 5 the PR is MERGED or CLOSED, at the head the watch pinned; it ends on
|
||||
the first poll that sees it closed, which may be mid-wait
|
||||
exit 6 checks green/mergeable, but pr-reviews failed (fetch error, not "no reviews")
|
||||
```
|
||||
|
||||
**`--reviews`** (default): after checks complete, print all reviews and comments
|
||||
via `scripts/pr-reviews`. Use `--no-reviews` to skip this. The reviews are shown
|
||||
but do not change the exit code — answering them is your responsibility.
|
||||
|
||||
**Exit 0 now means mergeable, not only green.** GitHub computes mergeability
|
||||
lazily and reports `UNKNOWN` for a while after the base moves. It also reports
|
||||
`BLOCKED` for the moment before a required check that `needs:` another job has
|
||||
registered. The watch waits out both, so you do not need to re-check
|
||||
`mergeable` by hand after it. **Exit 3 is not a pass.** It may be a required
|
||||
check that never ran. Why each
|
||||
state counts the way it does: `context/code/tooling.md`, "pr-watch and
|
||||
mergeability" (improvise-edpl).
|
||||
check that never ran. Why each state counts the way it does:
|
||||
`context/code/tooling.md`, "pr-watch and mergeability" (improvise-edpl).
|
||||
|
||||
**Pass `--expect <sha>` whenever you know which commit you are asking about.**
|
||||
It is the difference between "this PR is green" and "the thing I pushed is
|
||||
green", and a PR here was once merged at a head that predated its own fix.
|
||||
|
||||
## Reviews and Comments
|
||||
|
||||
Reviews are a **separate API surface** from the comment thread. Codex/ChatGPT
|
||||
reviews arrive as *reviews*, not comments — checking only the comment thread
|
||||
misses them entirely. `pr-watch --reviews` (the default) shows all three:
|
||||
|
||||
| Surface | What it is |
|
||||
|---|---|
|
||||
| `[REVIEW]` | The review's own body (pulls/{n}/reviews) |
|
||||
| `[INLINE]` | A comment on a line of the diff (pulls/{n}/comments) |
|
||||
| `[COMMENT]` | A comment on the PR thread (issues/{n}/comments) |
|
||||
|
||||
To fetch reviews directly: `./scripts/pr-reviews <n>` — **do not pipe it**. The
|
||||
script's `$?` must be read directly; piping or chaining loses the exit code.
|
||||
Redirect to a file if you need to process it.
|
||||
|
||||
### Review Obligations
|
||||
|
||||
Before treating a PR as done or merging:
|
||||
|
||||
1. **Read all review feedback** — reviews against an earlier head may still apply
|
||||
2. **Answer substantive feedback with attribution** — every reply must include
|
||||
the attribution line (see below)
|
||||
3. **Re-check after each push** that new reviews target the current head SHA
|
||||
4. **Don't wait indefinitely on Copilot/Codex** — reviews arrive as quota allows;
|
||||
an absent review and an unrequested one look identical. The adversarial review
|
||||
that gates a merge is dispatched by the orchestrator; external reviews are
|
||||
read *in addition*, not instead.
|
||||
|
||||
### GitHub Attribution (Hard Rule)
|
||||
|
||||
**Every GitHub write must include an attribution line.** Posts go out under the
|
||||
repository owner's account — an unattributed post reads as the human's words.
|
||||
|
||||
Open the body with: `_Posted by <Agent Stack> (<model>)_`
|
||||
|
||||
Examples:
|
||||
- `_Posted by Claude (Opus 4.5)_`
|
||||
- `_Posted by Cursor Agent (claude-sonnet-5)_`
|
||||
|
||||
A thread is not "answered" until the reply carries this attribution.
|
||||
|
||||
See AGENTS.md "GitHub Attribution" for the full rule and "Shipping a wave" for
|
||||
Copilot request mechanics and how to answer declined findings.
|
||||
|
||||
## Do not write the loop yourself
|
||||
|
||||
Four signals look like "finished" and are not:
|
||||
|
||||
+21
-13
@@ -1,7 +1,8 @@
|
||||
{"_type":"issue","id":"improvise-cq1q.12","title":"ADR 007 step 2b: displays in the grid frame, page bar, records labels, item rows and CSV export","description":"The second slice of improvise-cq1q.3 (ADR 007 step 2). The first slice put `display` on Category, Item and View, and made the category-panel headers, the tile bar and the view list paint it in both front ends. This slice covers the rest of the grid and the panels:\n- item rows in the category panel (CategoryRow.items needs an item display beside each key);\n- the grid frame's label tuples and group headers;\n- the page bar (PageAxis cat/selected/items);\n- records-mode column headers and coordinate cells;\n- CSV export headers (persistence/export.rs render_csv).\n\nConstraint: the frame tuples are also cell coordinates (`cell_key`, `innermost_group`, `run_start`), so the display must be added at projection time (for example, a display tuple beside the key tuple in AxisFrame), never swapped in place. The page bar's items are cycled by the browser (`SetPageSelection`), so keys stay beside displays there too. See context/code/core.md §21.\n\n## Acceptance Criteria\n- Tests first, each seen to fail. With a category, an item and a group each displayed differently from its key:\n - the TUI grid, the browser render model and CSV export draw the displays;\n - `cell_key` and every command a client composes still name keys.\n- Rival: a frame whose tuple carries the display instead of the key. Cell addressing must fail.\n","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T19:10:38Z","created_by":"agent-adr007","updated_at":"2026-09-25T19:10:38Z","dependencies":[{"issue_id":"improvise-cq1q.12","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T19:10:39Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.12","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T19:10:48Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"2c593fb5-4faa-5695-9e21-3e00aa3ee75b","issue_id":"improvise-cq1q.12","author":"Edward Langley","text":"From the adversarial review of PR #328 at 3116d524 (Claude agent, in the owner's session):\n\n1. **One more surface that still paints category keys.** The view panel's axis summary, `viewmodel::view_axis_summary` (for example `R:Region,Product`), is drawn by `src/ui/view_panel.rs` and by `wasm-client/render.rs` (`RenderViewRow.axes`). `ViewSummary.axes` holds `(category key, Axis)` only, so it needs a display beside each key, just as the frame does. It is not listed here or in cq1q.13.\n2. **A test gap to close in this slice.** A fault in which `view_summaries` gives every view the ACTIVE view's display passes the whole workspace. Add a second view whose display differs from both the active view's display and its own key.\n","created_at":"2026-09-25T19:38:50Z"}],"dependency_count":1,"dependent_count":1,"comment_count":1}
|
||||
{"_type":"issue","id":"improvise-cq1q.5","title":"ADR 007 step 4: .improv ids — new version literal, id grammar, loader refusals, writer, round-trip properties","description":"ADR 007 clauses 5, 9 and 10: persistence, and migration step 4.\n- A second version literal. The grammar's `file` rule gets one arm per literal, and the version line alone picks the regime.\n- The new arm reads declarations with an id (header_decl, item_decl, grouped_item) and references as id_ref. id_token is spelled with ranges (improvise-rxz9). axis_line is tried before format_line (improvise-le4p).\n- The old arm is read exactly as today: whole-line headers, references resolved against displays, ids assigned by slugify, and undeclared items declared at load (answer 24).\n- The loader refuses a new-version file with a duplicate id, a missing id, an id outside the alphabet or a reserved `_` id. The error names the line, and for a duplicate it names both lines.\n- The writer always writes the new literal, and pipe-quotes any display that would otherwise split (`## Category: |Region(code)|(regioncode)`, improvise-80rm).\n- The round-trip properties from clause 5, plus a formula-line generator strategy (improvise-rxz9), so the formula properties are not vacuous.\n\nThe formula-line syntax waits for improvise-m56i.\n\n## Acceptance Criteria\n- Tests first, each failing before the change:\n - an old file with `## Category: Region(code)` loads with display `Region(code)` and id `regioncode`, and its data stays drawn;\n - a new-version file keeps every id verbatim, including `east-3`;\n - duplicate, missing and capitalised ids are each refused with the line named;\n - a category displayed `Format` on a row axis round-trips;\n - format(parse(format(parse(x)))) == format(parse(x)) holds.\n- Evidence that the formula strategy emits `=` lines the walker keeps: count them.\n","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:41Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:49:41Z","dependencies":[{"issue_id":"improvise-cq1q.5","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:41Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.5","depends_on_id":"improvise-cq1q.1","type":"blocks","created_at":"2026-09-25T04:50:54Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.5","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T04:50:53Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.4","title":"ADR 007 step 3: re-key storage by id (Model, Workbook views, CellKey, DataStore, View config, RecordHash)","description":"ADR 007 clauses 2, 3 and 8, and migration step 3: the wide re-key. Every creation site takes its key from the workbook's IdAllocator (step 1): add_category, add_item, add_item_in_group, create_view and add_formula's target item. The display is the typed name.\n- Model::categories and Workbook::views are keyed by id.\n- CellKey is a sorted list of (CategoryId, ItemId), sorted by category id, so gotcha 5's canonical form holds under the new sort key. DataStore interns ids.\n- View config is keyed by id: axis maps, page selections, and hidden and collapsed sets (collapsed stays (CategoryId, group name), per improvise-p003).\n- RecordHash is computed over id text (clause 8). Update ADR 002's prose in the same PR, since the ADR assigns that update to the implementation PR.\n- Display-\u003eid resolution moves to the input edges: minibuffer commits, the command-line/script parser (coordinate with improvise-31oe.2 and improvise-31oe.3), and formula reference resolution.\n\nThis step is blocked by the user question in improvise-m56i on how typed formula names resolve (item 6) and on a non-_Measure formula target's identity (item 2).\n\n## Acceptance Criteria\n- Tests first: after add-category \"Time Period\", the key is `time_period` and the display is \"Time Period\".\n- Adding `East` to Region and to Direction gives `east` and `east-1` (answer 22).\n- A display rename (a test hook until fz77) moves no CellKey, no RecordHash and no layout_version. This is the property the design exists for, so assert that the labels changed and the surrogates did not. The rival, re-keying on rename, must fail it.\n- The wasm gate passes for every bundle crate.\n","notes":"Scope added for improvise-cq1q.11 (agent-adr007, 2026-09-25). This step now owns the OLD-format .improv edge, because it is the only format until cq1q.5: the old arm's loader assigns ids by slugify and resolves references against displays (answer 24 included), and the writer writes DISPLAYS in the old format. cq1q.5 keeps the new version literal, the id grammar, the refusals and the writer switch. Wire operands are already keys, via cq1q.7, which now lands first. Added acceptance criteria: (1) a document with categories displayed Region and Café, and item East, survives save and load at this step with every display intact. The rival, a writer that writes keys, gives region/caf/east and fails. (2) The browser round-trip suite (tests/browser_round_trip.rs, tests/lockstep.rs) stays green with no display-\u003eid resolver in ui/reduce.rs. The main branch is shippable after this step.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:27Z","created_by":"agent-adr007","updated_at":"2026-09-25T06:42:12Z","dependencies":[{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:27Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.2","type":"blocks","created_at":"2026-09-25T06:41:57Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T04:50:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.7","type":"blocks","created_at":"2026-09-25T06:41:47Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-m56i","type":"blocks","created_at":"2026-09-25T04:50:52Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":4,"dependent_count":4,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.3","title":"ADR 007 step 2: a display field on Category, Item and View, read by every renderer (keys unchanged)","description":"ADR 007 clause 2, first half. Category, Item and View each gain `display: String`. Every place that shows a name to a person reads `display`: the grid frame labels, the category, formula and view panels, the tile bar, the page bar, CSV export headers, the minibuffer prompts and the status messages. The key is still the name, and `display == name` everywhere at the end of this step, so behaviour does not change. This splits \"what people see\" from \"what keys the map\" before any key moves, so the re-key step (the next bead) changes only what goes into the key. It follows the user's answer 1: \"reserve `name` ... for the id\".\n\nCoordinate with improvise-felo.6, which is working in category.rs and view/layout.rs. Land after it, or merge.\n\n## Acceptance Criteria\n- A test-only document whose display differs from its key (constructed through a test hook, since no production path can build one yet) shows the display in the grid frame, the category panel, the view panel, the tile bar and the CSV export header. The key never appears.\n- Rival: revert one renderer to read the key, and the matching test fails.\n- The whole suite is otherwise unchanged.\n","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:49:14Z","dependencies":[{"issue_id":"improvise-cq1q.3","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.2","title":"ADR 007 step 1: slugify and the gensym id allocator (pure, improvise-core)","description":"ADR 007 clause 4: slugify, and the gensym-style id allocator. A pure module in improvise-core with no callers yet. It is migration step 2's first piece and depends on nothing.\n\n- `slugify(display)` does:\n 1. Unicode full case folding (answer 26). The `unicase` crate provides it; it is no_std, has no dependencies and is wasm-clean.\n 2. Each run of spaces becomes one `_`.\n 3. Every character outside a-z, 0-9, `-` and `_` is stripped.\n 4. Leading `_` is removed.\n 5. An empty result falls through to the allocator.\n- `IdAllocator`: one increment-only counter per document, starting at 1. A generated id stays bare unless it collides. A collision takes `-\u003cn\u003e` from the counter, advancing again until the candidate is free (answers 27, 28 and 29). An empty slug becomes `\u003ckind\u003e-\u003cn\u003e`. The allocator keeps every id it has ever seen, so a removed id is never reissued within a session.\n- `claim(id)` is how a hand-written id enters the allocator. It refuses a duplicate (answer 29), an id outside the alphabet, and a leading `_` other than the virtual ids `_index`, `_dim` and `_measure` (answer 23). A claimed id is never renamed, because hand-written ids are canonical.\n\n## Acceptance Criteria\n- Tests first, each shown failing: the ADR's examples (`East`-\u003e`east`, `Time Period`-\u003e`time_period`, `Region(code)`-\u003e`regioncode`, `Café`-\u003e`caf`, `Straße`-\u003e`strasse`, `_Draft`-\u003e`draft`, `Ü`-\u003e`category-\u003cn\u003e`); a collision across kinds; a claimed `east-1` that forces `east-2`; retirement; determinism across two independently built allocators.\n- Each test's rival injected and seen to fail: `to_lowercase` instead of folding (`Straße`), a lowest-free-number suffix, and replacing each single space instead of the run.\n- The wasm check passes for improvise-core, and harness/opfs-contract's lock is updated for the new dependency.\n","status":"in_progress","priority":1,"issue_type":"task","assignee":"agent-adr007","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:02Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:51:42Z","started_at":"2026-09-25T04:51:42Z","lease_expires_at":"2026-09-25T04:56:42Z","heartbeat_at":"2026-09-25T04:51:42Z","dependencies":[{"issue_id":"improvise-cq1q.2","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:02Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"a07b9c7f-8972-53a8-8b97-aed300617104","issue_id":"improvise-cq1q.2","author":"agent-adr007","text":"PR #318 opened, branch adr007/slugify, head a2ddb06e. It adds model::id (slugify, IdAllocator, claim) and the unicase dependency. All 11 tests were written first, and each of the six rivals was caught (context/test-evidence/core.md). Every gate is green locally after merging gh/main c91cc055.","created_at":"2026-09-25T05:15:38Z"},{"id":"13895058-8d4e-538d-9939-3ed93a325d69","issue_id":"improvise-cq1q.2","author":"agent-adr007","text":"Pushed 689480bf: Cargo.nix regenerated with crate2nix for unicase. The first CI run's crate2nix job would have failed without it. nix build .#improvise passes locally.","created_at":"2026-09-25T05:37:45Z"}],"dependency_count":0,"dependent_count":1,"comment_count":2}
|
||||
{"_type":"issue","id":"improvise-cq1q.4","title":"ADR 007 step 3: re-key storage by id (Model, Workbook views, CellKey, DataStore, View config, RecordHash)","description":"ADR 007 clauses 2, 3 and 8, and migration step 3: the wide re-key. Every creation site takes its key from the workbook's IdAllocator (step 1): add_category, add_item, add_item_in_group, create_view and add_formula's target item. The display is the typed name.\n- Model::categories and Workbook::views are keyed by id.\n- CellKey is a sorted list of (CategoryId, ItemId), sorted by category id, so gotcha 5's canonical form holds under the new sort key. DataStore interns ids.\n- View config is keyed by id: axis maps, page selections, and hidden and collapsed sets (collapsed stays (CategoryId, group name), per improvise-p003).\n- RecordHash is computed over id text (clause 8). Update ADR 002's prose in the same PR, since the ADR assigns that update to the implementation PR.\n- Display-\u003eid resolution moves to the input edges: minibuffer commits, the command-line/script parser (coordinate with improvise-31oe.2 and improvise-31oe.3), and formula reference resolution.\n\nThis step is blocked by the user question in improvise-m56i on how typed formula names resolve (item 6) and on a non-_Measure formula target's identity (item 2).\n\n## Acceptance Criteria\n- Tests first: after add-category \"Time Period\", the key is `time_period` and the display is \"Time Period\".\n- Adding `East` to Region and to Direction gives `east` and `east-1` (answer 22).\n- A display rename (a test hook until fz77) moves no CellKey, no RecordHash and no layout_version. This is the property the design exists for, so assert that the labels changed and the surrogates did not. The rival, re-keying on rename, must fail it.\n- The wasm gate passes for every bundle crate.\n","notes":"Scope added for improvise-cq1q.11 (agent-adr007, 2026-09-25). This step now owns the OLD-format .improv edge, because it is the only format until cq1q.5: the old arm's loader assigns ids by slugify and resolves references against displays (answer 24 included), and the writer writes DISPLAYS in the old format. cq1q.5 keeps the new version literal, the id grammar, the refusals and the writer switch. Wire operands are already keys, via cq1q.7, which now lands first. Added acceptance criteria: (1) a document with categories displayed Region and Café, and item East, survives save and load at this step with every display intact. The rival, a writer that writes keys, gives region/caf/east and fails. (2) The browser round-trip suite (tests/browser_round_trip.rs, tests/lockstep.rs) stays green with no display-\u003eid resolver in ui/reduce.rs. The main branch is shippable after this step.\nUpdated for the user's decisions of 2026-09-25 (ADR 007 answers 30 and 31; PR #326). A formula target outside _Measure keeps a real item with its own ItemId, allocated like any item, and the formula gets a separate FormulaId (answer 30). Typed names are resolved at the CLIENT, displays first and then ids, and an ambiguous display is refused with the candidates' ids listed (answer 31). The host-side edges in this step, the minibuffer commits and the script parser, follow the same order. Formula references are resolved by the client/editor edge, never by the host from display text (answer 32). The dependency on improvise-m56i is satisfied: it is answered.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:27Z","created_by":"agent-adr007","updated_at":"2026-09-25T18:59:37Z","dependencies":[{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:27Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.12","type":"blocks","created_at":"2026-09-25T19:10:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.13","type":"blocks","created_at":"2026-09-25T19:10:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.2","type":"blocks","created_at":"2026-09-25T06:41:57Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T04:50:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.7","type":"blocks","created_at":"2026-09-25T06:41:47Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-m56i","type":"blocks","created_at":"2026-09-25T04:50:52Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":6,"dependent_count":4,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.3","title":"ADR 007 step 2: a display field on Category, Item and View, read by every renderer (keys unchanged)","description":"ADR 007 clause 2, first half. Category, Item and View each gain `display: String`. Every place that shows a name to a person reads `display`: the grid frame labels, the category, formula and view panels, the tile bar, the page bar, CSV export headers, the minibuffer prompts and the status messages. The key is still the name, and `display == name` everywhere at the end of this step, so behaviour does not change. This splits \"what people see\" from \"what keys the map\" before any key moves, so the re-key step (the next bead) changes only what goes into the key. It follows the user's answer 1: \"reserve `name` ... for the id\".\n\nCoordinate with improvise-felo.6, which is working in category.rs and view/layout.rs. Land after it, or merge.\n\n## Acceptance Criteria\n- A test-only document whose display differs from its key (constructed through a test hook, since no production path can build one yet) shows the display in the grid frame, the category panel, the view panel, the tile bar and the CSV export header. The key never appears.\n- Rival: revert one renderer to read the key, and the matching test fails.\n- The whole suite is otherwise unchanged.\n","notes":"Split for review size (agent-adr007, 2026-09-25). This bead is the FIRST slice: display on Category/Item/View; CategoryRow/TileEntry/ViewSummary carry display beside the key; the category-panel headers, the tile bar and the view list paint it in the TUI and the browser. The rest is improvise-cq1q.12 (grid frame, page bar, records labels, item rows, CSV export) and improvise-cq1q.13 (status messages, view title). cq1q.4 now depends on both.","status":"in_progress","priority":1,"issue_type":"task","assignee":"agent-adr007","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","updated_at":"2026-09-25T19:10:51Z","started_at":"2026-09-25T18:59:52Z","lease_expires_at":"2026-09-25T19:04:52Z","heartbeat_at":"2026-09-25T18:59:52Z","dependencies":[{"issue_id":"improvise-cq1q.3","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":0,"dependent_count":4,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.2","title":"ADR 007 step 1: slugify and the gensym id allocator (pure, improvise-core)","description":"ADR 007 clause 4: slugify, and the gensym-style id allocator. A pure module in improvise-core with no callers yet. It is migration step 2's first piece and depends on nothing.\n\n- `slugify(display)` does:\n 1. Unicode full case folding (answer 26). The `unicase` crate provides it; it is no_std, has no dependencies and is wasm-clean.\n 2. Each run of spaces becomes one `_`.\n 3. Every character outside a-z, 0-9, `-` and `_` is stripped.\n 4. Leading `_` is removed.\n 5. An empty result falls through to the allocator.\n- `IdAllocator`: one increment-only counter per document, starting at 1. A generated id stays bare unless it collides. A collision takes `-\u003cn\u003e` from the counter, advancing again until the candidate is free (answers 27, 28 and 29). An empty slug becomes `\u003ckind\u003e-\u003cn\u003e`. The allocator keeps every id it has ever seen, so a removed id is never reissued within a session.\n- `claim(id)` is how a hand-written id enters the allocator. It refuses a duplicate (answer 29), an id outside the alphabet, and a leading `_` other than the virtual ids `_index`, `_dim` and `_measure` (answer 23). A claimed id is never renamed, because hand-written ids are canonical.\n\n## Acceptance Criteria\n- Tests first, each shown failing: the ADR's examples (`East`-\u003e`east`, `Time Period`-\u003e`time_period`, `Region(code)`-\u003e`regioncode`, `Café`-\u003e`caf`, `Straße`-\u003e`strasse`, `_Draft`-\u003e`draft`, `Ü`-\u003e`category-\u003cn\u003e`); a collision across kinds; a claimed `east-1` that forces `east-2`; retirement; determinism across two independently built allocators.\n- Each test's rival injected and seen to fail: `to_lowercase` instead of folding (`Straße`), a lowest-free-number suffix, and replacing each single space instead of the run.\n- The wasm check passes for improvise-core, and harness/opfs-contract's lock is updated for the new dependency.\n","status":"closed","priority":1,"issue_type":"task","assignee":"agent-adr007","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:02Z","created_by":"agent-adr007","updated_at":"2026-09-25T17:51:51Z","started_at":"2026-09-25T04:51:42Z","closed_at":"2026-09-25T17:51:51Z","close_reason":"PR #318 merged (6da6a040, head 7ddc50ac); reviewed PASS (issuecomment-5836573405); cq1q.11 closed by the re-sequenced bead graph","dependencies":[{"issue_id":"improvise-cq1q.2","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:02Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"a07b9c7f-8972-53a8-8b97-aed300617104","issue_id":"improvise-cq1q.2","author":"agent-adr007","text":"PR #318 opened, branch adr007/slugify, head a2ddb06e. It adds model::id (slugify, IdAllocator, claim) and the unicase dependency. All 11 tests were written first, and each of the six rivals was caught (context/test-evidence/core.md). Every gate is green locally after merging gh/main c91cc055.","created_at":"2026-09-25T05:15:38Z"},{"id":"13895058-8d4e-538d-9939-3ed93a325d69","issue_id":"improvise-cq1q.2","author":"agent-adr007","text":"Pushed 689480bf: Cargo.nix regenerated with crate2nix for unicase. The first CI run's crate2nix job would have failed without it. nix build .#improvise passes locally.","created_at":"2026-09-25T05:37:45Z"}],"dependency_count":0,"dependent_count":1,"comment_count":2}
|
||||
{"_type":"issue","id":"improvise-31oe.1.1","title":"Complete command names on the : command line","description":"First child of the auto-complete epic; the user's top priority. On the ':' command line, completing the command name offers every registered command (names read off the registry's prototypes, gotcha 15 -- no string list), filtered by the typed prefix. Decide and test: the key (Tab, with what on an exact match), single-match insertion, multi-match display (cycle vs list), and that the TUI and the browser use the same engine (improvise-y11g brings the ':' line to the browser). Tests first: every registered command is completable; a prefix narrows; an unknown prefix offers nothing; the candidate set equals the registry's name set (so a new command cannot be missing).","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T03:41:48Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:41:48Z","labels":["command","protocol","ux"],"dependencies":[{"issue_id":"improvise-31oe.1.1","depends_on_id":"improvise-31oe.1","type":"parent-child","created_at":"2026-09-25T03:41:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-8jk6.2","title":"Import drops whole records that lack a category value; keep them with the coordinate absent","description":"USER 2026-09-25, verbatim: 'this limit in import should be fixed, it dates to when this wasn't sparse.' Screenshot: 'Skipped 6 of 342 records missing category values: Settlement Date (6)'. build_model_with_warnings (crates/improvise-io/src/import/wizard.rs ~262-284) skips any record with no value for an accepted Category field; improvise-5j9 only made the loss visible. Records are sparse, so such a record should be KEPT with that coordinate absent from its CellKey. Same mechanism as the sparse folder merge (improvise-lpzu), where a whole file lacks a category column -- so it belongs with that work. Decide and test how an absent coordinate behaves downstream: a view with that category on Row/Column (is the record invisible there, shown under a blank/(none) row, or counted only in totals?), on Page (which selection shows it), on None (aggregates include it), records mode (blank cell in that column), .improv round-trip (a Data line simply omits the category), drill, and ADR 007/identity. The display question for Row/Column is the user's to confirm; state the recommendation in the PR.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-lpzu","owner":"el-github@elangley.org","created_at":"2026-09-25T03:37:31Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:02:21Z","started_at":"2026-09-25T03:37:52Z","closed_at":"2026-09-25T05:02:21Z","close_reason":"Landed in PR #314; reviewed PASS at c43ee724","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.2","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T03:37:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-felo.1","title":"Raise MAX_CATEGORIES from 12 to 24","description":"USER DECISION 2026-09-25, verbatim: '12 categories is feeling small, we should increase to 24.' Prompted by an Etrade transactions import that hit exactly 12 categories, which left no room for the duplicate-disambiguating category of improvise-cgqt (PR #304). Supersedes context/plan.md:260 ('Do not raise or restructure the MAX_CATEGORIES = 12 limit'). The limit lives in crates/improvise-core/src/model/types.rs and is MIRRORED by a private copy in crates/improvise-io/src/import/wizard.rs — make the wizard read the model's (one home), rather than changing two numbers. Update every doc that states 12 (README, design-principles, context/code/*, plan.md) and every test that fills to the cap by literal count; tests should derive the cap, not spell it. Before changing it, find and state why 12 was chosen (any cost that scales with category count: CellKey size, the secondary index, layout cross products, formula fold) and measure one representative operation at 24 categories.","status":"closed","priority":1,"issue_type":"feature","assignee":"wave-felo1","owner":"el-github@elangley.org","created_at":"2026-09-25T02:06:57Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:10:53Z","started_at":"2026-09-25T02:07:37Z","closed_at":"2026-09-25T03:10:53Z","close_reason":"Landed in PR #307; reviewed PASS at 74e460be","labels":["core","import"],"dependencies":[{"issue_id":"improvise-felo.1","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T02:06:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
@@ -101,7 +102,9 @@
|
||||
{"_type":"issue","id":"improvise-km8","title":"1.2 Fix Cargo.toml metadata","description":"Add missing package fields: description, repository, homepage, documentation, readme, keywords, categories, license. Description: 'Terminal pivot-table modeling in the spirit of Lotus Improv'. Keywords: tui, pivot, spreadsheet, data, improv. Categories: command-line-utilities, visualization.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:48Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:20:17Z","close_reason":"Metadata already present: description, license, repository, homepage, readme, keywords, categories all populated.","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-902","title":"1.1 Audit and remove or update context/SPEC.md","description":"Compare context/SPEC.md against actual code. If spec contradicts code significantly, delete it. Optionally move salvageable conceptual content to docs/design-notes.md with a staleness disclaimer. Do not try to bring spec into sync.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:46Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:10:05Z","close_reason":"Audited SPEC.md against code. Found minor inaccuracies (storage internals, wizard step count, search mode representation) but no major architectural contradictions. Deleted SPEC.md since it was largely redundant with repo-map.md and design-principles.md. Salvaged product vision and non-goals into docs/design-notes.md with staleness disclaimer.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-tik","title":"Phase 1: Repository hygiene","description":"Raise the quality floor of what a stranger sees when they land on the repo. Subtasks cover spec audit, Cargo.toml metadata, publish-readiness, CSV audit, and example files.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:45Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:22:34Z","closed_at":"2026-04-09T07:22:34Z","close_reason":"All 5 subtasks complete: SPEC.md audited, Cargo.toml metadata fixed, publish dry-run clean, CSV audit done, example files created.","dependencies":[{"issue_id":"improvise-tik","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T23:37:31Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-902","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-bv1","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.11","title":"ADR 007 plan: cq1q.4 (re-key) omits two input edges it reaches before their own steps — the old-format .improv reader/writer (before .5) and wire operands/projection names (before .7/.8)","description":"Found by the adversarial review of PR #318 (head 689480bf), while checking the cq1q plan's ordering.\n\nimprovise-cq1q.4 (re-key storage by id) lists these input edges where display→id resolution moves: minibuffer commits, the command-line/script parser, and formula reference resolution. Two more edges reach the re-key before their own steps land, and the ticket does not name either.\n\n1. **The .improv reader and writer, before cq1q.5.** After .4, the keys are slugs (`region`, `east`) and the displays are the typed names. Until .5 lands, the only file format is the old one. So:\n - the old-format loader must already assign ids by slugify and resolve references against displays (clause 5's old arm, currently .5's scope);\n - the writer must write displays, not keys.\n\n If the writer writes keys, every save between .4 and .5 lowercases and strips the user's names (`Region` → `region`, `Café` → `caf`), and nothing can restore them.\n\n2. **Wire operands and projection names, before cq1q.7/.8.** After .3, the browser's panels show displays taken from the projections, and the browser composes `SetAxis { category, .. }`, `HideItem` and the rest from the same strings. After .4 the host keys by id, so between .4 and .8 every such command names a key that does not exist. That covers almost every real name, because slugify folds case. The fix is one of:\n - `ui/reduce.rs::model_effects` resolves display→id as an interim input edge;\n - the projections keep carrying the key alongside the display.\n\n ViewState's name-keyed cursors, expanded_cats and view history (cq1q.7) meet the same seam.\n\nADR 007 clause 11 orders the migration the same way (re-key, then persistence, then projections, then vocabulary), so this is a gap in the step boundaries rather than in the ADR's decisions. What is needed: .4's acceptance criteria say the suite stays green, and they name these two interim edges. Or .4 is split so that each step leaves main shippable. A test for edge 1: a document with display `Region` survives save/load between .4 and .5 with its display intact.\n\nA smaller point: cq1q.3 depends on cq1q.2, but adding a display field needs neither slugify nor the allocator. The dependency serializes work for no reason.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:19:38Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:19:38Z","dependencies":[{"issue_id":"improvise-cq1q.11","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T06:19:38Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"ca207052-0f62-5f31-b8d9-3d4a3a30b68f","issue_id":"improvise-cq1q.11","author":"agent-adr007","text":"agent-adr007: resolved by reordering, with no new beads. (1) cq1q.7 (projections and ViewState carry an id field) now depends only on cq1q.3 and lands before the re-key, using the display-first trick: id == key == name until cq1q.4 changes what the key is. So the wire never names a key that does not exist, and no interim resolver in model_effects is needed. (2) cq1q.4 now owns the old-format reader and writer edge: the loader slugifies, the writer writes displays, and a save/load test keeps the displays Region and Café intact. (3) The cq1q.3 -\u003e cq1q.2 dependency is removed. cq1q.4 now depends on .2, .3 and .7, and cq1q.8 depends on .4 and .7. Details are appended to the notes of cq1q.4 and cq1q.7.","created_at":"2026-09-25T06:42:12Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
||||
{"_type":"issue","id":"improvise-cq1q.13","title":"ADR 007 step 2c: displays in status messages and the view title","description":"The third slice of improvise-cq1q.3 (ADR 007 step 2): the remaining places that show a key to a person.\n- Status and minibuffer messages that interpolate a category, item or view name: about 10 sites in improvise-command (ui/effect.rs, cmd/panel.rs, cmd/commit.rs, cmd/core.rs), plus `non_item_refusal` in improvise-protocol and the anyhow errors in workbook.rs that reach the status line.\n- The view title: `grid.rs` \" View: {}\" in the TUI, and `render.rs` `view_name` in the browser. Both read the key from `ViewState::active_view`; they need the active view's display from a projection (the ViewListCache already carries it since the first slice).\n\n## Acceptance Criteria\n- Tests first: with a view and a category displayed differently from their keys, the title and a representative status message (for example, the axis-change status) show the display.\n- Rival: interpolating the key. The test fails.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T19:10:41Z","created_by":"agent-adr007","updated_at":"2026-09-25T19:10:41Z","dependencies":[{"issue_id":"improvise-cq1q.13","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T19:10:41Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.13","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T19:10:49Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-nn4s.10","title":"ADR 004 spells reverse selection as background=accent, which REVERSED draws inverted; the accent must be the foreground","description":"Found in the post-merge review of PR #319 (merged head 3583359a). This needs the user,\nbecause it is a correction to accepted ADR 004 text, not to code.\n\nADR 004 clause 3 and the vocabulary section say the twelve `fg(Black).bg(accent)`\nselection sites become faces that inherit `selection` (which carries `reverse`) and\n\"override `background` from its own surface's accent\". That spelling is inverted.\nREVERSED (SGR 7) swaps the cell's foreground and background. So `bg = accent` plus\n`reverse` draws accent-coloured text on the terminal's default-foreground colour. It\ndoes not draw default-background ink on the accent. Clause 5's CSS rule does the same\nswap (`.reversed { color: var(--face-bg) }`), so the browser inverts identically.\n\nTo reproduce \"ink on accent\" with reverse, the derived face must state the accent as\nits FOREGROUND, leave background unstated, and set reverse. That is also what makes the\nlight-terminal fix in clause 3 work: the ink becomes the terminal's own background.\n\nThis blocks improvise-nn4s.6. If the ADR is migrated as written, every selection\nhighlight renders inverted. It is also not a no-op on a TestBackend buffer. Clause 8\nrequires that change to be named as an exception, with who decided it. So nn4s.6 needs\nboth the corrected spelling and the user's ruling on the exception.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T17:09:00Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:09:00Z","labels":["adr","adversarial-review","needs-user","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.10","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:09:00Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"04cf9807-5d5e-5926-a40c-56dfa32f62a7","issue_id":"improvise-nn4s.10","author":"Edward Langley","text":"USER RULING 2026-09-25, verbatim: 'ADR 004 is allowed to specify visual changes that make the UI more consistent'. So clause 8's exceptions are not limited to category-add: the selection faces using reverse (with the accent as FOREGROUND, per this ticket's correction) are an allowed visual change. Unblocks nn4s.6 once the ADR text is corrected.","created_at":"2026-09-25T17:15:08Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
||||
{"_type":"issue","id":"improvise-cq1q.11","title":"ADR 007 plan: cq1q.4 (re-key) omits two input edges it reaches before their own steps — the old-format .improv reader/writer (before .5) and wire operands/projection names (before .7/.8)","description":"Found by the adversarial review of PR #318 (head 689480bf), while checking the cq1q plan's ordering.\n\nimprovise-cq1q.4 (re-key storage by id) lists these input edges where display→id resolution moves: minibuffer commits, the command-line/script parser, and formula reference resolution. Two more edges reach the re-key before their own steps land, and the ticket does not name either.\n\n1. **The .improv reader and writer, before cq1q.5.** After .4, the keys are slugs (`region`, `east`) and the displays are the typed names. Until .5 lands, the only file format is the old one. So:\n - the old-format loader must already assign ids by slugify and resolve references against displays (clause 5's old arm, currently .5's scope);\n - the writer must write displays, not keys.\n\n If the writer writes keys, every save between .4 and .5 lowercases and strips the user's names (`Region` → `region`, `Café` → `caf`), and nothing can restore them.\n\n2. **Wire operands and projection names, before cq1q.7/.8.** After .3, the browser's panels show displays taken from the projections, and the browser composes `SetAxis { category, .. }`, `HideItem` and the rest from the same strings. After .4 the host keys by id, so between .4 and .8 every such command names a key that does not exist. That covers almost every real name, because slugify folds case. The fix is one of:\n - `ui/reduce.rs::model_effects` resolves display→id as an interim input edge;\n - the projections keep carrying the key alongside the display.\n\n ViewState's name-keyed cursors, expanded_cats and view history (cq1q.7) meet the same seam.\n\nADR 007 clause 11 orders the migration the same way (re-key, then persistence, then projections, then vocabulary), so this is a gap in the step boundaries rather than in the ADR's decisions. What is needed: .4's acceptance criteria say the suite stays green, and they name these two interim edges. Or .4 is split so that each step leaves main shippable. A test for edge 1: a document with display `Region` survives save/load between .4 and .5 with its display intact.\n\nA smaller point: cq1q.3 depends on cq1q.2, but adding a display field needs neither slugify nor the allocator. The dependency serializes work for no reason.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:19:38Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:51:52Z","closed_at":"2026-09-25T17:51:52Z","close_reason":"PR #318 merged (6da6a040, head 7ddc50ac); reviewed PASS (issuecomment-5836573405); cq1q.11 closed by the re-sequenced bead graph","dependencies":[{"issue_id":"improvise-cq1q.11","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T06:19:38Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"ca207052-0f62-5f31-b8d9-3d4a3a30b68f","issue_id":"improvise-cq1q.11","author":"agent-adr007","text":"agent-adr007: resolved by reordering, with no new beads. (1) cq1q.7 (projections and ViewState carry an id field) now depends only on cq1q.3 and lands before the re-key, using the display-first trick: id == key == name until cq1q.4 changes what the key is. So the wire never names a key that does not exist, and no interim resolver in model_effects is needed. (2) cq1q.4 now owns the old-format reader and writer edge: the loader slugifies, the writer writes displays, and a save/load test keeps the displays Region and Café intact. (3) The cq1q.3 -\u003e cq1q.2 dependency is removed. cq1q.4 now depends on .2, .3 and .7, and cq1q.8 depends on .4 and .7. Details are appended to the notes of cq1q.4 and cq1q.7.","created_at":"2026-09-25T06:42:12Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
||||
{"_type":"issue","id":"improvise-8jk6.16","title":"A --measure value that does not read as a number is dropped silently and the import exits 0","description":"Found in the adversarial review of PR #321 at efaa5050. A value in a column the user forced to Measure that does not read as a number is dropped with no warning, and the import exits 0. This is the same \"saved less than was asked\" family that context/code/cli.md section 13 closes for --category, --formula and kept-nothing, but it is not covered there.\n\nRepro, identical on main at c91cc055 and on the PR head:\n\n printf 'k,v\\na,abc\\nb,7\\nc,\"1,000\"\\n' \u003e nn.csv\n improvise import nn.csv --no-wizard --measure v -o nn.improv; echo \"exit=$?\"\n\nWhat happens: only `_Measure=v, k=b = 7` is written, \"Saved to\" is printed, and the exit status is 0. The `abc` and `1,000` values are gone with no report. Row `a` also loses its whole record, which is the improvise-8jk6.14 loss, but here the value was present and could not be read. That is a different reason.\n\nSince 8jk6.4, `csv_parser::number_in` is the one gate that decides whether a measure's CSV text is a number (via `Scalars::number` in `Plan::place`). So there is one place to count what it refused. Decide: warn with a count and the column (the 5j9 and 8jk6.6 partial-skip shape), or refuse headless. A thousands separator such as \"1,000\" is worth deciding about explicitly, since `number_in` does not read it.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:14:58Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:14:58Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.16","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T06:14:58Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-10o9","title":"Formula WHERE/IS naming a GROUP matches none of its members, so a date hierarchy cannot be crosstabbed by formula","description":"The crosstab half of improvise-8jk6.3 does not exist yet. The user: \"I think dates should usually be one category and we can use formulas to crosstab\". But a formula `WHERE` or `IS` that names a GROUP matches none of that group's members. Both compare the record's coordinate to the name exactly, and no record carries the group's name.\n\nMeasured by the adversarial review of PR #322 at 6b910889. The file was a headless import of a two-year CSV with `--extract Date:Year --extract Date:Quarter --extract Date:Month`, so `Date` has items `2024-01`… in groups `2024-Q1`… under years `2024`/`2025`:\n\n- `Y24 = SUM(Revenue WHERE Date = |2024|)` gives -0.00 on every row. The expected value is 1111.\n- `IsY = (Date IS |2024|) * Revenue` gives 0 on every row, including the 2024 months.\n- Naming an item works: `SUM(Revenue WHERE Date = |2024-01|)` = 11, and `(Date IS |2024-01|) * Revenue` = 11 on the 2024-01 row.\n\nThe PR author measured the same and reported it for filing (PR #322 body, and `context/code/formula-and-io.md` \"Crosstabs by formula are not available yet\").\n\nShape: resolve a name that is a group of the filtered category to its covered items, as the fold already does via `Category::items_in_group` (felo.6). Decide the item-vs-group name ambiguity first: improvise-vwc7 says an item may share a name with a group of its category. Tests first: a 3-level Year\u003eQuarter\u003eMonth fixture, with a WHERE and an IS on each level.\n","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:18Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:18Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-10o9","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:18Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-1xfb.9","title":"checkers.sh's env allowlist drops the Nix linker variables, so no suite can link Rust on macOS","description":"Found in the PR #313 review (issuecomment-5827613256): inside the .#ci shell on macOS, 'env -i HOME PATH TMPDIR cargo build --bin improvise' fails with 'ld: library not found for -liconv' (a trivial crate still links). scripts/checkers.sh scrubs the environment down to HOME/PATH/TMPDIR plus three NIX_* variables and drops the ones the Nix cc/linker wrapper needs (NIX_LDFLAGS, NIX_CFLAGS_COMPILE and friends). So any suite that builds Rust fails on macOS under checkers.sh; repo-stats-test.sh worked around it by requiring a prebuilt improvise. context/code/tooling.md (~line 911) overstates the allowlist. Fix: keep the wrapper's variables (or re-enter the dev shell inside the scrub); test by building a crate with a system-library dependency under checkers.sh's env on macOS. CI (Linux) is unaffected as far as known.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:00:52Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:00:52Z","labels":["ci","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.9","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T06:00:52Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
@@ -113,13 +116,13 @@
|
||||
{"_type":"issue","id":"improvise-8jk6.14","title":"Import silently stores nothing for a record with no measure value; a measureless CSV in a folder vanishes","description":"Found in the delta review of PR #314 at c43ee724. A record whose accepted measures are all absent writes no cell, and no warning says so. The categories and items it named are created, but the record is gone. The same thing held for a single-file import before #314, but folder import makes it easy to hit. Reproduced: improvise import \u003cdir\u003e --no-wizard, where dir holds age.csv (entity,age-months / src/a.rs,2) and tags.csv (entity,tag / e1,red). The output has items e1 and red and a records view for tags, but ## Data holds only the age line, and the tags view draws zero rows. The CLI prints only \"Saved to\". The test a_file_with_no_measure_gets_a_records_view_not_another_files_measure asserts row_count 0 (\"a record with no measure has no cell\"), which pins the loss rather than the intent. Decide: warn and count (as 5j9 did for skips), or store the record, e.g. as a Text cell or a presence marker. Records are sparse, and the user wants sparse records kept.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:58:03Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:58:03Z","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.14","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:58:03Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-n28d.1","title":"Drill shows only informative columns: drop categories constant across the drilled records","description":"USER 2026-09-25: 'I think 1+2 make sense, reseting is fine'. When drilling a cell, the _Drill view's records columns include the categories the drill fixed (they are on Axis::Filter at one item, gotcha 27) -- constant across every drilled record, so they carry no information. Default drill columns: categories that vary among the drilled records, plus the measures the drilled cell shows (not every measure in the model). Drill columns RESET on each drill (the user: 'reseting is fine'); _Drill stays virtual and unpersisted. Builds on improvise-31oe.5 (records columns follow the view). Tests first: a drill fixes Region and Month, so neither is a column; a category that varies stays; a hidden measure is absent; re-drilling resets. Mind pending-edit addressing by (row, RecordCol) (gotcha 1): column indices change with the column set.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:57:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:57:25Z","labels":["drill","records","ux"],"dependencies":[{"issue_id":"improvise-n28d.1","depends_on_id":"improvise-31oe.5","type":"blocks","created_at":"2026-09-25T04:57:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-n28d.1","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T04:57:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.10","title":"ADR 007 step 9: import generates ids by slugify and never parses them from headers","description":"ADR 007 answer 8 and clause 5, \"Import\". CSV and JSON dimension names are displays and are never parsed for ids. Every id comes from the workbook's IdAllocator, so two headers that slug alike get a gensym suffix. The saved file is the new version. Most of this falls out of step 3, when creation sites allocate ids. This bead is the import-specific evidence, plus whatever the wizard's preview or naming steps show.\n\nImport is other agents' ground (improvise-8jk6.3/.4/.5/.6 and improvise-lpzu), so coordinate and merge.\n\n## Acceptance Criteria\n- Tests first, through `improvise import --no-wizard`:\n - headers `Region(code)`, `Size(cm)` and `Time Period`/`Time_Period` import as displays with ids `regioncode`, `sizecm`, `time_period` and `time_period-\u003cn\u003e`;\n - the saved file pipe-quotes `|Region(code)|(regioncode)`;\n - the saved file reloads with every cell still drawn (improvise-80rm).\n- Rival: parse a trailing `(token)` as an id, and the first test fails.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:38Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:50:38Z","dependencies":[{"issue_id":"improvise-cq1q.10","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:38Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.10","depends_on_id":"improvise-cq1q.5","type":"blocks","created_at":"2026-09-25T04:51:03Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.9","title":"ADR 007 step 8: clause 12 — a formula edit captures its display-to-id mapping when the editor opens","description":"ADR 007 clause 12: a formula edit maps its display names to ids when the editor opens, following the OpenedCell pattern. It is blocked by the user question in improvise-hexb item 2 and improvise-m56i item 6: which side holds the mapping, what ReplaceFormula.raw carries on the wire, and how an ambiguous typed name resolves. The TUI and the browser must reach the same answer.\n\n## Acceptance Criteria\n- The required regression test, as corrected by improvise-hexb item 1:\n 1. Open the editor on `Profit = Revenue - Cost`.\n 2. From another client, rename `Revenue` to `Sales`, and rename a different item to `Revenue`.\n 3. Commit `Revenue - Cost * 1`.\n 4. The stored formula references the original item's id, and the commit is not refused.\n- Two rivals, each seen to fail: resolving at commit time (binds to the new Revenue), and refusing on rename.\n- A newly typed name that does not resolve is refused, and the refusal names it.\n- A captured id that is gone at commit gets Rejection::Gone.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:26Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:50:26Z","dependencies":[{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:26Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q.6","type":"blocks","created_at":"2026-09-25T04:51:00Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q.8","type":"blocks","created_at":"2026-09-25T04:51:01Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-hexb","type":"blocks","created_at":"2026-09-25T04:51:02Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.9","title":"ADR 007 step 8: clause 12 — a formula edit captures its display-to-id mapping when the editor opens","description":"ADR 007 clause 12: a formula edit maps its display names to ids when the editor opens, following the OpenedCell pattern. It is blocked by the user question in improvise-hexb item 2 and improvise-m56i item 6: which side holds the mapping, what ReplaceFormula.raw carries on the wire, and how an ambiguous typed name resolves. The TUI and the browser must reach the same answer.\n\n## Acceptance Criteria\n- The required regression test, as corrected by improvise-hexb item 1:\n 1. Open the editor on `Profit = Revenue - Cost`.\n 2. From another client, rename `Revenue` to `Sales`, and rename a different item to `Revenue`.\n 3. Commit `Revenue - Cost * 1`.\n 4. The stored formula references the original item's id, and the commit is not refused.\n- Two rivals, each seen to fail: resolving at commit time (binds to the new Revenue), and refusing on rename.\n- A newly typed name that does not resolve is refused, and the refusal names it.\n- A captured id that is gone at commit gets Rejection::Gone.\n","notes":"Updated for the user's decision of 2026-09-25 (answer 32; PR #326): the CLIENT holds the capture, as it holds OpenedCell, and the resolver lives in improvise-protocol so the TUI and the browser share it. ReplaceFormula.raw and AddFormula.raw carry only id-form text, and the host never resolves a display name. It only parses ids, and one that is gone is Rejection::Gone. Typed names new to the edit resolve against the client's id-bearing projection, displays first and then ids (answer 31); an ambiguous display is refused with the candidates' ids listed. The regression test edits a non-name token (Revenue - Cost -\u003e Revenue - Cost * 1), per hexb item 1. The dependency on improvise-hexb is satisfied: it is answered.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:26Z","created_by":"agent-adr007","updated_at":"2026-09-25T18:59:40Z","dependencies":[{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:26Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q.6","type":"blocks","created_at":"2026-09-25T04:51:00Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q.8","type":"blocks","created_at":"2026-09-25T04:51:01Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-hexb","type":"blocks","created_at":"2026-09-25T04:51:02Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.8","title":"ADR 007 step 7: wire operands by id, create by display, Rejection::Gone, ids_named and broken_chain's id arm","description":"ADR 007 clauses 6 and 7: the wire vocabulary by id; migration step 6.\n- Every ModelCommand operand that names an existing thing is an id. A create op takes a display and its parent's id: AddItem { category: CategoryId, display }, AddItemInGroup, AddFormula { target_category, raw }.\n- CreateView { display, switch: true } replaces the [CreateView, SwitchView] compound.\n- RenameRecordCoordinate { record, category: CategoryId, item: ItemRef::{Existing(ItemId), New(display)} }, and the category in CellId::Coordinate becomes a CategoryId.\n- The older variants move to ids one whole variant at a time: SetAxis, SetPageSelection, HideItem, SetCell/ClearCell { view }, and the rest.\n- Rejection::Gone(Named) comes with an exhaustive ids_named feeding Checked::against, and an id arm in broken_chain (improvise-gkyo). Rejection loses Copy. Remove CheckedBatch's created-record exemption, which no longer has a producer (improvise-p003 item 5).\n- The host assigns every id.\n\nThis may be split per variant group if it is too wide for one PR.\n\n## Acceptance Criteria\n- Tests first:\n - [RemoveFormula(f), ReplaceFormula(f)] is refused whole;\n - a command naming a removed id gets Rejection::Gone with the id's text;\n - an AddItem racing a remove-and-re-add of its category lands nowhere (Gone), not on a namesake. The rival, a parent addressed by display, fails it;\n - create-and-switch-view is one command and switches to the id the host assigned.\n- vocabulary_is_complete and vocabulary_is_composed still hold.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:15Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:50:15Z","dependencies":[{"issue_id":"improvise-cq1q.8","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:15Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.8","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T06:41:56Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.8","depends_on_id":"improvise-cq1q.7","type":"blocks","created_at":"2026-09-25T04:50:59Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.7","title":"ADR 007 step 6: projections carry id and display; ViewState keyed by id","description":"ADR 007 clause 6, projections and ViewState; migration step 5.\n- The projections carry id and display together:\n - CategoryRow { id, display, items: Vec\u003c(ItemHandle, String)\u003e, axis }, with ItemHandle::{Item(ItemId), FormulaTarget(FormulaId)} (improvise-i0n7);\n - FormulaCache rows { id, stored, display };\n - ViewSummary { id, display, .. }.\n- AxisFrame labels stay display strings.\n- ViewState keys by id: cursors, expanded_cats, view_back_stack/view_forward_stack, and active_view (improvise-p003).\n\n## Acceptance Criteria\n- Tests first: after a display rename of a category, that client's expanded_cats still holds the category, and its cursors and back history still name the view after a view rename. The rival, name keys, fails both.\n- The panel's formula row shows `Profit (profit) = Revenue - Cost` and shows ids where a display is ambiguous (answer 19). Use a fixture with two items displayed `Revenue` in different categories.\n- view_state_survives_a_json_round_trip still holds.\n","notes":"Reordered for improvise-cq1q.11 (agent-adr007, 2026-09-25). This step now lands BEFORE the re-key (cq1q.4) and depends only on cq1q.3. The projections gain an id field beside the display (CategoryRow, FormulaCache, ViewSummary). Clients compose every wire operand from that id field, and ViewState keys its cursors, expanded_cats, view history and active_view by it. Until cq1q.4 lands, id == the key == the name, so nothing changes behaviour. After cq1q.4 the key becomes the slug, and the browser's commands still name real keys. That closes the gap without an interim display-\u003eid resolver in model_effects. Added acceptance criterion: a test-only document whose key differs from its display (the cq1q.3 hook) composes SetAxis/HideItem from the panel with the KEY, and the host applies it. The rival, composing from the display, fails.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:03Z","created_by":"agent-adr007","updated_at":"2026-09-25T06:42:11Z","dependencies":[{"issue_id":"improvise-cq1q.7","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:03Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.7","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T06:41:46Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.6","title":"ADR 007 step 5: formula identity — FormulaId becomes one id, formula text over ids, lift kb1d","description":"ADR 007 clauses 1 and 5, and the km71 section: formula identity.\n- FormulaId's private fields become one id. Signatures do not change, which is why km71 made it opaque.\n- A formula's target display lives on the Formula. Where it is stored depends on the user's answer to improvise-m56i item 2.\n- Formula text is stored over ids (Formula::raw is no longer verbatim) and quoted through quote::quote_name (for example `|2024|` and `|sum|`).\n- WHERE/IS filter values are item ids in the stored text (the ADR's reading of answer 3; improvise-m56i item 5).\n- A target change becomes a display rename, so kb1d's refusal outside _Measure is lifted.\n\nBlocked by the user's answers to improvise-m56i items 2 and 6.\n\n## Acceptance Criteria\n- Tests first: renaming an item that a formula references does not change the formula's stored text or its FormulaId. The rival, a FormulaId still derived from (target, target_category), fails it.\n- Retargeting a formula outside _Measure succeeds. The test is the kb1d refusal inverted.\n- An id that begins with a digit or is a keyword round-trips through formula text.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:52Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:49:52Z","dependencies":[{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:52Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T04:50:55Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-m56i","type":"blocks","created_at":"2026-09-25T04:50:56Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.1","title":"ADR 007: text fixes from the review tickets (no decision changes)","description":"Text-only fixes to context/adr/007-stable-identities.md. They clarify the ADR and change no decision the user made. This is an ADR PR, so it needs the user's explicit approval before it merges (AGENTS.md, \"ADR pull requests\").\n\nFold in:\n- improvise-le4p. In the new-version arm of view_entry, try axis_line (id_ref ~ \": \" ~ axis keyword) before format_line. The id `format` then reads as an axis line, and no number format is spelled as an axis keyword. id_ref needs no quoting arm. Required test: a category displayed `Format` on a row axis round-trips.\n- improvise-p003:\n 1. The virtual views get the reserved ids `_records` and `_drill`, following answer 23 (\"no non-virtual id can start with _\").\n 2. _Index/_Dim synthetic coordinates are record positions, not items. They are outside the id namespace and are never persisted. This is the ADR's own reading; flag it as such.\n 3. ViewState::active_view and Workbook::active_view hold a ViewId.\n 4. The collapsed set is (CategoryId, group name) while groups have no id (answer 9), and `collapsed:` writes the category id then the group name.\n 5. Under clause 6, CheckedBatch's created-record exemption has nothing left that produces it. It is removed in the wire step.\n- improvise-i0n7, what remains: on an ItemHandle::FormulaTarget row, remove means RemoveFormula, and rename means a display rename of the formula target.\n- improvise-hexb items 1, 3 and 4:\n - The regression test edits a non-name token (`Revenue - Cost` -\u003e `Revenue - Cost * 1`) instead of committing unedited text.\n - Clause 11 lists clause 12's capture step.\n - Fix the drift in \"the only reader is one test\" and \"on the idempotent path\" (this is also improvise-8vs2 item 4).\n - The kb.pl fact decision('improvise-sboz', a_formula_edit_maps_display_names_to_ids_when_the_editor_opens, ...) restates the vacuous test. It must be replaced in the same change, and only the orchestrator writes kb.pl (scripts/kb replace).\n- improvise-8tk5, improvise-80rm, improvise-gkyo and improvise-rxz9 are already answered by revision 8 (answers 13 and 15; clauses 5, 6 and 7). Add one sentence on merges: two branches that add different things under one slug produce a duplicate id, and the merged file's load is refused (answers 11 and 29).\n\nOut of scope: improvise-m56i items 2 and 6, and improvise-hexb item 2. Those are user questions.\n\nAcceptance: `./scripts/bd-audit-ids` passes, no clause's decision changes, and the user approves on the PR.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:44Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:48:44Z","dependencies":[{"issue_id":"improvise-cq1q.1","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:48:44Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"bb182e07-65db-5fa1-84f0-1473d61d7fa8","issue_id":"improvise-cq1q.1","author":"Edward Langley","text":"From the adversarial review of PR #318 (head 689480bf), which is Claude as an agent in the owner's session. Here are two readings that clause 4 leaves implicit and PR #318 had to choose. Consider flagging them in the ADR as its own readings, as p003 item 2 does.\n\n1. **\"Space\" means U+0020 only.** A tab, a no-break space (U+00A0) and an ideographic space (U+3000) are stripped, not turned into `_`. So `Time\\tPeriod` → `timeperiod`, which collides with `TimePeriod` rather than `Time Period`. The reasoning is in context/code/core.md §19. A tab-separated import header is the realistic case.\n2. **A slug made only of `-` is a legal bare id.** Displays `-` and `---` slug to `-` and `---`, and they are not treated as empty. The id_token alphabet admits them, and an `.improv` line such as `-: row`, or an item line beginning `- -`, is legal but unreadable. Step 5's \"if nothing is left\" treats only the empty string as empty.\n","created_at":"2026-09-25T06:19:59Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1}
|
||||
{"_type":"issue","id":"improvise-nn4s.2","title":"Faces step 2: draw.rs face_style; axis_fg, minibuf_fg, field_kind_fg and mode_style become callers and disappear","description":"ADR 004 clause 8 step 2. One face_style(Face)-\u003eStyle in the TUI. AxisColor and Axis::color leave improvise-core (clause 6: face_of(Axis) in protocol). mode_style decomposes into the five mode faces plus the text-entry chain. Carries clause 8's one declared exception: minibuffer.category-add becomes green (user, Q1).","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-faces","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:26Z","created_by":"agent-faces","updated_at":"2026-09-25T04:48:48Z","started_at":"2026-09-25T04:48:48Z","lease_expires_at":"2026-09-25T04:53:48Z","heartbeat_at":"2026-09-25T04:48:48Z","dependencies":[{"issue_id":"improvise-nn4s.2","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:26Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-nn4s.1","title":"Faces step 1: Face, Theme, resolve and the default theme in improvise-protocol","description":"ADR 004 clause 8 step 1. Face vocabulary as semantic data in improvise-protocol (clause 6), derived from domain enums where one exists (clause 2); Colour = Default|Named|Indexed|Rgb (clause 4); six portable attributes (clause 5); inheritance resolved once by Theme::resolve(Face) (clause 3); serde JSON shape with face names as keys (clause 9). The default theme contains no Rgb. Faces land only where a migrated call site needs them; later steps add the rest.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-faces","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:21Z","created_by":"agent-faces","updated_at":"2026-09-25T04:48:47Z","started_at":"2026-09-25T04:48:47Z","lease_expires_at":"2026-09-25T04:53:47Z","heartbeat_at":"2026-09-25T04:48:47Z","dependencies":[{"issue_id":"improvise-nn4s.1","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:21Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.6","title":"ADR 007 step 5: formula identity — FormulaId becomes one id, formula text over ids, lift kb1d","description":"ADR 007 clauses 1 and 5, and the km71 section: formula identity.\n- FormulaId's private fields become one id. Signatures do not change, which is why km71 made it opaque.\n- A formula's target display lives on the Formula. Where it is stored depends on the user's answer to improvise-m56i item 2.\n- Formula text is stored over ids (Formula::raw is no longer verbatim) and quoted through quote::quote_name (for example `|2024|` and `|sum|`).\n- WHERE/IS filter values are item ids in the stored text (the ADR's reading of answer 3; improvise-m56i item 5).\n- A target change becomes a display rename, so kb1d's refusal outside _Measure is lifted.\n\nBlocked by the user's answers to improvise-m56i items 2 and 6.\n\n## Acceptance Criteria\n- Tests first: renaming an item that a formula references does not change the formula's stored text or its FormulaId. The rival, a FormulaId still derived from (target, target_category), fails it.\n- Retargeting a formula outside _Measure succeeds. The test is the kb1d refusal inverted.\n- An id that begins with a digit or is a keyword round-trips through formula text.\n","notes":"Updated for the user's decisions of 2026-09-25 (answer 30; PR #326). This step implements: (1) FormulaId becomes one id, allocated separately from the target. (2) For a target outside _Measure, add_formula keeps creating the real item (its own ItemId), and the formula line names that target by the ITEM's id. For a _Measure target, Formula carries the target's display, and a formula cell's CellKey carries the FormulaId text under _measure. (3) Formula text is stored over ids, and a WHERE/IS filter value is an item id. The exact formula-line spelling waits for Q23 on PR #326; option (a) is recommended there. Added acceptance criteria: a formula targeting CustomCat item Tax has item id tax and a distinct formula id (tax-\u003cn\u003e). Renaming the item's display changes neither id, and the formula line still names tax.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:52Z","created_by":"agent-adr007","updated_at":"2026-09-25T18:59:39Z","dependencies":[{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:52Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T04:50:55Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-m56i","type":"blocks","created_at":"2026-09-25T04:50:56Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-cq1q.1","title":"ADR 007: text fixes from the review tickets (no decision changes)","description":"Text-only fixes to context/adr/007-stable-identities.md. They clarify the ADR and change no decision the user made. This is an ADR PR, so it needs the user's explicit approval before it merges (AGENTS.md, \"ADR pull requests\").\n\nFold in:\n- improvise-le4p. In the new-version arm of view_entry, try axis_line (id_ref ~ \": \" ~ axis keyword) before format_line. The id `format` then reads as an axis line, and no number format is spelled as an axis keyword. id_ref needs no quoting arm. Required test: a category displayed `Format` on a row axis round-trips.\n- improvise-p003:\n 1. The virtual views get the reserved ids `_records` and `_drill`, following answer 23 (\"no non-virtual id can start with _\").\n 2. _Index/_Dim synthetic coordinates are record positions, not items. They are outside the id namespace and are never persisted. This is the ADR's own reading; flag it as such.\n 3. ViewState::active_view and Workbook::active_view hold a ViewId.\n 4. The collapsed set is (CategoryId, group name) while groups have no id (answer 9), and `collapsed:` writes the category id then the group name.\n 5. Under clause 6, CheckedBatch's created-record exemption has nothing left that produces it. It is removed in the wire step.\n- improvise-i0n7, what remains: on an ItemHandle::FormulaTarget row, remove means RemoveFormula, and rename means a display rename of the formula target.\n- improvise-hexb items 1, 3 and 4:\n - The regression test edits a non-name token (`Revenue - Cost` -\u003e `Revenue - Cost * 1`) instead of committing unedited text.\n - Clause 11 lists clause 12's capture step.\n - Fix the drift in \"the only reader is one test\" and \"on the idempotent path\" (this is also improvise-8vs2 item 4).\n - The kb.pl fact decision('improvise-sboz', a_formula_edit_maps_display_names_to_ids_when_the_editor_opens, ...) restates the vacuous test. It must be replaced in the same change, and only the orchestrator writes kb.pl (scripts/kb replace).\n- improvise-8tk5, improvise-80rm, improvise-gkyo and improvise-rxz9 are already answered by revision 8 (answers 13 and 15; clauses 5, 6 and 7). Add one sentence on merges: two branches that add different things under one slug produce a duplicate id, and the merged file's load is refused (answers 11 and 29).\n\nOut of scope: improvise-m56i items 2 and 6, and improvise-hexb item 2. Those are user questions.\n\nAcceptance: `./scripts/bd-audit-ids` passes, no clause's decision changes, and the user approves on the PR.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-adr007","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:44Z","created_by":"agent-adr007","updated_at":"2026-09-25T17:52:26Z","started_at":"2026-09-25T17:52:26Z","lease_expires_at":"2026-09-25T17:57:26Z","heartbeat_at":"2026-09-25T17:52:26Z","dependencies":[{"issue_id":"improvise-cq1q.1","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:48:44Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"bb182e07-65db-5fa1-84f0-1473d61d7fa8","issue_id":"improvise-cq1q.1","author":"Edward Langley","text":"From the adversarial review of PR #318 (head 689480bf), which is Claude as an agent in the owner's session. Here are two readings that clause 4 leaves implicit and PR #318 had to choose. Consider flagging them in the ADR as its own readings, as p003 item 2 does.\n\n1. **\"Space\" means U+0020 only.** A tab, a no-break space (U+00A0) and an ideographic space (U+3000) are stripped, not turned into `_`. So `Time\\tPeriod` → `timeperiod`, which collides with `TimePeriod` rather than `Time Period`. The reasoning is in context/code/core.md §19. A tab-separated import header is the realistic case.\n2. **A slug made only of `-` is a legal bare id.** Displays `-` and `---` slug to `-` and `---`, and they are not treated as empty. The id_token alphabet admits them, and an `.improv` line such as `-: row`, or an item line beginning `- -`, is legal but unreadable. Step 5's \"if nothing is left\" treats only the empty string as empty.\n","created_at":"2026-09-25T06:19:59Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1}
|
||||
{"_type":"issue","id":"improvise-nn4s.2","title":"Faces step 2: draw.rs face_style; axis_fg, minibuf_fg, field_kind_fg and mode_style become callers and disappear","description":"ADR 004 clause 8 step 2. One face_style(Face)-\u003eStyle in the TUI. AxisColor and Axis::color leave improvise-core (clause 6: face_of(Axis) in protocol). mode_style decomposes into the five mode faces plus the text-entry chain. Carries clause 8's one declared exception: minibuffer.category-add becomes green (user, Q1).","status":"closed","priority":2,"issue_type":"task","assignee":"agent-faces","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:26Z","created_by":"agent-faces","updated_at":"2026-09-25T17:10:21Z","started_at":"2026-09-25T04:48:48Z","closed_at":"2026-09-25T17:10:21Z","close_reason":"Landed in PR #319 (merged 4ee5d4b1); post-merge review found the code sound (issuecomment-5836381716); gaps filed as nn4s.8-.12","dependencies":[{"issue_id":"improvise-nn4s.2","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:26Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-nn4s.1","title":"Faces step 1: Face, Theme, resolve and the default theme in improvise-protocol","description":"ADR 004 clause 8 step 1. Face vocabulary as semantic data in improvise-protocol (clause 6), derived from domain enums where one exists (clause 2); Colour = Default|Named|Indexed|Rgb (clause 4); six portable attributes (clause 5); inheritance resolved once by Theme::resolve(Face) (clause 3); serde JSON shape with face names as keys (clause 9). The default theme contains no Rgb. Faces land only where a migrated call site needs them; later steps add the rest.","status":"closed","priority":2,"issue_type":"task","assignee":"agent-faces","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:21Z","created_by":"agent-faces","updated_at":"2026-09-25T17:10:19Z","started_at":"2026-09-25T04:48:47Z","closed_at":"2026-09-25T17:10:19Z","close_reason":"Landed in PR #319 (merged 4ee5d4b1); post-merge review found the code sound (issuecomment-5836381716); gaps filed as nn4s.8-.12","dependencies":[{"issue_id":"improvise-nn4s.1","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:21Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-31oe.8","title":"create-view silently replaces an existing view of the same name and exits 0","description":"Found by the PR #313 review (issuecomment-5826870397): Workbook::create_view is an IndexMap::insert, so 'create-view X' when a view X exists overwrites it -- its axes, page selections, hidden items and collapse state -- with no message, and headless 'improvise cmd/script' exits 0. The repo-stats pipeline's interim create-view replaced a per-file view that folder import (#314) had built. Refuse (naming the view) unless an explicit replace is asked for; tests first in both the TUI path and headless. Related: improvise-31oe.2/31oe.3 (refusals for unknown names, in progress).","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:43:03Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:43:03Z","labels":["headless","view"],"dependencies":[{"issue_id":"improvise-31oe.8","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:43:03Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-felo.8","title":"Semi-additive measures: a different summary per category","description":"USER 2026-09-25, 'yes, file these'. One summary per measure cannot express a semi-additive measure: holdings value is SUMMED across Account but takes LAST across Date; a stock price is AVG or LAST across Date but meaningless summed across Ticker. Add per-category overrides to a measure's aggregation, e.g. in '## Aggregation': '- Value: sum; Date: last' (grammar first). Decide the fold semantics when several categories are folded at once (apply per-category functions in a defined order -- e.g. the overriding categories innermost -- since sum-of-last and last-of-sum differ), how a collapsed group of the overriding category uses it, and totals. Depends on the ordered last/first ticket for the canonical examples. Interacts with ADR 009's incremental aggregates.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:51Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:30:51Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-felo.8","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T04:30:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-felo.7","title":"Ordered summary functions: last and first (closing/opening value)","description":"USER 2026-09-25: 'we probably should also be able to specify a summary function. e.g. stock price doesn't make sense to add' -- and on the proposal: 'yes, file these'. Today a measure's summary is one of sum/avg/min/max/count (AggFunc, improvise-formula ast.rs; per measure in Model::measure_agg, '## Aggregation' section '- Price: avg'), applied to every fold incl. collapsed groups. A stock price wants LAST (closing) or FIRST (opening) over a period. Those need an ORDER over the folded values; the natural one is the item order of the category being folded (e.g. months within a year of a date hierarchy, improvise-8jk6.3/felo.6). Decide: which category orders the fold when several are folded at once (the time-like one? an explicit per-measure 'along' category?), ties, and what last/first mean when the ordering category is on an axis rather than folded. Grammar first (agg_kind in improv.pest). Interacts with ADR 009 (PR #309): its integrated per-group state is an unordered multiset -- last/first need ordered state; raise it there. Test with a stock-price fixture.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:50Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:30:50Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-felo.7","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T04:30:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
@@ -159,7 +162,7 @@
|
||||
{"_type":"issue","id":"improvise-bqsl","title":":import in the TUI parses every file as JSON, so a CSV fails with a JSON parse error","description":"StartImportWizard (crates/improvise-command/src/ui/effect.rs, impl Effect for StartImportWizard) reads the path through app.storage and hands the bytes straight to serde_json::from_slice, so ':import sales.csv' refuses with 'JSON parse error' although the CLI's 'improvise import sales.csv' handles CSV via csv_parser. Two front doors, two answers to 'what can be imported' (design-principles §5: a derivation done twice). Fix: one shared 'bytes + file name -\u003e import records' function in improvise-io (CSV by extension, else JSON) that both the CLI's get_import_data and StartImportWizard call; csv_parser already has _bytes/_from variants over Storage (gotcha 2). Demonstrate first with a failing test: StartImportWizard over a MemoryFs holding a small CSV must open the wizard, not refuse. Consider whether :import should also take several paths (merge_csvs) — at least note the gap if not done.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T00:50:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T00:50:39Z","labels":["import"],"dependencies":[{"issue_id":"improvise-bqsl","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:27Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-lpzu","title":"improvise import should accept a folder of CSV files","description":"Today 'improvise import data/*.csv' works only because the shell expands the glob: get_import_data (src/main.rs) merges several CSV paths via csv_parser::merge_csvs, which adds a 'File' field per row from the file stem. Passing the folder itself ('improvise import data/') fails, because a single path goes to read_to_string, which errors on a directory. Wanted: a directory argument expands to its *.csv entries in a DETERMINISTIC order (sorted by name, not read_dir order — see gotcha 18, the same CSV imported twice must give the same .improv), non-recursive, then goes down the existing merge path. Decide and test: an empty folder (refuse, naming the folder), a folder containing non-CSV files (skip them or refuse — state which), and a folder mixed with file arguments. The directory listing belongs behind Storage::list so the browser/OPFS host can reuse it (gotcha 2), not std::fs in main.rs.","status":"closed","priority":2,"issue_type":"feature","assignee":"agent-lpzu","owner":"el-github@elangley.org","created_at":"2026-09-25T00:50:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:02:19Z","started_at":"2026-09-25T03:32:32Z","closed_at":"2026-09-25T05:02:19Z","close_reason":"Landed in PR #314; reviewed PASS at c43ee724","labels":["import"],"dependencies":[{"issue_id":"improvise-lpzu","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:28Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"ba36392a-bcb8-5db3-b276-13dc05ca2451","issue_id":"improvise-lpzu","author":"Edward Langley","text":"2026-09-25: now needed by improvise-1xfb.5.1 -- the user's design is that code-maat writes its CSVs into a directory and improvise processes that directory. Note code-maat's outputs have DIFFERENT schemas per analysis (coupling: entity,coupled,degree,average-revs; age: entity,age-months; abs-churn: date,added,deleted,commits), so folder import must decide what heterogeneous headers mean (one merged model with a File category and sparse measures, vs refuse, vs one model per schema). Raised P3 -\u003e P2.","created_at":"2026-09-25T03:30:03Z"},{"id":"3aafd79b-1988-54fc-860d-318579349b5a","issue_id":"improvise-lpzu","author":"Edward Langley","text":"USER DECISION 2026-09-25, verbatim: 'Records are already sparse: the model can just leave null/empty values for columns not in a given CSV file.' So heterogeneous headers MERGE into one model (with the existing File category from merge_csvs), and a record simply has no value for a column its file lacks -- not refuse, not one model per schema.","created_at":"2026-09-25T03:32:18Z"},{"id":"91707f9f-2542-533b-b5d9-41d0980ab506","issue_id":"improvise-lpzu","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'The stats model should pre-create views per file (also dir import should do this: one view filtered by the file column and projected to the columns in that file)'. So folder import creates, per source file, a view named after the file: File fixed to that file (filtered), and only that file's own columns shown -- its categories on the grid axes, its measures visible, and other files' categories/measures out of the grid (categories on None, other measures hidden). The merged default view stays.","created_at":"2026-09-25T03:38:09Z"},{"id":"1a0b8382-9330-5788-b62e-6b41c886a881","issue_id":"improvise-lpzu","author":"Edward Langley","text":"USER 2026-09-25: (1) '(none)' position: 'I think leaving it out for now is fine, maybe a UI element somewhere that shows the number of records underneath the current view?' -\u003e filed separately. (2) single-CSV folder: 'yes, we might want to have some kind of update import eventually (don't plan this yet, but we'd want file identity for this)' -\u003e a folder with ONE CSV still gets the File category and its per-file view.","created_at":"2026-09-25T04:30:53Z"},{"id":"b586e154-47ae-513c-9312-a04eef63ffaa","issue_id":"improvise-lpzu","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'yeah, adding a single file shouldn't get the File category'. Confirms the PR #314 rule: a folder (even of one CSV) always merges with File; a lone CSV named directly imports alone, without File.","created_at":"2026-09-25T04:54:23Z"}],"dependency_count":0,"dependent_count":0,"comment_count":5}
|
||||
{"_type":"issue","id":"improvise-weoe","title":"pr-reviews --wait cannot see a plain PR comment, so it never wakes for the user's ADR approval","description":"scripts/pr-reviews --wait is satisfied only by a review or an inline comment whose commit is the current head (has_head_review). A thread comment (issues/{n}/comments) is attached to no commit, so by design it can never satisfy --wait. However, ADR approval arrives as exactly that: the user comments 'Approved' / answers questions on the thread (AGENTS.md 'ADR pull requests'). So a steward waiting on the approved tool sleeps through the one event it is waiting for, and must hand-poll the thread beside it. That is how the earlier #210 watcher missed the user's 19:50Z answers, and the current #210 steward is working around it the same way. Proposed: a --since \u003ciso8601\u003e (or --comments) mode in which a thread comment created after the given time satisfies the wait. Test it with the fake-gh fixture: a thread comment arriving mid-wait wakes it, and a thread comment older than --since does not.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T23:22:01Z","created_by":"Edward Langley","updated_at":"2026-09-24T23:22:01Z","dependencies":[{"issue_id":"improvise-weoe","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-m56i","title":"ADR 007 leaves formula identity unspecified: no formula-line grammar, formula id vs target item id, where a _Measure target's display lives, filter-value strings","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30). This is broader than improvise-i0n7 (which covers only the CategoryRow shape of _Measure formula-target rows).\n\nADR 007 does not say how a formula and its target are identified in the file or the model. An implementer hits these at once:\n\n1. **No grammar for the formula line.** Clause 5's grammar block gives header_decl, item_decl and grouped_item, but no `formula_line` or formula `target` rule for answer 5's F1 (`- Profit(profit) = revenue - cost [cat]`). `formula.pest`'s `formula = { SOI ~ target ~ \"=\" ~ expr ... }` has no id tag, and today the whole line is `formula_raw`.\n2. **Whose id is in the parentheses?** Answer 5 (Q2) is *formula* id placement. Answer 19's `Profit (profit)` is described as \"the target's id\". For a `_Measure` target these coincide (clause 1: the target's handle is its FormulaId). For a target outside `_Measure`, `Model::add_formula` creates a real item. Under answer 22 (ids globally unique), that item's ItemId and the formula's FormulaId must differ. So the file must reference the target item by id somewhere, and the ADR does not say where.\n3. **Where a `_Measure` target's display lives.** Clause 2 gives `display` to Category, Item and View, but not to Formula. km71's section says \"a target change becomes a display rename of the target\", and a `_Measure` target has no item to hold that display.\n4. **Which ItemId a formula cell's CellKey carries** for a `_Measure` target, which has no item. Also `RecordCell.measure: Option\u003cString\u003e` and `CellId::Derived { target: String }` (render.rs) name the target.\n5. **WHERE / IS filter values.** formula.pest has `filter_value = { string | pipe_quoted | bare_ident }`. Is a double-quoted `\"East Coast\"` in id-form text an id or a display? Answer 3 says every reference is an id, but a quoted string has no stated meaning under ids.\n6. **Resolving typed names at the panel edge.** If a typed display name is ambiguous (answer 19), or equals some other thing's id, which wins, or is it refused?\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:30Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:30Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-m56i","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-m56i","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:30Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"0c2ab9a3-bfb7-5273-89bb-99fc544e5a06","issue_id":"improvise-m56i","author":"agent-adr007","text":"agent-adr007 planning. This blocks improvise-cq1q.4 and improvise-cq1q.6. Items 1, 3, 4 and 5 are readings that can go in the ADR text: a formula line is '- \u003cdisplay\u003e(\u003cformula id\u003e) = \u003cid-form expr\u003e [\u003ccategory id\u003e]'; Formula carries the target's display; a formula cell's CellKey carries the FormulaId text under _measure, which is unique by answer 22; and a filter value is an item id. Items 2 and 6 are USER QUESTIONS. Item 2: add_formula creates a real item for a target outside _Measure (types.rs add_formula), so under answer 22 that item's id and the formula's id must differ. Recommendation: treat every formula target like a _Measure one, with ItemHandle::FormulaTarget(FormulaId) and no real item, so one id in the parentheses serves in every category. Item 6: the precedence when a typed name is both a display and another thing's id. Recommendation: display first, then id; an ambiguous display is refused and the refusal lists the candidates' ids.","created_at":"2026-09-25T04:51:33Z"}],"dependency_count":0,"dependent_count":2,"comment_count":1}
|
||||
{"_type":"issue","id":"improvise-m56i","title":"ADR 007 leaves formula identity unspecified: no formula-line grammar, formula id vs target item id, where a _Measure target's display lives, filter-value strings","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30). This is broader than improvise-i0n7 (which covers only the CategoryRow shape of _Measure formula-target rows).\n\nADR 007 does not say how a formula and its target are identified in the file or the model. An implementer hits these at once:\n\n1. **No grammar for the formula line.** Clause 5's grammar block gives header_decl, item_decl and grouped_item, but no `formula_line` or formula `target` rule for answer 5's F1 (`- Profit(profit) = revenue - cost [cat]`). `formula.pest`'s `formula = { SOI ~ target ~ \"=\" ~ expr ... }` has no id tag, and today the whole line is `formula_raw`.\n2. **Whose id is in the parentheses?** Answer 5 (Q2) is *formula* id placement. Answer 19's `Profit (profit)` is described as \"the target's id\". For a `_Measure` target these coincide (clause 1: the target's handle is its FormulaId). For a target outside `_Measure`, `Model::add_formula` creates a real item. Under answer 22 (ids globally unique), that item's ItemId and the formula's FormulaId must differ. So the file must reference the target item by id somewhere, and the ADR does not say where.\n3. **Where a `_Measure` target's display lives.** Clause 2 gives `display` to Category, Item and View, but not to Formula. km71's section says \"a target change becomes a display rename of the target\", and a `_Measure` target has no item to hold that display.\n4. **Which ItemId a formula cell's CellKey carries** for a `_Measure` target, which has no item. Also `RecordCell.measure: Option\u003cString\u003e` and `CellId::Derived { target: String }` (render.rs) name the target.\n5. **WHERE / IS filter values.** formula.pest has `filter_value = { string | pipe_quoted | bare_ident }`. Is a double-quoted `\"East Coast\"` in id-form text an id or a display? Answer 3 says every reference is an id, but a quoted string has no stated meaning under ids.\n6. **Resolving typed names at the panel edge.** If a typed display name is ambiguous (answer 19), or equals some other thing's id, which wins, or is it refused?\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:30Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:30Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-m56i","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-m56i","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:30Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"0c2ab9a3-bfb7-5273-89bb-99fc544e5a06","issue_id":"improvise-m56i","author":"agent-adr007","text":"agent-adr007 planning. This blocks improvise-cq1q.4 and improvise-cq1q.6. Items 1, 3, 4 and 5 are readings that can go in the ADR text: a formula line is '- \u003cdisplay\u003e(\u003cformula id\u003e) = \u003cid-form expr\u003e [\u003ccategory id\u003e]'; Formula carries the target's display; a formula cell's CellKey carries the FormulaId text under _measure, which is unique by answer 22; and a filter value is an item id. Items 2 and 6 are USER QUESTIONS. Item 2: add_formula creates a real item for a target outside _Measure (types.rs add_formula), so under answer 22 that item's id and the formula's id must differ. Recommendation: treat every formula target like a _Measure one, with ItemHandle::FormulaTarget(FormulaId) and no real item, so one id in the parentheses serves in every category. Item 6: the precedence when a typed name is both a display and another thing's id. Recommendation: display first, then id; an ambiguous display is refused and the refusal lists the candidates' ids.","created_at":"2026-09-25T04:51:33Z"},{"id":"e029ce2b-7fdd-5e1c-98a5-df1460a148db","issue_id":"improvise-m56i","author":"Edward Langley","text":"USER DECISION 2026-09-25 (item 2, formula target outside _Measure): 'Real item + separate formula id' -- add_formula keeps creating a real item for a non-_Measure target; the formula gets its own id; formula lines name the target by the item's id. (Chosen over the recommended 'no real item, FormulaTarget handle'.)","created_at":"2026-09-25T17:51:47Z"},{"id":"7e1c14e6-9d79-56c6-bcc5-0abcbb9eb3bb","issue_id":"improvise-m56i","author":"Edward Langley","text":"USER DECISION 2026-09-25 (item 6, a typed name matching a display and another thing's id): 'Display first; client holds it' -- match display first, then id; an ambiguous display is refused listing the candidate ids.","created_at":"2026-09-25T17:51:49Z"}],"dependency_count":0,"dependent_count":2,"comment_count":3}
|
||||
{"_type":"issue","id":"improvise-le4p","title":"ADR 007: an id 'format' cannot be written as a view reference — format_line wins the ordered choice, and id_ref has no quoting arm","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30), against gh/main ad217cc1.\n\nADR 007 clause 5 says an id \"never needs pipe quoting in an .improv declaration or reference\", and its grammar gives references `id_ref = { id_token }` with no pipe-quoted arm. That is false for one id: `format`.\n\nIn improv.pest, `view_entry = _{ format_line | prune_line | hidden_line | collapsed_line | axis_line }` is an ordered choice, and `format_line = { \"format: \" ~ rest_of_line ~ ... }`. So the axis line for a category whose id is `format` (`format: row`, `format: page, east`) matches format_line first and is read as the view's number format. The axis assignment is lost with no error. (`prune: row` and `hidden: row` fail their own rule and backtrack to axis_line, so only `format` collides.)\n\nToday this is masked by accident: `quote::KEYWORDS` contains `FORMAT` (a formula keyword, from d00cea24), matched case-insensitively, so `format_md` writes `|format|: row`. Under ADR 007, slugify folds to lower case (answer 16(b)), so any category displayed `Format` gets the id `format`, and `id_ref` cannot quote it.\n\nThe implementation must pick one: reserve `format` from slugify (suffix it like any collision), give `id_ref` a pipe-quoted arm, or reorder or disambiguate the view-section rules. A round-trip test with a category displayed `Format` on a row axis should fail first.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:28Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:28Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-le4p","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-le4p","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:28Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"bc8717a4-50fc-52a0-86ed-84adf1f7793c","issue_id":"improvise-le4p","author":"agent-adr007","text":"agent-adr007 planning: a text fix, not a user question. In the new-version arm, try axis_line (which requires an axis keyword) before format_line. No number format is an axis keyword, so neither slugify nor id_ref needs to change. Folded into improvise-cq1q.1; the test is in improvise-cq1q.5.","created_at":"2026-09-25T04:51:35Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
||||
{"_type":"issue","id":"improvise-5jxt","title":"pr-reviews dies with 'jq: Argument list too long' on a PR with many comments (#210)","description":"scripts/pr-reviews passes the whole PR, inline-comment and issue-comment JSON to jq as --argjson arguments (fetch(), around line 109). On PR #210 (ADR 007, dozens of long comments) the argv exceeds ARG_MAX: ./scripts/pr-reviews 210 exits 126 with 'jq: Argument list too long', with or without --wait. So the approved review watcher cannot watch the busiest ADR PR at all, and a --wait over it fails in a way that is not exit 1 (timeout) or 0. Fix: feed the three documents via files or stdin (--slurpfile / --rawfile, or jq -s over a concatenated stream) instead of argv. Test: a fixture whose combined JSON exceeds getconf ARG_MAX.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-5jxt","owner":"el-github@elangley.org","created_at":"2026-09-24T22:07:30Z","created_by":"agent-adr007","updated_at":"2026-09-24T22:37:58Z","started_at":"2026-09-24T22:17:05Z","closed_at":"2026-09-24T22:37:58Z","close_reason":"Landed in main via #301.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-0s6.1","title":"Sanitize the repository for a public mirror (improvise/improvise)","description":"Prepare the repository for a public release, via a public mirror at improvise/improvise (user's plan, 2026-09-24).\n\nScope:\n1. Secret scan of the FULL history (gitleaks or trufflehog), every branch and tag that would be published. Rotate anything found; decide whether history must be rewritten.\n2. Content audit: list what becomes public and decide per item — .beads/ (issues.jsonl, interactions.jsonl), prolog-sessions/kb.pl, .claude/ and .agents/, context/ (agent notes), commit author emails, agent-posted PR/issue comments (these stay on the private repo; confirm nothing is copied).\n3. Choose mirror shape: true mirror of main vs. filtered/fresh history (e.g. drop .beads/, .claude/, prolog-sessions/, or squash to a single initial commit). Once public, the choice is effectively permanent.\n4. Workflows in the public repo: self-hosted runners must NOT be attached there. Disable Actions on the mirror initially, or add a GitHub-hosted-only CI. Disable or strip claude.yml (no CLAUDE_CODE_OAUTH_TOKEN there; if ever enabled, gate on author_association OWNER/MEMBER/COLLABORATOR).\n5. Mirror mechanism: push main only, private -\u003e public, via a narrowly scoped deploy key; never push agent WIP branches.\n6. Decide where Pages, releases (cargo dist), README badges and crates.io metadata (repository URL) point.\n7. Contribution flow: how outside PRs/issues on the mirror are brought into the private repo.\n\nAcceptance: a written audit (secret-scan result + public-content list with decisions), the mirror script/workflow in a PR, and the user's go-ahead before the first public push.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T19:52:46Z","created_by":"orchestrator","updated_at":"2026-09-24T19:52:46Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6.1","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-24T19:52:46Z","created_by":"orchestrator","metadata":"{}"}],"comments":[{"id":"b8d881e5-7b7f-5471-bb86-fbe206b5b706","issue_id":"improvise-0s6.1","author":"Edward Langley","text":"Found 2026-09-25 while reading a code-maat age report: bank-info.improv was tracked and deleted in 1c2361b4 (2026-09-19) but is still in history, reachable from many branches; foo.improv (deleted 359c1577) and whatever.improv likewise. Check bank-info.improv's content in history before any public mirror -- if it holds real financial data it needs a history rewrite or a mirror cut from a clean point, which is the user's decision. Also on gh/main: a generated TAGS file (stale; still says MAX_CATEGORIES = 12) and DOLT_REMOTE.md on some branch.","created_at":"2026-09-25T03:18:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
||||
@@ -357,7 +360,7 @@
|
||||
{"_type":"issue","id":"improvise-5ubs","title":"A cyclic-successor all() can silently omit a variant while its doc says it cannot","description":"Found by the adversarial review of PR #78, which probed it rather than reading the doc comment.\n\nAppMode::all() and Rejection::all() walk a total, cyclic after(). The claim in both doc comments is that a new variant cannot be omitted, because after() has no None arm so the only way to answer for a new variant is to NAME ANOTHER ONE.\n\nThat is half true and the half it gets wrong is the half that matters. ANSWERING after() FOR a new variant is forced by the compiler. POINTING AN EXISTING ARM AT IT IS NOT. A probe variant whose after() arm returns some existing variant, with nothing in the cycle pointing back to it, is simply never walked: all().len() came back 17, not 18, with the suite green.\n\nSo the consolidation replaced five grep-visible lists with one derivation whose totality is asserted in prose and checked by nothing. THE REVIEWER'S META-POINT IS THE REAL TICKET: five disagreeing AppMode lists were visible to grep; all() under-enumerating is visible to nothing. A consolidation owes a test that the surviving derivation is TOTAL, not merely that it agrees with what it replaced.\n\nWHAT IS STILL BETTER THAN BEFORE, so this is not a revert: the compiler now forces a human to look, and two of the five lists it replaced were ALREADY WRONG (both missing SavePrompt). This is a gap in a guarantee, not a regression.\n\nOPTIONS, roughly in order of cost:\n- A test that walks the cycle and asserts every variant is visited, driven by something independent of after() -- which needs an independent enumeration, and that is the problem restated.\n- A derive macro or a dependency (strum). Rejected during PR #78 only because adding a workspace dep mid-wave collides in Cargo.toml; on its own it is the honest answer and cheap.\n- Accept and document the residual precisely, replacing both doc comments, which currently overclaim.\n\nWHATEVER IS CHOSEN, FIX THE DOC COMMENTS FIRST: they presently say the omission is impossible, and a reader checking this property will believe them. An overclaiming comment is worse than none, because it stops the next person looking.","notes":"A FOURTH INSTANCE, inside the PR that found this ticket. The adversarial review of PR #78 added a fourteenth `pub struct` declaring `EffectKind::Model(ModelChange::LayoutGeometry)`, pointed at by nothing, and the whole suite stayed green.\n\nThe site is layout_geometry_effects_actually_change_document_config in ui/effect.rs. Its comment said \"a fourteenth declaring LayoutGeometry must come here and say so\". That is false for exactly the reason this ticket names: adding is compiler-forced only where an EXISTING arm must point at the new thing, and here nothing must. `assert_eq!(cases.len(), 13)` is a count, not a totality check -- it catches a case being deleted from the list, never one being absent from it.\n\nCorrected in #78 to say what it is rather than what it is not; the mechanism is unchanged and the cursor consolidation does not depend on it. Filed here rather than fixed because the honest fix is whatever 5ubs settles on, and a fourth hand-rolled attempt in a fourth place is the disease.\n\nWHAT MAKES THIS ONE WORTH RECORDING: it is the same defect as AppMode::all(), in the PR whose own consolidation work surfaced AppMode::all(). A list that is checked for LENGTH reads exactly like a list that is checked for COMPLETENESS, and both are written as an assert_eq over a number.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:01:27Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T12:39:36Z","closed_at":"2026-09-21T12:39:36Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-349m","title":"format_md/parse_md is not idempotent when a .improv file has two Category sections with the same name","description":"format -\u003e parse -\u003e format differs, the two sides differing only in the order of '\u003e' group lines. DETERMINISTIC -- 8 failures in 8 runs on unmodified main (00f7678) -- so it is NOT gotcha 18's HashSet shape.\n\nCause: the file carries two '## Category: ||' sections with the SAME name. The second merges into the first and its group lines are appended in a position the second parse does not reproduce.\n\nReproducer, from the proptest's own minimal input:\n\n let file = \"v2025-04-09\\n# |\\\\a|\\n## View: a\\n_-: row\\ncollapsed: ||/A--\\n\\\n ## Category: ||\\n- |\\\\a\\\\a\\\\a|,|\\\\a\\\\a|,|\\\\a|,|\\\\a|\\n\u003e _---[|\\\\a|]\\n\u003e _[|\\\\a|]\\n\\\n ## Category: ||\\n- ||[||]\\n\u003e ||[||]\\n\";\n let m1 = parse_md(file).unwrap();\n let printed = format_md(\u0026m1);\n assert_eq!(printed, format_md(\u0026parse_md(\u0026printed).unwrap())); // fails\n\nWHY IT LOOKS FLAKY AND IS NOT: persistence::parse::grammar_prop_tests::generated_file_roundtrips goes red only when the grammar-walking generator happens to emit a duplicate section header, which is rare. It was observed once during PR #70's CI, the rerun passed, and the honest move was to take proptest's minimal input and try it deterministically rather than rerun and move on. That is what turned a 'flake' into a filed bug.\n\nFound by the improvise-pdvm/75ld agent on PR #70; not caused by that PR. bd search for idempotent, roundtrip, duplicate category, format_md and group order returned nothing, so it was not already filed.\n\nWorth deciding as part of the fix: is a duplicate '## Category:' header legal input at all? If it is not, the parser should refuse it and the generator should stop emitting it; if it is, the merge must produce a canonical group order. The round-trip property cannot hold without one of those answers.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:42:59Z","created_by":"orchestrator","updated_at":"2026-09-21T04:42:54Z","closed_at":"2026-09-21T04:42:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-v5pc","title":"restore the browser assertion that ? opens help","description":"36ade35 moved the shell suite to node and deleted harness/tests/widgets.spec.js, taking the Playwright test '? opens help, and its rows are the shared ones' with it. Nothing replaced it: no node test and no spec asserts that the help overlay is drawn, that its active tab is the shared module's page title, or that l pages it. The evidence entry in context/test-evidence/clients.md survived the deletion and went on promising the coverage for a whole release -- the improvise-5ray cost, now caught by --check-evidence, and the heading is demoted to prose rather than deleted so its two injections (dropping the paintHelp call; removing the mode != Help guard in help_rows) survive for whoever restores it. improvise-ktv1's acceptance -- a browser shows help -- is currently unasserted end to end.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:25:28Z","created_by":"claude-agent-scripts","updated_at":"2026-09-21T00:06:02Z","closed_at":"2026-09-21T00:06:02Z","close_reason":"Duplicate of improvise-78v6, already fixed and merged in PR #72. harness/tests/help.spec.js on main carries test(\"? opens help, and its rows carry the keys and the words, not empty frames\") plus a second test that reads an advertised key out of the DOM, presses it, and asserts the selection moved.\n\nWorth noting HOW this duplicate happened, because the filing agent did nothing wrong: it searched bd before filing, as AGENTS.md requires, and found nothing -- because the fix was in a sibling agent's UNMERGED PR at the time. A parallel wave makes in-flight work invisible to bd search. The cheap addition to the wave brief is to check open PRs, not only the tracker, before filing something a sibling unit might own.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-xfsb","title":"Agents should post to GitHub under their own identity, not the owner's","description":"Today every agent comment, review and PR goes out as @fiddlerwoaroof, because gh uses his credentials. The interim is a convention -- open each comment with a line saying a model wrote it (doctrine(an_agent_posting_to_github_says_so), AGENTS.md) -- and a convention is something an agent can forget. A separate identity makes attribution structural instead.\n\nTwo routes:\n\n1. GitHub App installed on the repo. Agents mint an installation token and export GH_TOKEN. Comments render as \u003cname\u003e[bot], the same shape as copilot-pull-request-reviewer[bot]. Scoped permissions, no seat consumed, revocable independently of the owner's account, and the bot suffix is not spoofable by a human account.\n\n2. Machine user with its own PAT as GH_TOKEN. Simpler to set up, consumes a seat on some plans, and is indistinguishable from a person at a glance -- which is most of what we are trying to fix.\n\nPrefer 1. Needs the user: create the App, grant contents+pull_requests+issues write, install it, and put the app id and private key where agents can reach them.\n\nWorth checking while doing it: whether the bot can be requested as a PR reviewer, which would let review agents post as reviews rather than as comments.\n\nOnce it lands, the attribution line becomes belt-and-braces rather than the mechanism, and the doctrine should be amended to say so rather than deleted -- a reader seeing an unattributed comment from the owner's account still needs to know it might be an agent.","notes":"DEFERRED by the user 2026-09-22. Recording the reasoning, because the status was\nset with none and the INTERIM IS LOAD-BEARING WHILE THIS SITS.\n\nWHY IT IS A FINE THING TO DEFER: route 1 needs the user to create a GitHub App,\ngrant it contents+pull_requests+issues write, install it, and put the app id and\nprivate key where agents can reach them. That is credential work only he can do,\nand nothing in the codebase is blocked on it.\n\nWHAT MUST STAY IN FORCE MEANWHILE, and this is the part that would be quietly\nlost: EVERY agent comment, review, PR body and issue still opens with a line\nnaming the model and that it is an agent posting in his session\n(doctrine(an_agent_posting_to_github_says_so), AGENTS.md). While this ticket is\ndeferred that convention is THE MECHANISM, not belt-and-braces -- an\nunattributed comment reads as the owner's own words to everyone who sees it.\nDeferring the structural fix therefore RAISES the cost of forgetting the\nconvention rather than lowering it.\n\nMeasured this session: the convention held across four merged PRs and every\nthread comment. It is not currently failing; it is simply the kind of thing that\nfails silently and only once someone notices.\n\nWHAT REOPENS IT:\n 1. The user creating the App -- the only real blocker.\n 2. Any instance of the convention actually being forgotten on a public\n artefact. One is enough: the failure mode is a human reading a model's\n words as his own, and it is not detectable after the fact by anything\n automated.\n 3. A second person gaining write access to the repo, which makes \"who said\n this\" ambiguous in a way the attribution line alone does not resolve.\n\nNOT A REASON TO REOPEN: more agents, or more PRs. Volume does not change the\nargument either way.\n\nNote the ticket's own closing paragraph still applies and should not be lost: when\nthis lands, the attribution doctrine is AMENDED rather than deleted, because a\nreader seeing an unattributed comment from the owner's account still needs to know\nit might be an agent.","status":"deferred","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T23:04:19Z","created_by":"orchestrator","updated_at":"2026-09-22T06:22:50Z","dependencies":[{"issue_id":"improvise-xfsb","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-xfsb","title":"Agents should post to GitHub under their own identity, not the owner's","description":"Today every agent comment, review and PR goes out as @fiddlerwoaroof, because gh uses his credentials. The interim is a convention -- open each comment with a line saying a model wrote it (doctrine(an_agent_posting_to_github_says_so), AGENTS.md) -- and a convention is something an agent can forget. A separate identity makes attribution structural instead.\n\nTwo routes:\n\n1. GitHub App installed on the repo. Agents mint an installation token and export GH_TOKEN. Comments render as \u003cname\u003e[bot], the same shape as copilot-pull-request-reviewer[bot]. Scoped permissions, no seat consumed, revocable independently of the owner's account, and the bot suffix is not spoofable by a human account.\n\n2. Machine user with its own PAT as GH_TOKEN. Simpler to set up, consumes a seat on some plans, and is indistinguishable from a person at a glance -- which is most of what we are trying to fix.\n\nPrefer 1. Needs the user: create the App, grant contents+pull_requests+issues write, install it, and put the app id and private key where agents can reach them.\n\nWorth checking while doing it: whether the bot can be requested as a PR reviewer, which would let review agents post as reviews rather than as comments.\n\nOnce it lands, the attribution line becomes belt-and-braces rather than the mechanism, and the doctrine should be amended to say so rather than deleted -- a reader seeing an unattributed comment from the owner's account still needs to know it might be an agent.","notes":"DEFERRED by the user 2026-09-22. Recording the reasoning, because the status was\nset with none and the INTERIM IS LOAD-BEARING WHILE THIS SITS.\n\nWHY IT IS A FINE THING TO DEFER: route 1 needs the user to create a GitHub App,\ngrant it contents+pull_requests+issues write, install it, and put the app id and\nprivate key where agents can reach them. That is credential work only he can do,\nand nothing in the codebase is blocked on it.\n\nWHAT MUST STAY IN FORCE MEANWHILE, and this is the part that would be quietly\nlost: EVERY agent comment, review, PR body and issue still opens with a line\nnaming the model and that it is an agent posting in his session\n(doctrine(an_agent_posting_to_github_says_so), AGENTS.md). While this ticket is\ndeferred that convention is THE MECHANISM, not belt-and-braces -- an\nunattributed comment reads as the owner's own words to everyone who sees it.\nDeferring the structural fix therefore RAISES the cost of forgetting the\nconvention rather than lowering it.\n\nMeasured this session: the convention held across four merged PRs and every\nthread comment. It is not currently failing; it is simply the kind of thing that\nfails silently and only once someone notices.\n\nWHAT REOPENS IT:\n 1. The user creating the App -- the only real blocker.\n 2. Any instance of the convention actually being forgotten on a public\n artefact. One is enough: the failure mode is a human reading a model's\n words as his own, and it is not detectable after the fact by anything\n automated.\n 3. A second person gaining write access to the repo, which makes \"who said\n this\" ambiguous in a way the attribution line alone does not resolve.\n\nNOT A REASON TO REOPEN: more agents, or more PRs. Volume does not change the\nargument either way.\n\nNote the ticket's own closing paragraph still applies and should not be lost: when\nthis lands, the attribution doctrine is AMENDED rather than deleted, because a\nreader seeing an unattributed comment from the owner's account still needs to know\nit might be an agent.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Ed","owner":"el-github@elangley.org","created_at":"2026-09-20T23:04:19Z","created_by":"orchestrator","updated_at":"2026-09-25T07:19:43Z","started_at":"2026-09-25T07:19:43Z","lease_expires_at":"2026-09-25T07:24:43Z","heartbeat_at":"2026-09-25T07:19:43Z","dependencies":[{"issue_id":"improvise-xfsb","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-84wp","title":"Doctrine: a check that looks like the answer, answering a neighbouring question","description":"Ready-to-apply prose for `context/design-principles.md` §7. Filed rather than committed because PR #60 head `ec03f95` is byte-identical (tree `ec5d531...`) to a tree the reviewer fully tested, and a further commit would destroy that property and require a fresh harness run while `main` was moving several times an hour. The artefact is worth more preserved than rushed.\n\n---\n\n### A check that looks like the answer, answering a neighbouring question\n\nThe characteristic defect in this repo is not a wrong line; it is a check that is *adjacent* to the thing it names. Three instances landed in one campaign (PRs #59/#60), in three different layers, which is why it is worth stating as a shape rather than three gotchas:\n\n- **A vacuous assertion.** `set_panel_open_and_cursor` asserted `category_panel_open` was true after an effect set it true -- once the launch default supplied `true`, the effect could be replaced by a **no-op** and 1391 tests passed. It answered *is the flag true* where it meant *did the effect set it*.\n- **A vacuous repair of it.** The first fix folded `[open:false, open:true]` and asserted the final state -- which a reducer that folds nothing also satisfies. It answered *does the text mention the other direction* where it meant *does the writer move the flag both ways*. **Reading the diff is what failed**, and it failed because the vacuous version contained `open: false`.\n- **A clean auto-merge.** `git merge-tree` reporting no conflict answers *can git combine these texts*, where the question is *does the combination do what both sides meant*. Two PRs edited the same four files with no conflict and neither side CI-tested together.\n\nEach has the same remedy shape: **name the question you actually mean, then find an input that separates it from its neighbour.**\n\n- For an assertion: **assert the direction the default cannot supply.** Closing the panel discriminates; opening it proves nothing.\n- For a repair: inject a **one-directional** fault (`if open { set true }` -- can raise, never lower). A vacuous repair cannot see it, because a test that only asserts the `true` direction is satisfied by a writer that only ever writes `true`.\n- For a merge: **materialise it and run the gates**, and check that a count which should MOVE actually moved. Playwright going 16 -\u003e 17 is what showed the other side survived; \"no conflict\" and an unchanged total would both have been consistent with losing it.\n\nThis generalises the existing `doctrine(fold_tests_must_test_order_not_the_axiom)` (asserting `fold(prefix)+fold(rest) == fold(whole)` is the definition of a left fold, so it answers a neighbouring question too). What the campaign adds is that the pattern recurs in **test repairs** and in **merge hygiene**, not only in production code and not only in fold tests.\n\n---\n\nAcceptance: the section is in §7; `doctrine(fold_tests_must_test_order_not_the_axiom)` cross-references it or is subsumed by it. Worth considering a `kb.pl` doctrine clause, which is orchestrator-only.\n","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:38:23Z","created_by":"wave3-webui","updated_at":"2026-09-21T04:49:32Z","closed_at":"2026-09-21T04:49:32Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-qsmp","title":"App::cmd_context's panel-open propagator is untested: C cannot open a closed panel and nothing fails","description":"`App::cmd_context` (crates/improvise-command/src/ui/app.rs) propagates `self.view_state.category_panel_open` into the `CmdContext`. **Hardcode it to `true` and the entire workspace passes** — 1394 tests, nothing fails.\n\nThat matters because `registry.rs` reads it in three places for `toggle-panel-and-focus`s open-vs-close decision and the panel focus/cycle commands. With it stuck true, pressing `C` on a CLOSED category panel decides to *close* it rather than open it — the panel becomes unopenable by its own key — and no test notices.\n\n**Why it is invisible:** `cmd/mod.rs`s `test_helpers` sets `category_panel_open` explicitly on the context it builds, so no command test ever exercises the real propagator. The tests construct the value they then assert on.\n\n**This is PRE-EXISTING and was not caused by the launch-default ruling (PR #60).** That distinction was established by control rather than assumed: with the default inverted, this mutation fails *exactly the same two tests as inverting the default alone* and nothing more. So it was unguarded before the ruling too. The remedy is therefore \"a test at all\", not the precondition-stating fixture that fixed the two genuinely-disarmed tests (`set_panel_open_and_cursor`, `panel_commands_address_the_named_panel_only`).\n\n**The method that found it is worth more than the finding** and is written up in `context/test-evidence/clients.md` under \"How to know a launch default has stopped disarming things\": mutate every site that writes, propagates or reads the value; sites where nothing fails are suspects; then re-run each suspect with the default inverted to separate *vacuous* (fix the fixture) from *unguarded* (file it). Grep cannot do this — it finds sites that NAME the flag, not tests that merely start from a default-built state.\n\nAcceptance: a command-layer test that drives the real `App::cmd_context` rather than a hand-built context, such that `toggle-panel-and-focus` on a closed panel opens it; proved by hardcoding the propagator and watching it fail.\n\nSibling note: `improvise-wasm-client/src/render.rs` has the same propagator shape for `RenderModel`. Hardcoding THAT one true is improvise-wsaw reintroduced one layer up, and Rust sees nothing — only the Chromium harness catches it, at `contract.spec.js`s `toBeHidden`. Consequence worth keeping: a Rust-only run of the `test` CI job cannot see a reintroduced wsaw, which is the sharpest argument on file for the browser job being able to fail the build.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T21:23:53Z","created_by":"wave3-webui","updated_at":"2026-09-21T04:43:50Z","started_at":"2026-09-21T03:27:55Z","closed_at":"2026-09-21T04:43:50Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-5ray","title":"check-evidence ignores #### headings and every browser-test entry uses them","description":"Found by review of PR #65, 2026-09-20 (finding 4). Sibling of improvise-lyyc\nand improvise-r0qa, and the sharpest of the three because this one has already\nlet a false claim stand for a release.\n\nscripts/check-comment-runs.py --check-evidence collects headings with\n`s.startswith(\"### \")` and builds its `defined` set from rust_files(roots) only.\nTwo consequences compound:\n\n 1. A `####` heading is invisible to it — `\"### \"` does not match `\"#### \"`.\n 2. Even if it matched, `defined` holds only Rust `fn` names, so a JavaScript\n test name could never resolve.\n\ncontext/test-evidence/clients.md uses `#### \"quoted name\"` for every browser\ntest precisely BECAUSE of (2) — a `###` heading naming a Playwright test would\nbe reported as an orphan. So the convention that keeps the checker quiet is the\nsame convention that makes it blind.\n\nDEMONSTRATED: appending\n\n #### \"a test that does not exist anywhere\"\n\nto context/test-evidence/clients.md leaves `--check-evidence` exiting 0.\n\nWHAT IT COST: PR #61 deleted `a socket that opened and then dropped reads as\ndisconnected, and stays that way`. Its `####` heading stayed. The gate passed\nfor the whole release while the file promised a guard that was not there —\nexactly the \"dangling explanation\" design-principles §7 says the checker exists\nto prevent.\n\nFIX SHAPE: collect `###`/`####` alike, and extend `defined` with JS test names\n(`test(\"…\"` in harness/tests/*.spec.js and web/tests/*.test.js). Note the\nchecker's own --self-test must grow a case, or this fix is unguarded in exactly\nthe way the thing it fixes was.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T21:22:55Z","created_by":"wave3-jstests","updated_at":"2026-09-20T23:35:11Z","started_at":"2026-09-20T23:22:05Z","closed_at":"2026-09-20T23:35:11Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
@@ -506,6 +509,11 @@
|
||||
{"_type":"issue","id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-szi8","title":"Protocol version in the client/server handshake","description":"The user decided on PR #320 (ADR 005, question 9): \"yeah, accept and add a ticket for handshake version.\"\n\nProblem: the client/server protocol has no version handshake. A deployed browser page can talk to an improvise-ws-server built from a different commit, and nothing says so. ADR 005 changes how a cell value is spelled on the wire: exact values become strings such as \"12.50\" and \"10/3\", and inexact values become \"#i…\". Across such a version skew:\n- an old peer reading a new value refuses the whole frame, loudly;\n- a new peer reads an old peer's bare JSON number as inexact, silently. That old peer's typed 0.1 then compares unequal to every typed 0.1 on the new side, though both look identical.\n\nAsk: add a protocol version to the handshake (ClientFrame::Viewport / the Snapshot), and refuse or warn on a mismatch before any value crosses.\n\nAcceptance:\n- A mismatched peer is told so at the handshake, before any frame carrying values is exchanged.\n- A test pins both directions, old→new and new→old.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T17:16:53Z","created_by":"agent-adr005","updated_at":"2026-09-25T17:16:53Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-nn4s.12","title":"minibuffer.* inherits the status badge through mode, so a theme omitting a prompt face draws a badge; decide overlay-vs-replace before theme upload","description":"Found in the post-merge review of PR #319 (merged head 3583359a). Needs a decision\nbefore improvise-nn4s.7 (theme upload) lands, and possibly the user's.\n\nAfter Codex's P2 fix, `minibuffer.\u003centry\u003e` inherits `mode.text-entry`, which inherits\n`mode`, as ADR 004 says. But `mode` is the status-line badge: `Named(Black)` on\n`Named(BrightBlack)`. The prompt is accent-on-`Indexed(235)`, bold. So the shipped theme\nstates every colour/weight attribute on all seven minibuffer leaves, and the parent\ncontributes nothing but slant, underline and reverse.\n\nClause 9 promises that a theme may safely *omit* a face and inheritance supplies it.\nUnder a replace-semantics upload, a user theme that omits `minibuffer.formula` gets the\nbadge colours: black on dark grey, not bold. It does not get a prompt. Two readings fix it:\n- (a) an uploaded theme OVERLAYS `Theme::shipped()` face by face, so omission falls back\n to the shipped leaf. That is a code decision for nn4s.7, but `Theme` should then say so.\n- (b) the ADR chain is revisited, so the prompt and the badge stop sharing a parent.\n That is the user's call, and it is a published-name/parent change (clause 9).\n\nNot a defect today: the only theme is the shipped one, and it reproduces the literals.\nThe ask is that nn4s.7 not ship without choosing (a) or (b), and a test that an uploaded\ntheme omitting a minibuffer face still draws a prompt.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T17:09:04Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:09:04Z","labels":["adversarial-review","needs-user","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.12","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:09:04Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-nn4s.11","title":"Stale pointers to axis_fg/minibuf_fg/AxisColor after PR #319: web/style.css, repo-map, kb.pl","description":"Found in the post-merge review of PR #319 (merged head 3583359a).\n\nThese references still name what #319 deleted (`axis_fg`, `minibuf_fg`, `AxisColor`,\n`Axis::color`):\n- `web/style.css:677-679`, the comment over `#minibuffer.green`, says the class\n resolves \"exactly as `draw.rs::minibuf_fg` resolves it for the TUI\".\n- `context/repo-map.md` (orchestrator-owned): the How-to-find row \"Change an axis label,\n color or cycle\" (colors resolve in `draw.rs::axis_fg`), gotcha 26 (`color() -\u003e AxisColor`,\n `draw.rs::axis_fg`), and the File Inventory line for `view/axis.rs` (\"AxisColor\").\n `improvise-protocol/src/face.rs` has no inventory line.\n- `prolog-sessions/kb.pl` (orchestrator-owned, `scripts/kb` only):\n `location(axis_presentation, ...)`, which says the color/AxisColor live on Axis;\n `location(axis_color_resolution, ...)`, which says axis_fg is in draw.rs; and\n `invariant(semantic_palette_at_the_core_boundary, ...)`, whose precedents are\n MinibufColor -\u003e minibuf_fg.\n- `TAGS:1235` still lists `mode_style`. It is a generated file, so regenerate or ignore.\n\nAlso: improvise-nn4s.1 and improvise-nn4s.2 are still in_progress although #319 merged.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T17:09:02Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:09:02Z","labels":["adversarial-review","docs","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.11","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:09:02Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-nn4s.9","title":"every_face_reaches_the_terminal_with_every_attribute_it_states checks only BOLD and uses terminal_colour as its own oracle","description":"Found in the post-merge review of PR #319 (merged head 3583359a).\n\n`src/draw.rs::face_tests::every_face_reaches_the_terminal_with_every_attribute_it_states`\ndoes not test what its name says:\n- It checks fg and bg against `terminal_colour`, which is the function under test, so a\n wrong slot mapping cannot fail it. One example is `AnsiSlot::White =\u003e Color::White`,\n the ratatui naming trap `context/code/tui.md` warns about.\n- Of the four modifiers it checks only BOLD.\n- The shipped theme states no italic, underline or reverse, so\n `the_shipped_theme_reproduces_every_migrated_literal` cannot see them either.\n\nInjected fault: in `face_style`, map `Slant::Italic` to `Modifier::UNDERLINED` and\n`reverse` to `Modifier::DIM`. `cargo test --workspace --no-fail-fast` stays fully green.\nThis matters before improvise-nn4s.6, whose whole mechanism is `reverse`.\n\nFix: build a synthetic `ResolvedFace` per attribute, or a non-shipped `Theme` that\nstates each one, and assert the exact `Modifier` bit. Pin `terminal_colour` against a\nliteral table of the 16 slots (ANSI 7 = ratatui Gray, 8 = DarkGray, 15 = White) rather\nthan against itself.\n\nSame pass, a nit: `Face::of_mode`'s `.unwrap_or(Face::ModeTextEntry)` and\n`draw_bottom_bar`'s matching fallback are unreachable. Remove them, or make them\n`expect`, so a disagreement between `TextEntry::of` and `of_mode` fails loudly instead\nof falling back silently.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T17:08:58Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:08:58Z","labels":["adversarial-review","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.9","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:08:58Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-nn4s.8","title":"The wire half of ADR 004's category-add exception is unpinned: MinibufColor::Yellow on category_add passes the whole workspace","description":"Found in the post-merge review of PR #319 (merged head 3583359a).\n\nADR 004 clause 8's one declared exception has two halves. The TUI half is pinned by\n`the_category_add_prompt_draws_as_the_item_add_prompt_does`. The wire half is not.\n`AppMode::category_add` now sends `MinibufColor::Green`, and the browser's class comes\nfrom it (`web/app.js:311`).\n\nInjected fault: set `category_add`'s `color` in `crates/improvise-protocol/src/lib.rs`\nback to `MinibufColor::Yellow`. `cargo test --workspace --no-fail-fast` stays fully green.\nNo Playwright spec asserts the minibuffer class either. So the browser could go back to\nyellow while the TUI stays green, and nothing would notice.\n\nThere is a second hazard here. The TUI no longer reads `MinibufferConfig.color`: it\nderives the face from the mode variant. The browser still reads `color`. That means two\nderivations of one fact until improvise-nn4s.4 lands, and nothing holds them in step.\n\nFix, the smallest that discriminates: a protocol test asserting that for every\ntext-entry `AppMode`, `minibuffer().color` agrees with the shipped theme's\n`minibuffer.\u003centry\u003e` foreground. It must include category-add == item-add. It is deleted\nwith the field in nn4s.4.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T17:08:55Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:08:55Z","labels":["adversarial-review","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.8","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:08:55Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-9xy9","title":"Cargo.nix has no freshness gate: dev-dependency test-support edges drifted on main unnoticed, and the nix-build job builds only #improvise","description":"Found by the adversarial review of PR #318 (head 689480bf).\n\nOn gh/main (c91cc055), Cargo.nix had drifted from the Cargo.toml files, and nothing noticed. Five dependency edges ask for improvise-core's or improvise-protocol's `test-support` feature:\n- the root crate's improvise-protocol;\n- improvise-command's improvise-core and improvise-protocol;\n- improvise-wasm-client's improvise-protocol.\n\nMain's Cargo.nix mentions `test-support` three times. PR #318 regenerated it with crate2nix for unicase, and four more `test-support` edges appeared that had nothing to do with that PR.\n\nWhy CI misses it: the `nix-build` job runs only `nix build .#improvise`. That builds the non-test crate graph, so a missing dev-dependency feature edge never fails. A missing *normal* dependency does fail, which is why #318 had to regenerate for unicase. No job checks that Cargo.nix is what crate2nix would generate from the current Cargo.lock and manifests.\n\nFix: a CI step, or a checkers suite, that regenerates Cargo.nix and diffs it (`crate2nix generate` then `git diff --exit-code Cargo.nix`). Alternatively, build the crate2nix test derivations. Either way, inject a stale edge and watch the check fail.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:19:23Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:19:23Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-8jk6.17","title":"Headless import suite misses three boundary faults: every bad formula named, CSV float measures, :import-json on CSV","description":"Found in the adversarial review of PR #321 at efaa5050. The headless import suite (tests/headless_cli.rs) does not see three faults at the CLI boundary, although the user treats that boundary as a product and context/code/cli.md section 13 states its contract. Each fault below was injected alone, and the whole workspace suite was run:\n\n1. **Every bad formula is named.** cli.md section 13 says: \"Every bad formula is named on stderr\". The headless test passes one bad and one good formula, so naming only the first bad one (the author's rival R5) is caught only by the io unit test `a_formula_that_does_not_parse_stops_the_build_and_is_named`. The fix is cheap: pass two bad `--formula`s in `an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing`.\n2. **A CSV float measure.** With `csv_parser::number_in`'s f64 branch disabled (integers only), `1000.50` and similar measures are silently dropped. Only io unit tests failed (4 csv_parser tests and one wizard test). No headless or root integration test imports a CSV with a decimal measure and checks the value.\n3. **`:import-json` / ModelCommand::ImportJsonHeadless on a CSV.** With its CSV branch changed back to `ImportPipeline::new(Value::Array(recs.to_vec()))`, the JSON constructor, only `import_headless_refuses_records_that_collide` failed, and it failed only incidentally, because Revenue stopped being a measure. No test for this path asserts that a numeric CSV column is proposed as a Measure, or that a category's text is kept verbatim.\n\nThe wizard-only rivals R6 and R8 have no headless surface, so a unit test is the right level for them. They are not part of this ticket.\n","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T06:15:00Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:20:11Z","started_at":"2026-09-25T06:20:11Z","lease_expires_at":"2026-09-25T06:25:11Z","heartbeat_at":"2026-09-25T06:20:11Z","labels":["headless","import","tests"],"dependencies":[{"issue_id":"improvise-8jk6.17","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T06:15:00Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-tpm8","title":"import --extract on a field with no detected date format is silently ignored and exits 0","description":"`improvise import --no-wizard --extract F:Year` on a field F with no detected date format does nothing and exits 0 without a word. Examples are a years-only column (`2023,2024,2025`, forced with `--time`), or a column where one value fails to parse.\n\nFound by the adversarial review of PR #322 at 6b910889 (years.csv, `--time Year --extract Year:Year --extract Year:Month`). The output was one flat category of the raw years, and there was no warning. That result is fine; the silence is not, because the user asked for a component and got none. The behaviour is not new with #322. Sibling of improvise-31oe.2 and improvise-8jk6.5 (other silent headless no-ops).\n\nExpected: a warning naming the field and saying no date format was detected, so the extraction was not applied. It could also be an error, matching how 8jk6.5 is resolved.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:25Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-tpm8","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
@@ -548,7 +556,7 @@
|
||||
{"_type":"issue","id":"improvise-j4no","title":"TUI draw panics on a terminal 0 or 1 row tall (index outside of buffer)","description":"Found reviewing PR #303 (improvise-gtn7.1), pre-existing and identical at gh/main ae5cf408: rendering draw() into a TestBackend of height 0 (any width \u003e=1) or height 1 (width \u003e=3), with or without side panels, panics in ratatui-core buffer.rs:251 'index outside of buffer: the area is Rect { .. height: 0 } but index is (0, 0)'. A real terminal can be resized that small; the TUI loop would crash. Probe: loop w 0..=120, h in {0,1,3,5,24}, all 8 panel masks, press Right x12 and draw; 1904 failing combos at both commits, all at h\u003c=1. Fix: find the widget that writes without checking its Rect (likely a bar or the welcome/grid block) and guard, then pin with that sweep.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:40:30Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:40:30Z","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-j7ij","title":"No test notices a side panel that is open but never drawn: App::side_panel_open can drop a flag with the suite green","description":"Found reviewing PR #303 (improvise-gtn7.1). Injected: App::side_panel_open returns category||view, omitting formula_panel_open. cargo test -p improvise -p improvise-command stays green. Effect: the formula panel is toggled open (FormulaPanel mode reachable) but draw_content skips the whole side column, so the panel is invisible; grid and keypress path agree on full width, so gtn7.1's test cannot see it. Same hole pre-dates the PR (draw_content computed the OR inline). Fix shape: a draw-level assertion that each open panel's title/border appears in the frame, one panel at a time AND with all three open, asserting the direction the launch default (category open) cannot supply.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:40:26Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:40:26Z","labels":["tests","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-felo","title":"Epic: Core model, layout and formula evaluation","description":"improvise-core and improvise-formula: Model and DataStore correctness (07i0, nwgk, vwc7, 98pd), GridLayout prune and marker logic and its docs (dw2d), formula evaluation performance (9iyv), and ADR 005's exact-decimal arithmetic (l2b8). Out of scope: stable ids (the ADR 007 epic), .improv reading and writing (the import/export epic), and records mode and drill (their own epic).","status":"open","priority":3,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:12Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:12Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-hexb","title":"ADR 007 clause 12: the regression test commits UNEDITED text (a no-op commit passes it), and which side holds the captured mapping is unstated","description":"Found by the delta review of PR #210 (ADR 007, `4830fa30..fcb8f539`). None of this blocks. Clause 12 resolves the contradiction it was written for. These are details the implementer of clause 12 will hit.\n\n1. **The required regression test can pass without exercising the mapping.** It says \"Commit the unedited text\". A host or client that treats an unedited commit as a no-op (the text equals what was shown, so send nothing, or `raw == was`, so write nothing) passes both assertions without resolving any name. That is the \"a rival that fails the setup\" vacuity shape. The test should change something the user typed that is not a name, for example `Revenue - Cost` → `Revenue - Cost * 1` (Codex's original scenario: \"edit only the constant/operator\"). Then assert that the stored formula references the original item's id.\n\n2. **Which side holds the captured mapping, and what `ReplaceFormula.raw` carries on the wire, is unstated.** Clause 12 cites `OpenedCell` (client-side, in `improvise-protocol/src/client.rs`) as the model. But it also says a name not in the mapping \"is resolved against the document at commit\", which only the host can do. So either:\n - `raw` travels as id-form text for the captured names and display text for new ones (a mixed rendering, which needs a rule for telling them apart, even though ids are globally unique); or\n - the mapping travels with the command; or\n - the host keeps a per-session open-editor record.\n\n The TUI (host-local) and the browser (remote) must reach the same answer. This overlaps improvise-m56i item 6 (resolving typed names at the panel edge).\n\n3. **Clause 11 (Migration)** does not list clause 12's capture step. The Consequences bullet \"the host must resolve display names typed there back to ids\" now describes only the names that are new to the edit.\n\n4. **Minor factual drift in the revised Context paragraph.** \"The only reader is one test\" is wrong: `category.rs`'s `add_item_returns_sequential_ids` and `add_item_duplicate_returns_same_id`, and `types.rs:1385`, all read the ids. `Category::add_item` returns the id on every call, not only on the idempotent path. The claim that matters, that no production caller uses the value, holds: `parse.rs:389` discards it.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T00:21:24Z","created_by":"agent-review-210","updated_at":"2026-09-25T00:21:24Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-hexb","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:53Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-hexb","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-25T00:21:24Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"7834165f-01be-5178-97e7-d15d6c21a9fc","issue_id":"improvise-hexb","author":"Edward Langley","text":"Second copy of item 1 (review of PR #302 at 95d4814b, Claude agent): prolog-sessions/kb.pl decision('improvise-sboz', a_formula_edit_maps_display_names_to_ids_when_the_editor_opens, ...) restates clause 12's required regression test verbatim, including 'commit unedited'. When this ticket amends the test (edit a non-name token, e.g. Revenue - Cost -\u003e Revenue - Cost * 1), that kb fact must be updated in the same change via scripts/kb replace, or the kb keeps the vacuous form after the ADR is fixed.","created_at":"2026-09-25T00:51:28Z"},{"id":"e17020bb-539a-562f-8101-9f0b977ec4fd","issue_id":"improvise-hexb","author":"agent-adr007","text":"agent-adr007 planning. Items 1, 3 and 4 go in the text fix improvise-cq1q.1. Item 2 is a USER QUESTION and blocks improvise-cq1q.9. Recommendation: the client holds the capture, as it holds OpenedCell, and resolves typed names against its own CategoryTreeCache, which clause 6 gives ids. The wire then carries only id-form text, and the host never resolves a display name. The TUI and the browser share the resolver in improvise-protocol.","created_at":"2026-09-25T04:51:34Z"}],"dependency_count":0,"dependent_count":1,"comment_count":2}
|
||||
{"_type":"issue","id":"improvise-hexb","title":"ADR 007 clause 12: the regression test commits UNEDITED text (a no-op commit passes it), and which side holds the captured mapping is unstated","description":"Found by the delta review of PR #210 (ADR 007, `4830fa30..fcb8f539`). None of this blocks. Clause 12 resolves the contradiction it was written for. These are details the implementer of clause 12 will hit.\n\n1. **The required regression test can pass without exercising the mapping.** It says \"Commit the unedited text\". A host or client that treats an unedited commit as a no-op (the text equals what was shown, so send nothing, or `raw == was`, so write nothing) passes both assertions without resolving any name. That is the \"a rival that fails the setup\" vacuity shape. The test should change something the user typed that is not a name, for example `Revenue - Cost` → `Revenue - Cost * 1` (Codex's original scenario: \"edit only the constant/operator\"). Then assert that the stored formula references the original item's id.\n\n2. **Which side holds the captured mapping, and what `ReplaceFormula.raw` carries on the wire, is unstated.** Clause 12 cites `OpenedCell` (client-side, in `improvise-protocol/src/client.rs`) as the model. But it also says a name not in the mapping \"is resolved against the document at commit\", which only the host can do. So either:\n - `raw` travels as id-form text for the captured names and display text for new ones (a mixed rendering, which needs a rule for telling them apart, even though ids are globally unique); or\n - the mapping travels with the command; or\n - the host keeps a per-session open-editor record.\n\n The TUI (host-local) and the browser (remote) must reach the same answer. This overlaps improvise-m56i item 6 (resolving typed names at the panel edge).\n\n3. **Clause 11 (Migration)** does not list clause 12's capture step. The Consequences bullet \"the host must resolve display names typed there back to ids\" now describes only the names that are new to the edit.\n\n4. **Minor factual drift in the revised Context paragraph.** \"The only reader is one test\" is wrong: `category.rs`'s `add_item_returns_sequential_ids` and `add_item_duplicate_returns_same_id`, and `types.rs:1385`, all read the ids. `Category::add_item` returns the id on every call, not only on the idempotent path. The claim that matters, that no production caller uses the value, holds: `parse.rs:389` discards it.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T00:21:24Z","created_by":"agent-review-210","updated_at":"2026-09-25T00:21:24Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-hexb","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:53Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-hexb","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-25T00:21:24Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"7834165f-01be-5178-97e7-d15d6c21a9fc","issue_id":"improvise-hexb","author":"Edward Langley","text":"Second copy of item 1 (review of PR #302 at 95d4814b, Claude agent): prolog-sessions/kb.pl decision('improvise-sboz', a_formula_edit_maps_display_names_to_ids_when_the_editor_opens, ...) restates clause 12's required regression test verbatim, including 'commit unedited'. When this ticket amends the test (edit a non-name token, e.g. Revenue - Cost -\u003e Revenue - Cost * 1), that kb fact must be updated in the same change via scripts/kb replace, or the kb keeps the vacuous form after the ADR is fixed.","created_at":"2026-09-25T00:51:28Z"},{"id":"e17020bb-539a-562f-8101-9f0b977ec4fd","issue_id":"improvise-hexb","author":"agent-adr007","text":"agent-adr007 planning. Items 1, 3 and 4 go in the text fix improvise-cq1q.1. Item 2 is a USER QUESTION and blocks improvise-cq1q.9. Recommendation: the client holds the capture, as it holds OpenedCell, and resolves typed names against its own CategoryTreeCache, which clause 6 gives ids. The wire then carries only id-form text, and the host never resolves a display name. The TUI and the browser share the resolver in improvise-protocol.","created_at":"2026-09-25T04:51:34Z"},{"id":"2849c32c-59f4-5d6e-b1cb-ef08af4cc8c6","issue_id":"improvise-hexb","author":"Edward Langley","text":"USER DECISION 2026-09-25 (item 2): the CLIENT holds clause 12's display-\u003eid capture (like OpenedCell) and resolves names against its id-bearing projection; the wire carries only id-form text; the host never resolves a display name.","created_at":"2026-09-25T17:51:49Z"}],"dependency_count":0,"dependent_count":1,"comment_count":3}
|
||||
{"_type":"issue","id":"improvise-8vs2","title":"ADR 007 states stale facts about main: fdk1's RenameRecordCoordinate has landed, CellId::Coordinate exists, wpxr (#281) is merged","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30). The ADR's text about the current code is stale against its own base, gh/main ad217cc1:\n\n1. **improvise-fdk1 has landed** (PR #221, ticket closed 2026-09-24). `ModelCommand::RenameRecordCoordinate { record: RecordHash, category: String, item: String }` exists in `improvise-protocol/src/reduce.rs`. Yet the ADR says:\n - its header, \"Unblocks: improvise-fdk1\";\n - Context, \"A records-mode coordinate rename has no wire form (improvise-fdk1)\";\n - \"What this means for improvise-fdk1\", which proposes the operation as new (`to: ItemRef`).\n\n The section should instead revise the landed variant's operands to `CategoryId` / `ItemRef`, as it does for km71's `FormulaId`.\n2. **`CellId::Coordinate { record, category }` already exists** (render.rs). \"`CellId::Coordinate` grows `{ record, category: CategoryId }`\" should read \"its `category` becomes a `CategoryId`\".\n3. **improvise-wpxr (PR #281) has merged**, and the ADR still calls it \"open\": \"If improvise-wpxr (PR #281, open) lands first\". `SetCell { view, cell, value }` and `ClearCell { view, cell }` are on main, so they are simply on the list.\n4. Minor: \"No production code reads either value\" (`Category.id` / `Item.id`) is slightly overstated. `Model::add_category` and `Category::add_item` return them on the idempotent path. No caller uses the value, which is the claim that matters.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:31Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:31Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-8vs2","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:53Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-8vs2","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:31Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"5a61964c-18a7-5653-95b4-eb53e4685795","issue_id":"improvise-8vs2","author":"agent-adr007","text":"agent-adr007 planning: revision 8 fixed items 1 to 3. Item 4, the 'idempotent path' wording, remains and is folded into improvise-cq1q.1.","created_at":"2026-09-25T04:51:22Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
||||
{"_type":"issue","id":"improvise-p003","title":"ADR 007 namespace gaps: _Records/_Drill ids, _Index/_Dim items under global uniqueness, active_view, collapsed: by group name, CheckedBatch's create exemption","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30), against gh/main ad217cc1. These are places where the id namespace and the list of re-keyed state are incomplete:\n\n1. **Virtual views.** `view::VIRTUAL_VIEW_NAMES` is `[\"_Records\", \"_Drill\"]`. Answer 23 reserves `_` for virtual things, but clause 1 reserves only `_index`, `_dim` and `_measure`. Slugify's step 4 turns `_Drill` into `drill`, which collides with a user view named `Drill` or takes a gensym suffix. So a virtual view's id is neither reserved nor stable. They are never persisted, but they are addressed on the wire (`SwitchView`, and `_Drill` in improvise-obtu/t219).\n2. **`_Index` and `_Dim` items.** These are synthetic, such as record indices, and `_Dim` carries a column index (gotcha 1). Answer 22 makes ids unique across every kind. The ADR does not say whether these items take part. If they do, `_Index`'s `0` collides with a user item whose id is `0` (for example a display `0`).\n3. **`ViewState::active_view` and `Workbook::active_view`** are view names (`String`). Clause 6 lists cursors, expanded_cats and the view history as keyed by id, but not the active view itself.\n4. **`collapsed:` names a group.** `collapsed_line = \"collapsed: \" name \"/\" name` is category/group. Groups have no id (answer 9), but clause 2 says \"hidden and collapsed sets are keyed by ids\", and clause 5 lists `collapsed:` among references that carry the id. Only `[Coastal]` is exempted. So the collapsed set is (CategoryId, group name).\n5. **CheckedBatch's created-record exemption** (gotcha 43: a record that neither resolves nor is claimed, for `[AddItem(East), SetCell(East)]`). Clause 6 says a frame cannot address what it creates, so that exemption has no remaining producer. It should be removed or re-argued with the id vocabulary, not left as dead reasoning.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:31Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:31Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-p003","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:55Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-p003","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:31Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"26a29b15-8906-5014-a8cb-1725955f0b89","issue_id":"improvise-p003","author":"agent-adr007","text":"agent-adr007 planning: all five items are readings for improvise-cq1q.1. Item 2 (_Index/_Dim coordinates are positions outside the id namespace) is the ADR's reading and should be flagged to the user on that PR. Item 5 is implemented in improvise-cq1q.8.","created_at":"2026-09-25T04:51:36Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
||||
{"_type":"issue","id":"improvise-3d64","title":"renamed_away() says 'the row you were editing has changed' when only the edited cell's measure went (Opened::Gone on a value target)","description":"Codex on #274 (discussion_r4090469430), repeated on integration PR #299 at 8dd39fd5. Opened::Gone is also produced for a value target when the same record remains but the edited measure disappears or changes, yet resolve.rs renamed_away() / RECORD_RENAMED_AWAY says the ROW changed. Use target-neutral wording (the cell you were editing), and pin it with a test that reaches Gone through a measure change on a surviving record.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T19:35:31Z","created_by":"agent-train2","updated_at":"2026-09-24T19:35:31Z","labels":["browser","pr-274"],"dependencies":[{"issue_id":"improvise-3d64","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
@@ -881,7 +889,7 @@
|
||||
{"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:07:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-1xfb.5.2","title":"Bookkeeping nits from the PR #311 review","description":"Non-blocking findings from the #311 review (issuecomment-5826314337), for the next bookkeeping pass: (1) kb decision scroll_measured_where_drawn cites a tui.md heading imprecisely -- real heading is 'The grid's width is one answer, and a scroll is measured where it is drawn'; (2) repo-map calls the pre-#303 tile-bar code first_tile, a name #303 introduced; (3) category_cap_has_one_home says the literal 24 appears only in add_category_max_limit -- 'the only test' is exact; (4) two user quotes in kb clauses lack quotation marks and drop the final period; (5) three rewritten inventory rows keep stale line counts: import/wizard.rs 1967-\u003e2730, import_wizard_ui.rs 829-\u003e1316, draw.rs 1122-\u003e1189.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T03:44:37Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:44:37Z","labels":["docs","kb","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.5.2","depends_on_id":"improvise-1xfb.5","type":"parent-child","created_at":"2026-09-25T03:44:37Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"a6c01ac1-0730-5e63-8bb6-54a42a2b1046","issue_id":"improvise-1xfb.5.2","author":"Edward Langley","text":"Also for the next bookkeeping pass (PR #315 review, issuecomment-5827266982): (6) repo-map gotcha 45 is stale -- apply_effects raises dirty outright only for WholeWorkbook; for CellData/LayoutGeometry only when observed content/config changed (app.rs:1025-1052); the 'omitting it is unrepresentable' claim needs rewording. (7) repo-map Wasm section cites improvise-fuj (closed) for 'the two effects that still bypass Storage'; the live bypasses are autosave and the headless writes, improvise-31oe.11, and neither is an effect. (8) PR #314's proposed repo-map lines (folder import row, gotcha 27 Filter now persisted in per-file views, gotcha 7 sparse records) and kb facts. (9) PR #317's proposed gotcha 44 rewording and new gotcha 51 once it merges.","created_at":"2026-09-25T05:24:21Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
||||
{"_type":"issue","id":"improvise-1xfb.5.2","title":"Bookkeeping nits from the PR #311 review","description":"Non-blocking findings from the #311 review (issuecomment-5826314337), for the next bookkeeping pass: (1) kb decision scroll_measured_where_drawn cites a tui.md heading imprecisely -- real heading is 'The grid's width is one answer, and a scroll is measured where it is drawn'; (2) repo-map calls the pre-#303 tile-bar code first_tile, a name #303 introduced; (3) category_cap_has_one_home says the literal 24 appears only in add_category_max_limit -- 'the only test' is exact; (4) two user quotes in kb clauses lack quotation marks and drop the final period; (5) three rewritten inventory rows keep stale line counts: import/wizard.rs 1967-\u003e2730, import_wizard_ui.rs 829-\u003e1316, draw.rs 1122-\u003e1189.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T03:44:37Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:44:37Z","labels":["docs","kb","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.5.2","depends_on_id":"improvise-1xfb.5","type":"parent-child","created_at":"2026-09-25T03:44:37Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"a6c01ac1-0730-5e63-8bb6-54a42a2b1046","issue_id":"improvise-1xfb.5.2","author":"Edward Langley","text":"Also for the next bookkeeping pass (PR #315 review, issuecomment-5827266982): (6) repo-map gotcha 45 is stale -- apply_effects raises dirty outright only for WholeWorkbook; for CellData/LayoutGeometry only when observed content/config changed (app.rs:1025-1052); the 'omitting it is unrepresentable' claim needs rewording. (7) repo-map Wasm section cites improvise-fuj (closed) for 'the two effects that still bypass Storage'; the live bypasses are autosave and the headless writes, improvise-31oe.11, and neither is an effect. (8) PR #314's proposed repo-map lines (folder import row, gotcha 27 Filter now persisted in per-file views, gotcha 7 sparse records) and kb facts. (9) PR #317's proposed gotcha 44 rewording and new gotcha 51 once it merges.","created_at":"2026-09-25T05:24:21Z"},{"id":"735d27bf-0c67-587e-8965-12b1a4495c1a","issue_id":"improvise-1xfb.5.2","author":"Edward Langley","text":"Also for the next bookkeeping pass: ADR 004 step 1-2 (PR #319) made stale repo-map entries -- the 'Change an axis label, color or cycle' row, gotcha 26 (axis colour now Face::Axis in improvise-protocol/src/face.rs via draw.rs::face_style), the view/axis.rs inventory line, and face.rs needs an inventory line; kb facts location(axis_presentation), location(axis_color_resolution), invariant(semantic_palette_at_the_core_boundary) cite removed names (improvise-nn4s.11).","created_at":"2026-09-25T17:10:22Z"}],"dependency_count":0,"dependent_count":0,"comment_count":2}
|
||||
{"_type":"issue","id":"improvise-loqe","title":"improvise import --on-duplicate is silently ignored without --no-wizard","description":"Found reviewing PR #304 (improvise-cgqt) at 92233cff. --on-duplicate is documented as applying with --no-wizard, but clap accepts it on a wizard import too, where run_wizard_import ignores the whole ImportConfig (a pre-existing TODO). A user passing --on-duplicate sum without --no-wizard is asked anyway, with no warning that the flag did nothing. Either reject the flag without --no-wizard (clap requires) or pre-seed the wizard's policy.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:55:40Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:55:40Z","labels":["adversarial-review","cli","import"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-hkhw","title":"ClientState::bootstrap keeps status_seen/status_owner, so a snapshot with the same status text and an equal EditorSession inherits the old document's status ownership","description":"Codex P2 on #298, originally on #279 (https://github.com/fiddlerwoaroof/improvise/pull/279#discussion_r4090596331). bootstrap (crates/improvise-protocol/src/client.rs) resets every cache and in_flight, but it leaves status_seen and status_owner (improvise-1x9h) alone before calling settle_status. When the snapshot's status_msg equals status_seen and EditorSession::of(view) equals the old owner (same mode, same selected cell, same seed), the ownership survives the rebase. status_stands then keeps a toast standing that should expire. The effect is cosmetic: only a toast's lifetime is wrong. Fix: clear both fields in bootstrap before settling. Regression: equal text and an equal EditorSession across a bootstrap; the default state satisfies the other cases.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T19:05:35Z","created_by":"agent-review-298","updated_at":"2026-09-24T19:05:35Z","labels":["adversarial-review","browser","pr-279","pr-298"],"dependencies":[{"issue_id":"improvise-hkhw","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:42Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"improvise-dw2d","title":"core.md and walk_open_runs' doc call it 'the one reading of the markers', but prune_axis_items reads them a second time through open_at","description":"Found reviewing PR #250 at cfa8bf1e (improvise-e265). context/code/core.md (e265 section) and the doc comment on layout.rs walk_open_runs say it is 'the one reading of the markers'. prune_axis_items keeps its own reading: its input loop applies the same truncate/resize rules through open_at (OpenRuns), because it needs the header entries it moves, not only their positions. So core has two readers of the AxisEntry marker rules (three counting AxisFrame::groups on the wire type). Not a bug today: the enumeration judges prune's output through walk_open_runs, so a drift between the two fails pruning_keeps_every_surviving_rows_runs_at_every_level. Fix either the claim (the one reading for lookups; the pruner tracks its input by the same rules and the enumeration holds them together) or the code (prune reads via walk_open_runs over the undrained slice and clones items[at]).","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T06:53:15Z","created_by":"agent-review-250","updated_at":"2026-09-24T06:53:15Z","dependencies":[{"issue_id":"improvise-dw2d","depends_on_id":"improvise-e265","type":"discovered-from","created_at":"2026-09-24T06:53:21Z","created_by":"agent-review-250","metadata":"{}"},{"issue_id":"improvise-dw2d","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:20Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
||||
+46
-257
@@ -1,273 +1,62 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Prepare a Claude Code on the web container for work on this repository.
|
||||
#
|
||||
# Section 0 runs everywhere, cloud or not: it asserts that the `bd` on PATH
|
||||
# is the one flake.nix pins. Everything after it is cloud-only setup.
|
||||
# This hook delegates to the shared `scripts/agent-bootstrap.sh`, which works
|
||||
# for any coding agent stack (Claude Code, Cursor, etc.). This file exists
|
||||
# because Claude Code expects hooks at `.claude/hooks/session-start.sh`.
|
||||
#
|
||||
# Three things are slow or wrong by default in a fresh cloud session:
|
||||
# For local checkouts, only the bd pin assertion runs.
|
||||
# For cloud sessions (CLAUDE_CODE_REMOTE=true), full bootstrap runs.
|
||||
#
|
||||
# 1. `bd` is not installed, so the `bd prime` hook and every `bd` command
|
||||
# in AGENTS.md silently do nothing.
|
||||
# 2. `.beads/issues.jsonl` is a stale export. An agent that reads it picks
|
||||
# work that is already done — this has happened. The authoritative
|
||||
# tracker is the Dolt database on the git remote.
|
||||
# 3. The Rust workspace is uncompiled, so the first `cargo test` pays a
|
||||
# cold build before it reports anything.
|
||||
#
|
||||
# The tracker is re-synced on every session, not just when the database is
|
||||
# missing: cloud containers reuse a cached workspace, so "already present"
|
||||
# does not mean "still current", and a silently ageing tracker is the exact
|
||||
# failure this hook exists to prevent. Installing bd and warming the build
|
||||
# do check first, being genuinely one-time.
|
||||
#
|
||||
# Nothing here writes to a tracked file, stages anything, or creates a commit.
|
||||
# See also: scripts/agent-bootstrap.sh (the stack-agnostic implementation)
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||
cd "$ROOT" || exit 0
|
||||
BOOTSTRAP="$ROOT/scripts/agent-bootstrap.sh"
|
||||
|
||||
log() { printf '[session-start] %s\n' "$*"; }
|
||||
|
||||
# ── 0. The bd on PATH must be the bd flake.nix pins ────────────────────────
|
||||
# `flake.nix` pins beads so that one build of `bd` touches this repo. A git
|
||||
# worktree does not load that pin — a fresh `.envrc` is unallowed, so a
|
||||
# process there falls through to whatever `bd` the user's profile happens to
|
||||
# carry. Today those agree; nothing enforces that they keep agreeing.
|
||||
#
|
||||
# The failure that matters is silent: writing .beads/issues.jsonl with a
|
||||
# skewed build empties the dependency graph and reports success. Nobody
|
||||
# notices until they ask what blocks what. So the mismatch is turned into a
|
||||
# loud one here rather than trusted. Reading with a skewed bd is safe — it
|
||||
# is the mutating commands that corrupt — so this blocks nothing, it shouts.
|
||||
#
|
||||
# The expected version is read from flake.nix, not written down twice: a
|
||||
# hard-coded copy would go stale the first time the pin moves, and a stale
|
||||
# assertion is worse than none.
|
||||
beads_pin() {
|
||||
sed -n 's|.*beads\.url[[:space:]]*=[[:space:]]*"github:[^/]*/[^/]*/v\{0,1\}\([^"]*\)".*|\1|p' \
|
||||
flake.nix 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
bd_running_version() {
|
||||
bd version 2>/dev/null | sed -n 's/^bd version \([0-9][0-9.]*\).*/\1/p' | head -1
|
||||
}
|
||||
|
||||
bd_pin_banner() {
|
||||
{
|
||||
printf '\n[session-start] ===== BEADS VERSION SKEW =====\n'
|
||||
printf '[session-start] %s\n' "$@"
|
||||
printf '[session-start] =============================\n\n'
|
||||
} >&2
|
||||
}
|
||||
|
||||
# Prints the banner and returns 1 when the two disagree, or when either one
|
||||
# cannot be read — an unreadable answer is a rotted assertion, not a pass.
|
||||
assert_bd_pin() {
|
||||
command -v bd >/dev/null 2>&1 || return 0 # nothing on PATH to assert about
|
||||
|
||||
pinned=$(beads_pin)
|
||||
running=$(bd_running_version)
|
||||
|
||||
case "$pinned" in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*)
|
||||
bd_pin_banner "cannot read the beads pin out of flake.nix" \
|
||||
"read: '${pinned:-<nothing>}'" \
|
||||
"The pin moved to a ref this hook cannot parse (a branch or a" \
|
||||
"commit, perhaps). Teach beads_pin() to read it, or the check" \
|
||||
"silently protects nothing."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$running" ]; then
|
||||
bd_pin_banner "cannot read the version of the bd on PATH" \
|
||||
"bd: $(command -v bd)" \
|
||||
"'bd version' did not answer in the expected shape. Until it does," \
|
||||
"nothing here can tell whether that build matches the pin."
|
||||
return 1
|
||||
fi
|
||||
|
||||
[ "$running" = "$pinned" ] && return 0
|
||||
|
||||
bd_pin_banner "the bd on PATH is NOT the bd this repo pins" \
|
||||
"pinned (flake.nix): $pinned" \
|
||||
"running: $running ($(command -v bd))" \
|
||||
"" \
|
||||
"Reading is safe. Do NOT run a mutating bd command — create, update," \
|
||||
"close, dep, dolt push. Version skew between builds empties the" \
|
||||
"dependency graph in .beads/issues.jsonl and reports success." \
|
||||
"" \
|
||||
"Fix now: nix develop (in this directory), then re-run the" \
|
||||
" command; or 'direnv allow' here." \
|
||||
"Fix for good: worktrees are created fresh per dispatch, so allowing" \
|
||||
" them one at a time never catches the next one. Put the" \
|
||||
" repo root under [whitelist] prefix in" \
|
||||
" ~/.config/direnv/direnv.toml."
|
||||
return 1
|
||||
}
|
||||
|
||||
pin_status=0
|
||||
assert_bd_pin || pin_status=2
|
||||
|
||||
# Local checkouts have their own setup; this is for the web containers.
|
||||
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
|
||||
exit "$pin_status"
|
||||
fi
|
||||
|
||||
# Install a verified database at .beads/embeddeddolt.
|
||||
#
|
||||
# The staged copy lives on another filesystem, so it is brought alongside the
|
||||
# live one FIRST. Every step after that is a same-filesystem rename, which
|
||||
# either happens or does not — no half-finished copy. The previous database is
|
||||
# removed only once the new one is in place, so no failure path leaves the repo
|
||||
# without a tracker, and every move is checked: an unchecked one would let the
|
||||
# new database land *inside* the old directory and still report success.
|
||||
install_tracker() {
|
||||
staged=$1
|
||||
live=.beads/embeddeddolt
|
||||
incoming=$live.incoming
|
||||
previous=$live.previous
|
||||
|
||||
rm -rf "$incoming" "$previous" || return 1
|
||||
cp -rf "$staged" "$incoming" || { rm -rf "$incoming"; return 1; }
|
||||
|
||||
if [ -d "$live" ] && ! mv "$live" "$previous"; then
|
||||
rm -rf "$incoming"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if mv "$incoming" "$live"; then
|
||||
rm -rf "$previous"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Could not put the new one in place; restore what was there.
|
||||
if [ -d "$previous" ] && ! mv "$previous" "$live"; then
|
||||
log "ERROR: tracker left at $previous — move it back to $live by hand"
|
||||
fi
|
||||
rm -rf "$incoming"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── 1. bd on PATH ──────────────────────────────────────────────────────────
|
||||
# The embedded Dolt engine needs CGO, so this builds from source (~3 min).
|
||||
# The container image is cached after the hook, so it is paid once.
|
||||
export PATH="$PATH:/root/go/bin:$HOME/go/bin"
|
||||
# Pinned to a commit rather than `main`, and downloaded before it is run:
|
||||
# piping a moving URL straight into a shell executes code nobody has seen,
|
||||
# and leaves nothing to inspect afterwards. The installer stays at
|
||||
# /tmp/bd-install.sh so what actually ran can be read.
|
||||
#
|
||||
# To take a newer beads, resolve the peeled commit of its tag:
|
||||
# git ls-remote --tags https://github.com/steveyegge/beads | grep 'v1.3.0^{}'
|
||||
BD_INSTALLER_REF=f45b249ce6b40ba62aecc03949e6371e8f7c79d8 # tag v1.3.0
|
||||
BD_INSTALLER_URL="https://raw.githubusercontent.com/steveyegge/beads/$BD_INSTALLER_REF/scripts/install.sh"
|
||||
|
||||
if ! command -v bd >/dev/null 2>&1; then
|
||||
log "installing bd (beads v1.3.0)…"
|
||||
if ! curl -fsSL "$BD_INSTALLER_URL" -o /tmp/bd-install.sh 2>/tmp/bd-install.log; then
|
||||
log "WARNING: could not download the bd installer; see /tmp/bd-install.log."
|
||||
elif bash /tmp/bd-install.sh >>/tmp/bd-install.log 2>&1; then
|
||||
log "bd installed"
|
||||
else
|
||||
log "WARNING: bd install failed; see /tmp/bd-install.log (the script that"
|
||||
log " ran is at /tmp/bd-install.sh). Issue tracking unavailable."
|
||||
fi
|
||||
fi
|
||||
# Forward the Claude env file variable to the generic name the bootstrap uses
|
||||
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
|
||||
# Single quotes are deliberate: $PATH and $HOME must be written literally so
|
||||
# they expand when the session sources this, not now.
|
||||
# shellcheck disable=SC2016
|
||||
echo 'export PATH="$PATH:/root/go/bin:$HOME/go/bin"' >> "$CLAUDE_ENV_FILE"
|
||||
fi
|
||||
command -v bd >/dev/null 2>&1 && bd metrics off >/dev/null 2>&1
|
||||
|
||||
# bd may have arrived only just now, in which case §0 had nothing to check.
|
||||
# Re-check, but only if §0 stayed quiet, so the banner is never printed twice.
|
||||
if [ "$pin_status" -eq 0 ]; then
|
||||
assert_bd_pin || pin_status=2
|
||||
export AGENT_ENV_FILE="$CLAUDE_ENV_FILE"
|
||||
fi
|
||||
|
||||
# ── 2. Sync the issue tracker from the Dolt remote ─────────────────────────
|
||||
# `bd init` in-place rewrites .beads/config.yaml and commits, so the sync
|
||||
# happens in a throwaway directory and only the database is moved back.
|
||||
# That directory must be named after the database (see .beads/metadata.json),
|
||||
# since bd derives the database name from it.
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
DB_NAME="$(jq -r '.dolt_database // "improvise"' .beads/metadata.json 2>/dev/null || echo improvise)"
|
||||
ORIGIN="$(git remote get-url origin 2>/dev/null || true)"
|
||||
case "$ORIGIN" in
|
||||
git@github.com:*) ORIGIN="https://github.com/${ORIGIN#git@github.com:}" ;;
|
||||
esac
|
||||
ORIGIN="${ORIGIN%.git}"
|
||||
|
||||
# The mktemp is part of the condition on purpose. Unchecked, a failure
|
||||
# leaves TMP empty, "$TMP/$DB_NAME" becomes an absolute path at the
|
||||
# filesystem root, and the cleanup at the end degrades to `rm -rf ""`.
|
||||
# Short-circuiting also means no temp directory is created when there is
|
||||
# no origin to sync from.
|
||||
if [ -n "$ORIGIN" ] && TMP=$(mktemp -d) && [ -d "$TMP" ]; then
|
||||
log "syncing issue tracker from $ORIGIN …"
|
||||
STAGE="$TMP/$DB_NAME"
|
||||
mkdir -p "$STAGE"
|
||||
|
||||
# `bd init` exits non-zero when the remote is unreachable but STILL leaves
|
||||
# a .beads/embeddeddolt behind — an empty database. Testing only for the
|
||||
# directory would swap that over a good tracker, so check the status and
|
||||
# confirm the result actually carries issues. An empty remote tracker is
|
||||
# indistinguishable from a failed clone here; keeping what we already have
|
||||
# is the safe way to be wrong.
|
||||
sync_ok=0
|
||||
if (
|
||||
cd "$STAGE" && git init -q . &&
|
||||
BD_NON_INTERACTIVE=1 bd init --non-interactive --skip-agents --skip-hooks -q \
|
||||
--remote "git+$ORIGIN"
|
||||
) >/tmp/bd-sync.log 2>&1 && [ -d "$STAGE/.beads/embeddeddolt" ]; then
|
||||
listing="$( (cd "$STAGE" && bd list --all 2>/dev/null) || true )"
|
||||
case "$listing" in
|
||||
"" | "No issues found."*) ;;
|
||||
*) sync_ok=1 ;;
|
||||
esac
|
||||
# For local checkouts, only verify the bd pin (no install, no sync)
|
||||
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
|
||||
# Inline the pin-check only, since the full bootstrap is cloud-only
|
||||
cd "$ROOT" || exit 0
|
||||
beads_pin() {
|
||||
sed -n 's|.*beads\.url[[:space:]]*=[[:space:]]*"github:[^/]*/[^/]*/v\{0,1\}\([^"]*\)".*|\1|p' \
|
||||
flake.nix 2>/dev/null | head -1
|
||||
}
|
||||
bd_running_version() {
|
||||
bd version 2>/dev/null | sed -n 's/^bd version \([0-9][0-9.]*\).*/\1/p' | head -1
|
||||
}
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
pinned=$(beads_pin)
|
||||
running=$(bd_running_version)
|
||||
# Fail closed: if we can't parse either version, warn rather than silently pass
|
||||
if [ -z "$pinned" ]; then
|
||||
printf '[session-start] WARNING: cannot parse beads pin from flake.nix\n' >&2
|
||||
printf '[session-start] The pin check is skipped, but mutating bd commands may be unsafe.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$sync_ok" -eq 1 ] && install_tracker "$STAGE/.beads/embeddeddolt"; then
|
||||
log "issue tracker synced — 'bd ready' now reflects the remote, not issues.jsonl"
|
||||
elif [ -d .beads/embeddeddolt ]; then
|
||||
log "WARNING: tracker sync failed (see /tmp/bd-sync.log); kept the previous database."
|
||||
log " It may be out of date — verify any bead against the code."
|
||||
else
|
||||
log "WARNING: tracker sync failed; see /tmp/bd-sync.log."
|
||||
log " .beads/issues.jsonl is a STALE export — verify any bead against the code."
|
||||
if [ -z "$running" ]; then
|
||||
printf '[session-start] WARNING: cannot parse bd version from "bd version"\n' >&2
|
||||
printf '[session-start] bd: %s\n' "$(command -v bd)" >&2
|
||||
printf '[session-start] The pin check is skipped, but mutating bd commands may be unsafe.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "$running" != "$pinned" ]; then
|
||||
printf '\n[session-start] ===== BEADS VERSION SKEW =====\n' >&2
|
||||
printf '[session-start] pinned (flake.nix): %s\n' "$pinned" >&2
|
||||
printf '[session-start] running: %s (%s)\n' "$running" "$(command -v bd)" >&2
|
||||
printf '[session-start] Reading is safe. Do NOT run mutating bd commands.\n' >&2
|
||||
printf '[session-start] Fix: nix develop, or direnv allow.\n' >&2
|
||||
printf '[session-start] =============================\n\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
rm -rf "$TMP"
|
||||
elif [ -n "$ORIGIN" ]; then
|
||||
log "WARNING: could not create a temporary directory; skipped the tracker sync."
|
||||
log " .beads/issues.jsonl is a STALE export — verify any bead against the code."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 3. Warm the build so the first cargo test is not a cold compile ────────
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
log "warming cargo build…"
|
||||
if cargo build --workspace --tests >/tmp/cargo-warm.log 2>&1; then
|
||||
log "workspace built"
|
||||
else
|
||||
log "WARNING: cargo build failed; see /tmp/cargo-warm.log"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 4. Environment facts worth knowing up front ────────────────────────────
|
||||
cat <<'NOTES'
|
||||
[session-start] Environment notes (see context/cloud-agents.md):
|
||||
• Use the preinstalled cargo toolchain for ordinary work. `nix develop`
|
||||
also works, but only after scripts/nix-seed-flake-inputs.sh — GitHub
|
||||
tarballs are not served here, so the locked inputs are seeded over git.
|
||||
• `bd dolt push` is blocked outbound (403). Bead status changes stay local.
|
||||
• Never run a bare `bd init` in this repo: it rewrites AGENTS.md, CLAUDE.md
|
||||
and .claude/settings.json, and commits. Use --skip-agents --skip-hooks.
|
||||
• `bd dolt push <remote>` ignores its argument and pushes to origin.
|
||||
NOTES
|
||||
|
||||
# Non-zero only when §0 found version skew; the setup above still ran.
|
||||
exit "$pin_status"
|
||||
# Cloud session: run the full bootstrap
|
||||
exec "$BOOTSTRAP"
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/beads
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Improvise",
|
||||
"install": "nix-daemon >/tmp/nix-daemon.log 2>&1 & ./scripts/agent-bootstrap.sh",
|
||||
"start": "nix-daemon &"
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/beads
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/beads-merge
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/code-review
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/kb
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/pr-watch
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/wave
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Cloud agent smoke
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".claude/**"
|
||||
- ".cursor/**"
|
||||
- "scripts/agent-bootstrap.sh"
|
||||
- "scripts/nix-seed-flake-inputs.sh"
|
||||
- ".github/workflows/cloud-agent-smoke.yml"
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
name: Claude entry point + bead
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Bootstrap through Claude session-start hook
|
||||
shell: bash
|
||||
env:
|
||||
CLAUDE_CODE_REMOTE: "true"
|
||||
run: |
|
||||
if ! ./.claude/hooks/session-start.sh; then
|
||||
echo "=== /tmp/nix-daemon.log ==="
|
||||
cat /tmp/nix-daemon.log 2>/dev/null || true
|
||||
echo "=== /tmp/nix-seed.log ==="
|
||||
cat /tmp/nix-seed.log 2>/dev/null || true
|
||||
echo "=== /tmp/bd-install.log ==="
|
||||
cat /tmp/bd-install.log 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
- name: Create, read back, and close a smoke-test bead
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out="$(bd create "Cloud agent smoke: Claude entry point" --description="Ephemeral local-only bead used to verify the Claude cloud bootstrap entry point." --type=task --priority=4 --json)"
|
||||
id="$(printf '%s' "$out" | jq -r '.id // .issue.id // empty')"
|
||||
test -n "$id"
|
||||
bd show "$id"
|
||||
bd close "$id" --reason="Cloud-agent smoke test completed"
|
||||
bd show "$id" | grep -qi closed
|
||||
|
||||
cursor:
|
||||
name: Cursor install entry point + bead
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Bootstrap through Cursor install command
|
||||
shell: bash
|
||||
run: |
|
||||
nix-daemon >/tmp/nix-daemon.log 2>&1 &
|
||||
if ! ./scripts/agent-bootstrap.sh; then
|
||||
echo "=== /tmp/nix-daemon.log ==="
|
||||
cat /tmp/nix-daemon.log 2>/dev/null || true
|
||||
echo "=== /tmp/nix-seed.log ==="
|
||||
cat /tmp/nix-seed.log 2>/dev/null || true
|
||||
echo "=== /tmp/bd-install.log ==="
|
||||
cat /tmp/bd-install.log 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
- name: Create, read back, and close a smoke-test bead
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out="$(bd create "Cloud agent smoke: Cursor entry point" --description="Ephemeral local-only bead used to verify the Cursor cloud bootstrap entry point." --type=task --priority=4 --json)"
|
||||
id="$(printf '%s' "$out" | jq -r '.id // .issue.id // empty')"
|
||||
test -n "$id"
|
||||
bd show "$id"
|
||||
bd close "$id" --reason="Cloud-agent smoke test completed"
|
||||
bd show "$id" | grep -qi closed
|
||||
@@ -68,3 +68,4 @@ __pycache__/
|
||||
# The release web capture, written to the repo root by
|
||||
# scripts/build-web-release.sh (improvise-oujb).
|
||||
/improvise-web.tar.gz
|
||||
.opencode
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
force-push. The rule, the message convention and the incidents are
|
||||
`.agents/skills/wave/agent-rules.md`, rule 8.
|
||||
- always start responses with bananaS!
|
||||
- there is a prolog MCP available: load a session from `kb` at the root of the repository at the beginning of
|
||||
every session, and query it as you work. **Reading only** — the MCP must never write `kb.pl`.
|
||||
- **Knowledge base: use `scripts/kb`, not Prolog MCP.** The Prolog MCP is optional and not
|
||||
available in all agent stacks (Cursor cloud agents do not have it). All agents must use
|
||||
`scripts/kb add|replace|rm|query|check` for KB operations — see the `kb` skill at
|
||||
`.agents/skills/kb/SKILL.md`. Reading `prolog-sessions/kb.pl` directly is fine for queries.
|
||||
- save structured facts as you go — work done, interesting locations, important decisions — with
|
||||
`scripts/kb add` (see the `kb` skill). Write a fact when you learn it; there is no reason to batch.
|
||||
- query the knowledge base as needed and use Prolog reasoning when it makes sense.
|
||||
@@ -25,6 +27,87 @@
|
||||
|
||||
This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context.
|
||||
|
||||
## Multi-Stack Agent Support
|
||||
|
||||
This repo supports multiple coding-agent stacks: Claude Code, Cursor (IDE and cloud), and
|
||||
potentially others. All stacks share the same operational setup:
|
||||
|
||||
| Component | Location | Notes |
|
||||
|---|---|---|
|
||||
| Toolchain | `nix develop` / `direnv` | **Required.** Rust from flake, wasm target, beads pin, trealla |
|
||||
| Skills | `.agents/skills/` | Canonical source; `.claude/skills/` and `.cursor/skills/` symlink here |
|
||||
| Session bootstrap | `scripts/agent-bootstrap.sh` | Seeds nix inputs, installs bd, syncs tracker, warms cargo |
|
||||
| Knowledge base | `scripts/kb` | **All stacks use this**, not Prolog MCP |
|
||||
| Issue tracker | `bd` CLI | Synced from Dolt remote at session start |
|
||||
|
||||
### Toolchain Requirement
|
||||
|
||||
**Agents must work inside `nix develop` (or direnv) for this repo.** Bare system cargo that
|
||||
fails `edition2024` or missing wasm target is a misconfigured session. The bootstrap seeds
|
||||
nix flake inputs automatically (GitHub tarballs are blocked in cloud), so `nix develop`
|
||||
should work after install completes.
|
||||
|
||||
Run commands inside the dev shell:
|
||||
```bash
|
||||
nix develop --command cargo test
|
||||
nix develop --command cargo build --workspace
|
||||
```
|
||||
|
||||
Or enter an interactive shell: `nix develop`
|
||||
|
||||
**Claude Code**: `.claude/hooks/session-start.sh` calls the bootstrap automatically.
|
||||
|
||||
**Cursor cloud**: `.cursor/environment.json` runs the bootstrap via `install`. Skills are
|
||||
discovered from `.cursor/skills/` (symlinks to `.agents/skills/`).
|
||||
|
||||
**Local agents**: Run `scripts/agent-bootstrap.sh` manually, or use `direnv allow` for
|
||||
automatic dev shell activation.
|
||||
|
||||
See `context/cloud-agents.md` for environment-specific notes (nix, network, etc.).
|
||||
|
||||
### GitHub Attribution (Hard Rule)
|
||||
|
||||
**Every GitHub write (PR, comment, review reply) must include an attribution line identifying
|
||||
the AI agent.** Posts go out under the repository owner's account, and an unattributed post
|
||||
reads as the human owner's words to everyone who sees it.
|
||||
|
||||
**Format:** Open the body with an italicized attribution line:
|
||||
|
||||
```
|
||||
_Posted by <Agent Stack> (<model>)_
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `_Posted by Claude (Opus 4.5)_`
|
||||
- `_Posted by Cursor Agent (claude-sonnet-5)_`
|
||||
- `_Posted by Claude Code_`
|
||||
|
||||
For PR bodies, the `🤖 Generated with …` footer that some tools add covers this. For
|
||||
comments and review replies, add the line yourself — there is no automatic footer.
|
||||
|
||||
**This applies to:** PR creation, PR updates, issue comments, review comments, and review
|
||||
replies. A thread is not "answered" until the reply carries this attribution.
|
||||
|
||||
### PR Tracking: Checks AND Reviews
|
||||
|
||||
For any PR an agent owns or is working on, **track both CI checks and GitHub comments/reviews**.
|
||||
The `pr-watch` skill (`.agents/skills/pr-watch/SKILL.md`) is the agent-facing recipe.
|
||||
|
||||
**`scripts/pr-watch`** now prints reviews after checks are green (use `--no-reviews` to skip).
|
||||
Reviews are a separate API surface from comments — Codex/ChatGPT reviews arrive as *reviews*,
|
||||
not comments, and are easy to miss if you only read the comment thread.
|
||||
|
||||
1. **Run `scripts/pr-watch <n>`** — waits for checks, then prints all reviews/comments
|
||||
2. **Read all review feedback** — reviews against an earlier head may still apply
|
||||
3. **Answer substantive feedback with attribution** before merging (see format above)
|
||||
4. **Re-check after each push** that new reviews target the current head SHA
|
||||
5. **Don't wait indefinitely on Copilot/Codex** — reviews arrive as quota allows
|
||||
|
||||
To fetch reviews directly: `./scripts/pr-reviews <n>` — **do not pipe it** (exit code trap).
|
||||
|
||||
See the `pr-watch` skill for the full recipe, and "Shipping a wave" below for Copilot
|
||||
request mechanics and how to answer declined findings.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+37
-29
@@ -854,7 +854,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
@@ -886,6 +886,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"siphasher",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1044,9 +1045,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.105"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -1061,7 +1062,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"portable-atomic",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1605,7 +1606,7 @@ dependencies = [
|
||||
"palette",
|
||||
"serde",
|
||||
"strum",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
@@ -1892,9 +1893,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.1"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
|
||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
@@ -2072,11 +2073,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.20",
|
||||
"thiserror-impl 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2092,9 +2093,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2173,7 +2174,7 @@ dependencies = [
|
||||
"log",
|
||||
"rand 0.10.3",
|
||||
"sha1",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2194,6 +2195,12 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
@@ -2282,9 +2289,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -2295,19 +2302,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.78"
|
||||
version = "0.4.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
|
||||
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -2315,9 +2323,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -2328,18 +2336,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.105"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
|
||||
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -2525,18 +2533,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.57"
|
||||
version = "0.8.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||
checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.57"
|
||||
version = "0.8.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||
checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -2657,7 +2657,7 @@ rec {
|
||||
}
|
||||
{
|
||||
name = "thiserror";
|
||||
packageId = "thiserror 2.0.20";
|
||||
packageId = "thiserror 2.0.21";
|
||||
}
|
||||
{
|
||||
name = "unicode-width";
|
||||
@@ -2668,6 +2668,7 @@ rec {
|
||||
{
|
||||
name = "improvise-protocol";
|
||||
packageId = "improvise-protocol";
|
||||
features = [ "test-support" ];
|
||||
}
|
||||
{
|
||||
name = "improvise-wasm-client";
|
||||
@@ -2751,6 +2752,11 @@ rec {
|
||||
}
|
||||
];
|
||||
devDependencies = [
|
||||
{
|
||||
name = "improvise-core";
|
||||
packageId = "improvise-core";
|
||||
features = [ "test-support" ];
|
||||
}
|
||||
{
|
||||
name = "improvise-protocol";
|
||||
packageId = "improvise-protocol";
|
||||
@@ -2791,6 +2797,10 @@ rec {
|
||||
name = "siphasher";
|
||||
packageId = "siphasher";
|
||||
}
|
||||
{
|
||||
name = "unicase";
|
||||
packageId = "unicase";
|
||||
}
|
||||
];
|
||||
devDependencies = [
|
||||
{
|
||||
@@ -2802,7 +2812,9 @@ rec {
|
||||
packageId = "serde_json";
|
||||
}
|
||||
];
|
||||
|
||||
features = {
|
||||
};
|
||||
resolvedDefaultFeatures = [ "test-support" ];
|
||||
};
|
||||
"improvise-formula" = rec {
|
||||
crateName = "improvise-formula";
|
||||
@@ -3032,6 +3044,11 @@ rec {
|
||||
name = "improvise-core";
|
||||
packageId = "improvise-core";
|
||||
}
|
||||
{
|
||||
name = "improvise-protocol";
|
||||
packageId = "improvise-protocol";
|
||||
features = [ "test-support" ];
|
||||
}
|
||||
{
|
||||
name = "improvise-ws-server";
|
||||
packageId = "improvise-ws-server";
|
||||
@@ -3293,9 +3310,9 @@ rec {
|
||||
};
|
||||
"js-sys" = rec {
|
||||
crateName = "js-sys";
|
||||
version = "0.3.105";
|
||||
version = "0.3.106";
|
||||
edition = "2021";
|
||||
sha256 = "17pr58p55pxbflkjl2wz6pyz854j80nlgavjq4mcwr583q6x4myf";
|
||||
sha256 = "1icwmpjw54lb7vg5926k5y4y5jbiih0zxhwgjwnzn475v90xk0vq";
|
||||
libName = "js_sys";
|
||||
authors = [
|
||||
"The wasm-bindgen Developers"
|
||||
@@ -3347,7 +3364,7 @@ rec {
|
||||
}
|
||||
{
|
||||
name = "thiserror";
|
||||
packageId = "thiserror 2.0.20";
|
||||
packageId = "thiserror 2.0.21";
|
||||
usesDefaultFeatures = false;
|
||||
}
|
||||
];
|
||||
@@ -4896,7 +4913,7 @@ rec {
|
||||
}
|
||||
{
|
||||
name = "thiserror";
|
||||
packageId = "thiserror 2.0.20";
|
||||
packageId = "thiserror 2.0.21";
|
||||
usesDefaultFeatures = false;
|
||||
}
|
||||
{
|
||||
@@ -5877,9 +5894,9 @@ rec {
|
||||
};
|
||||
"smallvec" = rec {
|
||||
crateName = "smallvec";
|
||||
version = "1.16.1";
|
||||
version = "1.16.2";
|
||||
edition = "2018";
|
||||
sha256 = "14gqvsqdli51r1bii3hfqv5vx1b9r0gic4br0x9fsixmy5b70ims";
|
||||
sha256 = "13iai5hhwyp8z0pbn8r11q4j5956jaxhcbvvf2drm17f1q7myfgr";
|
||||
authors = [
|
||||
"The Servo Project Developers"
|
||||
];
|
||||
@@ -6413,18 +6430,18 @@ rec {
|
||||
];
|
||||
|
||||
};
|
||||
"thiserror 2.0.20" = rec {
|
||||
"thiserror 2.0.21" = rec {
|
||||
crateName = "thiserror";
|
||||
version = "2.0.20";
|
||||
version = "2.0.21";
|
||||
edition = "2021";
|
||||
sha256 = "0kxs6p295jffxhzaxpxv1dwaaf5iqlm6sx8h0djp6ancbxgj71pc";
|
||||
sha256 = "17hq1lh5dyr3bkc7zzjrbrp4qgkvhc48kgq1n5fdxkindaw2rr89";
|
||||
authors = [
|
||||
"David Tolnay <dtolnay@gmail.com>"
|
||||
];
|
||||
dependencies = [
|
||||
{
|
||||
name = "thiserror-impl";
|
||||
packageId = "thiserror-impl 2.0.20";
|
||||
packageId = "thiserror-impl 2.0.21";
|
||||
}
|
||||
];
|
||||
features = {
|
||||
@@ -6458,11 +6475,11 @@ rec {
|
||||
];
|
||||
|
||||
};
|
||||
"thiserror-impl 2.0.20" = rec {
|
||||
"thiserror-impl 2.0.21" = rec {
|
||||
crateName = "thiserror-impl";
|
||||
version = "2.0.20";
|
||||
version = "2.0.21";
|
||||
edition = "2021";
|
||||
sha256 = "1bwjc94gi0xn5jz26h1a8bjj1wdkvvr6jifamyc4mp9n28zcs15w";
|
||||
sha256 = "0945n8agp7kg6n6b35yyjb4g5xv2q22vrw15h6jj1nw76a99flgy";
|
||||
procMacro = true;
|
||||
libName = "thiserror_impl";
|
||||
authors = [
|
||||
@@ -6812,7 +6829,7 @@ rec {
|
||||
}
|
||||
{
|
||||
name = "thiserror";
|
||||
packageId = "thiserror 2.0.20";
|
||||
packageId = "thiserror 2.0.21";
|
||||
}
|
||||
];
|
||||
devDependencies = [
|
||||
@@ -6874,6 +6891,17 @@ rec {
|
||||
sha256 = "154smf048k84prsdgh09nkm2n0w0336v84jd4zikyn6v6jrqbspa";
|
||||
|
||||
};
|
||||
"unicase" = rec {
|
||||
crateName = "unicase";
|
||||
version = "2.9.0";
|
||||
edition = "2018";
|
||||
sha256 = "0hh1wrfd7807mfph2q67jsxqgw8hm82xg2fb8ln8cvblkwxbri6v";
|
||||
authors = [
|
||||
"Sean McArthur <sean@seanmonstar.com>"
|
||||
];
|
||||
features = {
|
||||
};
|
||||
};
|
||||
"unicode-ident" = rec {
|
||||
crateName = "unicode-ident";
|
||||
version = "1.0.26";
|
||||
@@ -7114,9 +7142,9 @@ rec {
|
||||
};
|
||||
"wasm-bindgen" = rec {
|
||||
crateName = "wasm-bindgen";
|
||||
version = "0.2.128";
|
||||
version = "0.2.129";
|
||||
edition = "2021";
|
||||
sha256 = "1gsi4ggm03dqr3j9bcc1sbxzmkw6ykm3cqx4g8xmw31v7niqgjxf";
|
||||
sha256 = "02flhqld01jqb6vbfyx1gb8s78g1pnq2164daxad93y6mhrlzdcv";
|
||||
libName = "wasm_bindgen";
|
||||
authors = [
|
||||
"The wasm-bindgen Developers"
|
||||
@@ -7147,12 +7175,6 @@ rec {
|
||||
rename = "rustversion-compat";
|
||||
}
|
||||
];
|
||||
devDependencies = [
|
||||
{
|
||||
name = "once_cell";
|
||||
packageId = "once_cell";
|
||||
}
|
||||
];
|
||||
features = {
|
||||
"default" = [ "std" ];
|
||||
"enable-interning" = [ "std" ];
|
||||
@@ -7165,9 +7187,9 @@ rec {
|
||||
};
|
||||
"wasm-bindgen-futures" = rec {
|
||||
crateName = "wasm-bindgen-futures";
|
||||
version = "0.4.78";
|
||||
version = "0.4.79";
|
||||
edition = "2021";
|
||||
sha256 = "0a6r1q1v2qvi3ypmy0nva3il422iyml1f61i897wbxfdsb9wbx3f";
|
||||
sha256 = "03k1wcg5j3wqk6vgy6gzcq868vsa9k81dlhqissfk0nrw96v7fiw";
|
||||
libName = "wasm_bindgen_futures";
|
||||
authors = [
|
||||
"The wasm-bindgen Developers"
|
||||
@@ -7178,6 +7200,13 @@ rec {
|
||||
packageId = "js-sys";
|
||||
usesDefaultFeatures = false;
|
||||
}
|
||||
{
|
||||
name = "tokio";
|
||||
packageId = "tokio";
|
||||
usesDefaultFeatures = false;
|
||||
target = { target, features }: (("emscripten" == target."os" or null) && (target."wasm_bindgen_unstable_tokio" or false));
|
||||
features = [ "rt" "time" ];
|
||||
}
|
||||
{
|
||||
name = "wasm-bindgen";
|
||||
packageId = "wasm-bindgen";
|
||||
@@ -7193,9 +7222,9 @@ rec {
|
||||
};
|
||||
"wasm-bindgen-macro" = rec {
|
||||
crateName = "wasm-bindgen-macro";
|
||||
version = "0.2.128";
|
||||
version = "0.2.129";
|
||||
edition = "2021";
|
||||
sha256 = "1vdrjrb7yqh8p0r0yr93rlaq9iq05hy1wl9kbsjv7a61wc8xb456";
|
||||
sha256 = "0dc5xq09sy1v9ns1fhb0cnyqz5p1wcjjvkdmxskj9qhnbg1x0a9f";
|
||||
procMacro = true;
|
||||
libName = "wasm_bindgen_macro";
|
||||
authors = [
|
||||
@@ -7217,9 +7246,9 @@ rec {
|
||||
};
|
||||
"wasm-bindgen-macro-support" = rec {
|
||||
crateName = "wasm-bindgen-macro-support";
|
||||
version = "0.2.128";
|
||||
version = "0.2.129";
|
||||
edition = "2021";
|
||||
sha256 = "12p4jm9dr88h1j5sglycizvvxvr05pgmz7aa2v9g47h7y23lh7j1";
|
||||
sha256 = "1dx6w90f14avmhri7ss9lyz03060g6475r4axy3bm7biqf5ill3g";
|
||||
libName = "wasm_bindgen_macro_support";
|
||||
authors = [
|
||||
"The wasm-bindgen Developers"
|
||||
@@ -7253,10 +7282,10 @@ rec {
|
||||
};
|
||||
"wasm-bindgen-shared" = rec {
|
||||
crateName = "wasm-bindgen-shared";
|
||||
version = "0.2.128";
|
||||
version = "0.2.129";
|
||||
edition = "2021";
|
||||
links = "wasm_bindgen";
|
||||
sha256 = "0ghh8hqx038h9c9zj1rlmzlv2k2shh917q756dn054hcipbir541";
|
||||
sha256 = "1ilmp5d3sl8lrq9djhcs90gk66yvk8mgwk4sfrvpvkd75b2wkw13";
|
||||
libName = "wasm_bindgen_shared";
|
||||
authors = [
|
||||
"The wasm-bindgen Developers"
|
||||
@@ -7271,9 +7300,9 @@ rec {
|
||||
};
|
||||
"web-sys" = rec {
|
||||
crateName = "web-sys";
|
||||
version = "0.3.105";
|
||||
version = "0.3.106";
|
||||
edition = "2021";
|
||||
sha256 = "1y57whpj5ncrl0cljmfsj5m31jqm66ylad68317yq03g0d5drgcz";
|
||||
sha256 = "195bd1m14w3hsw5fg3sr938mh7y4cb260d0sq6a6brffxjfin9l8";
|
||||
libName = "web_sys";
|
||||
authors = [
|
||||
"The wasm-bindgen Developers"
|
||||
@@ -8440,9 +8469,9 @@ rec {
|
||||
};
|
||||
"zerocopy" = rec {
|
||||
crateName = "zerocopy";
|
||||
version = "0.8.57";
|
||||
version = "0.8.58";
|
||||
edition = "2021";
|
||||
sha256 = "0wr85plmrkz0nh5grbqap23l9ghqq4mq1ip4x759q23dyflh4lfk";
|
||||
sha256 = "0i6vsbj4qfqj62b8qxl7wdcin8rnqz6pw88p6zzl5dc2mnpqyzn1";
|
||||
dependencies = [
|
||||
{
|
||||
name = "zerocopy-derive";
|
||||
@@ -8472,9 +8501,9 @@ rec {
|
||||
};
|
||||
"zerocopy-derive" = rec {
|
||||
crateName = "zerocopy-derive";
|
||||
version = "0.8.57";
|
||||
version = "0.8.58";
|
||||
edition = "2021";
|
||||
sha256 = "1g5js10p1q1d7wn6b74wjvrjznv34x5cfxpj7jj8s9a4mgsh2v0l";
|
||||
sha256 = "1whbf7cjcqyp5nhvma78yy489swr7mfgc9m6r6h4ckyz8kh5cpsr";
|
||||
procMacro = true;
|
||||
libName = "zerocopy_derive";
|
||||
dependencies = [
|
||||
|
||||
+82
-13
@@ -1,11 +1,30 @@
|
||||
# Cloud Agents (LLM Reference)
|
||||
|
||||
Environment notes for agents running in Claude Code on the web. Local
|
||||
checkouts are unaffected — the session hook exits early unless
|
||||
Environment notes for agents running in cloud containers — Claude Code on the web,
|
||||
Cursor cloud agents, and similar setups. Local checkouts are unaffected.
|
||||
|
||||
## Session Bootstrap
|
||||
|
||||
All agent stacks use the same bootstrap script: `scripts/agent-bootstrap.sh`. It:
|
||||
|
||||
1. **Seeds nix flake inputs** (GitHub tarballs are blocked; seeds over git instead)
|
||||
2. Verifies the `bd` version matches the flake.nix pin
|
||||
3. Installs `bd` if missing
|
||||
4. Syncs the Dolt issue tracker from the git remote
|
||||
5. Warms the cargo build **inside `nix develop`**
|
||||
|
||||
**After bootstrap, `nix develop` works.** This is the required toolchain — bare system
|
||||
cargo that fails `edition2024` or missing wasm target is a misconfigured session.
|
||||
|
||||
**Claude Code**: `.claude/hooks/session-start.sh` calls the bootstrap automatically when
|
||||
`CLAUDE_CODE_REMOTE=true`.
|
||||
|
||||
`.claude/hooks/session-start.sh` handles the setup automatically. This doc
|
||||
is the "why", and the record of what bites when it doesn't run.
|
||||
**Cursor cloud**: `.cursor/environment.json` runs the bootstrap via `install`. Skills are
|
||||
in `.cursor/skills/` (symlinks to `.agents/skills/`).
|
||||
|
||||
**Other stacks**: Run `scripts/agent-bootstrap.sh` manually after checkout.
|
||||
|
||||
This doc is the "why", and the record of what bites when setup doesn't run.
|
||||
|
||||
---
|
||||
|
||||
@@ -60,7 +79,8 @@ same if you need to re-sync by hand; never run it in the working tree.
|
||||
|
||||
| Thing | Status | Use instead |
|
||||
|---|---|---|
|
||||
| `nix develop` **unseeded** | Fails: flake inputs are GitHub **tarballs**, which are not served here | Run `scripts/nix-seed-flake-inputs.sh` first — see below. Or just use the preinstalled `cargo`/`rustc`, the same stable toolchain the flake pins |
|
||||
| `nix develop` **without bootstrap** | Fails: flake inputs are GitHub **tarballs**, which are blocked | Bootstrap seeds inputs automatically; if it failed, run `scripts/nix-seed-flake-inputs.sh` manually |
|
||||
| Bare system cargo | Fails: `edition2024`, missing wasm target | **Always use `nix develop`** — this is the required toolchain |
|
||||
| `bd dolt push` | Fails: 403 outbound | Nothing; bead status changes stay local. Say so rather than implying the tracker was updated |
|
||||
| `git+ssh://` Dolt remote in `config.yaml` | No ssh client in the container | `git+https://<origin>`, which the hook passes explicitly |
|
||||
| `git push`, GitHub API | **Work normally** | — |
|
||||
@@ -70,13 +90,13 @@ starting by hand). Reads of public GitHub repos over the git protocol work —
|
||||
it is specifically the `/archive/*.tar.gz` and `codeload` endpoints that are
|
||||
refused.
|
||||
|
||||
### Making `nix develop` work
|
||||
### How nix develop works after bootstrap
|
||||
|
||||
`scripts/nix-seed-flake-inputs.sh` makes the devShell work **without touching
|
||||
`flake.nix` or `flake.lock`**. Verified end to end: `rustc 1.98.1`, `cargo`,
|
||||
`crate2nix` in the shell, `git status` clean.
|
||||
The bootstrap runs `scripts/nix-seed-flake-inputs.sh` automatically, which seeds
|
||||
the flake inputs over git **without touching `flake.nix` or `flake.lock`**.
|
||||
After bootstrap, `nix develop` should work. If it fails, check `/tmp/nix-seed.log`.
|
||||
|
||||
Why it is needed, and why the obvious fixes are not available:
|
||||
Why the seeding is needed:
|
||||
|
||||
- Nix's `github:` fetcher wants a tarball. GitHub traffic **bypasses the
|
||||
environment's domain allowlist entirely** and goes through a proxy that
|
||||
@@ -105,9 +125,9 @@ around it — a mismatch fails loudly. Do not instead rewrite the lock's nodes
|
||||
to `git` type: carrying the old `narHash` onto a different fetcher is exactly
|
||||
the claim the lock exists to make.
|
||||
|
||||
It is not in the session-start hook because it is expensive (minutes, plus
|
||||
several GB of store once the devShell builds) and most work does not need the
|
||||
devShell. Run it when you do.
|
||||
The bootstrap runs this automatically. It takes a few minutes and several GB of
|
||||
store once the devShell builds. If bootstrap reported a seed failure, check
|
||||
`/tmp/nix-seed.log` and re-run `scripts/nix-seed-flake-inputs.sh` manually.
|
||||
|
||||
Two alternatives if per-container seeding is unattractive: push the inputs to
|
||||
a binary cache with `nix flake archive` and point sessions at it with
|
||||
@@ -143,3 +163,52 @@ environment, where egress leaves through your own network boundary.
|
||||
reorder or fold commits (`.agents/skills/wave/agent-rules.md`, rule 8).
|
||||
- Keep `context/repo-map.md` and `context/design-principles.md` current —
|
||||
they are the two docs agents are expected to maintain by hand.
|
||||
|
||||
---
|
||||
|
||||
## PR Tracking
|
||||
|
||||
For any PR you own, **track both CI checks and GitHub comments/reviews**:
|
||||
|
||||
- **`scripts/pr-watch <n>`**: Waits for checks, then prints all reviews/comments (default).
|
||||
Reviews are a separate API surface — Codex/ChatGPT reviews arrive as *reviews* not comments.
|
||||
- **Answer feedback with attribution** before marking work done (see below).
|
||||
- **Re-check after push** that reviews target the current head SHA.
|
||||
|
||||
The `pr-watch` skill (`.agents/skills/pr-watch/SKILL.md`) is the agent-facing recipe.
|
||||
See AGENTS.md "PR Tracking: Checks AND Reviews" and "Shipping a wave" for the full workflow.
|
||||
|
||||
---
|
||||
|
||||
## GitHub Attribution (Hard Rule)
|
||||
|
||||
**Every GitHub write (PR, comment, review reply) must include an attribution line.**
|
||||
Posts go out under the repository owner's account — an unattributed post reads as the
|
||||
human's words.
|
||||
|
||||
Open the body with: `_Posted by <Agent Stack> (<model>)_`
|
||||
|
||||
Examples:
|
||||
- `_Posted by Claude (Opus 4.5)_`
|
||||
- `_Posted by Cursor Agent (claude-sonnet-5)_`
|
||||
|
||||
A thread is not "answered" until the reply carries this attribution. See AGENTS.md
|
||||
"GitHub Attribution" for the full rule.
|
||||
|
||||
---
|
||||
|
||||
## Knowledge Base
|
||||
|
||||
**Use `scripts/kb`, not Prolog MCP.** The Prolog MCP is not available in all agent stacks
|
||||
(Cursor cloud agents do not have it). All agents must use `scripts/kb` for KB operations:
|
||||
|
||||
```bash
|
||||
scripts/kb check # kb_consistent, exactly what CI runs
|
||||
scripts/kb query '<goal>' # run a goal; exit 1 if it fails
|
||||
scripts/kb add <delta.pl> # add clauses; refuses if a key already exists
|
||||
scripts/kb replace <delta.pl> # add, overwriting existing keys
|
||||
scripts/kb rm <keys.pl> # remove by key; refuses if a key is absent
|
||||
```
|
||||
|
||||
Reading `prolog-sessions/kb.pl` directly for queries is fine. See the `kb` skill at
|
||||
`.agents/skills/kb/SKILL.md` for the full interface.
|
||||
|
||||
@@ -1685,3 +1685,82 @@ probability (`> ||[||]` is a self-parent), so `generated_file_parses` accepts a
|
||||
carries `depth` for the day a front end wants to indent.
|
||||
- Two `>` lines giving one group two different parents: the last wins, as it
|
||||
did before this change. That makes the hierarchy a function of line order.
|
||||
|
||||
## 20. How an id is made — `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2)
|
||||
|
||||
`slugify` and `IdAllocator` are the first piece of ADR 007. They are pure
|
||||
functions of the display and the allocator's own history. Nothing calls them
|
||||
yet: the creation sites move onto them in the re-key step (improvise-cq1q.4).
|
||||
Each rule traces to a numbered answer in the ADR's "What the user decided".
|
||||
|
||||
**Case folding is full Unicode folding, and it comes from `unicase`.**
|
||||
`str::to_lowercase` is not folding: `ß` lowercases to itself and is then
|
||||
stripped, so `Straße` would slug to `strae` (answer 26). The same goes for `ſ`
|
||||
(long s) and the `fi` ligature. `unicase` 2.9 is `no_std` and has no
|
||||
dependencies, so it passes the wasm gate. Its `to_folded_case` applies the
|
||||
C+F mappings of Unicode's CaseFolding.txt, and its own tests pin `Maße` →
|
||||
`masse`. Most of what folding produces is stripped anyway: only an ASCII
|
||||
letter survives step 3. Deriving that ASCII-producing subset of the table by
|
||||
hand was rejected. It is a second copy of the Unicode data, and nothing would
|
||||
say when it drifted.
|
||||
|
||||
**A "space" is U+0020.** Answer 18 says "replace space with `_`", and answer 21
|
||||
says runs collapse. A tab or a no-break space is not a space here, so it is
|
||||
stripped by step 3. Only a run of U+0020 becomes one `_`. An underscore the
|
||||
display already had is never merged with the one a space run makes. So `a_ b`
|
||||
slugs to `a__b`: the steps are applied as written, and no extra
|
||||
underscore-collapsing step exists. A trailing run survives, so `East ` slugs
|
||||
to `east_`. Only *leading* underscores are removed (step 4).
|
||||
|
||||
**The allocator records every id it admits and releases none.** That is the
|
||||
whole of retirement (clause 4, "within a session a removed id is never handed
|
||||
out again"). No release operation exists, so a removed id cannot be reissued
|
||||
by construction; no check refuses it. The counter starts at 1, is shared by
|
||||
every kind, and advances each time a suffix is tried, including a candidate
|
||||
that turns out to be taken by a hand-written id (answers 28 and 29). So
|
||||
`east-7` may follow `east` directly, and no order is enforced among suffixes.
|
||||
**The walk past taken candidates is a loop, never recursion.** A
|
||||
new-version file saved from a non-ASCII import holds `item-1` … `item-N`, and
|
||||
the counter restarts at 1 on load, so one generation walks all N. The first
|
||||
version recursed once per candidate, not in tail position, and aborted the
|
||||
process on a stack overflow: in debug at N ≈ 11,000, in release at 50,000.
|
||||
An abort, not a panic, so the TUI lost unsaved edits (Codex P2 on PR #318).
|
||||
An empty slug always takes a suffix (`category-1`), even when the bare kind
|
||||
name happens to be free, because answer 16(e) spells the result
|
||||
`<kind>-<n>`.
|
||||
|
||||
**`claim` is the only door for a hand-written id.** It checks, in this order:
|
||||
the alphabet (non-empty, `a`–`z`, `0`–`9`, `-`, `_`), the reserved leading
|
||||
`_` (only `_index`, `_dim` and `_measure` may carry one, per answer 23, and
|
||||
the match is exact: `_indexer` and `_measure-1` are Reserved, since "no other
|
||||
id may start with `_`"), and
|
||||
duplicates. It never renames. Refusing is the loader's job, and the loader
|
||||
must name both lines of a duplicate; `ClaimError` carries only the id, so the
|
||||
loader keeps the line numbers. The virtual views `_records` and `_drill` are
|
||||
not yet in `VIRTUAL_IDS`. improvise-p003 item 1 asks for them, and they arrive
|
||||
with the ADR text fix (improvise-cq1q.1) rather than ahead of it.
|
||||
|
||||
## 21. A display beside the key (ADR 007 clause 2, improvise-cq1q.3)
|
||||
|
||||
`Category`, `Item` and `View` each carry a private `display`. It starts equal to
|
||||
the key and changes only through `set_display` / `set_item_display`, which move
|
||||
no key. Until the re-key (improvise-cq1q.4), nothing in production calls those
|
||||
setters, so `display == name` everywhere and behaviour does not change. The
|
||||
split exists so that the re-key changes what goes *into* the key, and nothing a
|
||||
person sees moves with it.
|
||||
|
||||
**The key travels beside the display, never in place of it.** `CategoryRow`,
|
||||
`TileEntry` and `ViewSummary` each gain a `display` field. The key field
|
||||
(`name`, `category`) stays exactly what it was, because both front ends compose
|
||||
commands from it: `resolve.rs` builds `SetAxis`, `RemoveItem` and the rest out of
|
||||
those strings. Carrying the display *in* the key field would make every
|
||||
command name a key that does not exist once the two differ. That is the gap
|
||||
improvise-cq1q.11 names, and cq1q.7 later turns these key fields into ids. The
|
||||
active-view marker in both view panels still compares keys for the same reason.
|
||||
|
||||
**What this first slice covers:** the category panel's headers, the tile bar and
|
||||
the view list, in both front ends. Item rows, the grid frame, the page bar,
|
||||
records-mode labels, CSV export headers, status messages and the view title
|
||||
still show keys. They follow in later slices of this step, because the grid's
|
||||
label tuples are also cell coordinates (`cell_key`, `innermost_group`), so their
|
||||
display has to be added at projection time rather than in place.
|
||||
|
||||
@@ -6605,3 +6605,19 @@ commits.
|
||||
`target.view`), run across the whole workspace: fails **only** this test.
|
||||
- A composer that hard-codes `"Default"` fails here as well. Neither view in
|
||||
this test is `Default`, so the default cannot satisfy it by accident.
|
||||
|
||||
### a_download_commit_keeps_the_host_session_mirror_in_lockstep
|
||||
|
||||
Codex on PR #329: `LocalThenRequest` closed and cleared the browser's download
|
||||
prompt, but the bytes-only request moved no corresponding state in the host's
|
||||
session mirror. The test opens SavePrompt through the real browser key path,
|
||||
types a nonempty filename, and commits it. It asserts both that the response is
|
||||
still `DocumentBytes` and that the host mirror is Normal with an empty save
|
||||
buffer. Another session then writes a cell; the requester folds that later
|
||||
projection and must still equal its host mirror.
|
||||
|
||||
- **Rival** (the pre-fix download handlers, which returned bytes without
|
||||
closing the mirrored prompt): fails at the host-mode assertion with
|
||||
`SavePrompt` versus `Normal`, before the later projection.
|
||||
- The nonempty filename proves Enter commits rather than taking the empty-name
|
||||
refusal, and matching `DocumentBytes` preserves the bytes-download ruling.
|
||||
|
||||
@@ -1913,3 +1913,114 @@ identity on the view.
|
||||
`prune_empty` at depth: an empty quarter goes, header and all, and its year's
|
||||
header stays for the sibling quarter that has data. Rival: `prune_axis_items`
|
||||
keying open runs by level alone. This test and the `NESTED` enumeration fail.
|
||||
|
||||
## `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2)
|
||||
|
||||
These tests were written before `slugify` and `IdAllocator` had bodies. With
|
||||
`todo!()` bodies all eleven failed. Each rival below was then injected into the
|
||||
finished module, run against `cargo test -p improvise-core --lib model::id`, and
|
||||
reverted. The reasoning is `context/code/core.md` §20.
|
||||
|
||||
### model::id::tests::slugify_case_folds_rather_than_lowercases
|
||||
|
||||
Rival: `display.to_lowercase()` in place of `unicase`'s full folding. Only
|
||||
this test fails: `left: "strae"`, `right: "strasse"`. `ß` lowercases to itself
|
||||
and step 3 strips it. `slugify_follows_the_adrs_examples` cannot see this rival,
|
||||
because each of its displays lowercases and folds alike.
|
||||
|
||||
### model::id::tests::slugify_collapses_a_run_of_spaces_to_one_underscore
|
||||
|
||||
Rival: every space becomes `_`, with runs not collapsed. Only this test fails:
|
||||
`"time___period"`. No other fixture has two spaces in a row.
|
||||
|
||||
### model::id::tests::slugify_keeps_an_underscore_the_display_already_had
|
||||
|
||||
Rival: `.replace("__", "_")` after the space step, which reads answer 21 as
|
||||
"no doubled underscores". Only this test fails: `left: "a_b"`. The rule
|
||||
collapses runs of *spaces*. An underscore that was in the display is kept.
|
||||
|
||||
### model::id::tests::the_suffix_is_the_next_counter_value_not_the_lowest_free_number
|
||||
|
||||
Rival: the lowest free `<base>-<n>` for each base, which revision 5 of the ADR
|
||||
proposed and answer 28 replaced. Three tests fail: this one (`"west-1"` against
|
||||
`"west-2"`), `an_empty_slug_takes_its_kind_and_the_shared_counter` (`"item-1"`)
|
||||
and `a_slug_taken_by_another_kind_takes_a_suffix` (`"time_period-1"`). Only
|
||||
interleaving two bases separates one shared counter from per-base numbering,
|
||||
and this test is the one that does it on purpose.
|
||||
|
||||
### model::id::tests::a_claimed_id_keeps_its_spelling_and_generation_steps_past_it
|
||||
|
||||
Rival: `claim` validates the id but does not record it. This test and
|
||||
`claim_refuses_a_duplicate_a_malformed_and_a_reserved_id` fail. The second
|
||||
`East` is issued `east-1`, the file's own id, which is the collision answer 29
|
||||
forbids: "doing otherwise will corrupt the data section".
|
||||
|
||||
### model::id::tests::generated_ids_are_distinct_well_formed_and_never_reserved
|
||||
|
||||
Rival: `generate` does not record the id it issues, so nothing is retired. Five
|
||||
tests fail, this proptest among them (`reissued`), along with every
|
||||
suffix-order test. The proptest is the one that states the property itself: no
|
||||
id is issued twice in a session, whatever the displays.
|
||||
|
||||
### model::id::tests::two_allocators_fed_alike_generate_alike
|
||||
|
||||
This test builds two allocators independently, so they share no hash state,
|
||||
rather than calling one twice. `seen` is a `HashSet`, but it is only ever
|
||||
queried and never iterated, so its order cannot reach an id (gotcha 18). No
|
||||
rival of the current shape breaks this test. It guards against a future
|
||||
change that picks a candidate by walking `seen`.
|
||||
|
||||
### model::id::tests::a_long_claimed_run_of_suffixes_is_walked_without_recursion
|
||||
|
||||
This is the Codex P2 on PR #318 (review of `a2ddb06e`), reproduced by the adversarial review of
|
||||
`689480bf`. `IdAllocator::suffixed` recursed once per taken candidate, and the
|
||||
call was not in tail position. A new-version file saved from a non-ASCII import
|
||||
claims `item-1` … `item-N`, and the counter restarts at 1 on load, so the
|
||||
next empty-slug generation walks all N. Against the recursive version this test
|
||||
(N = 20,000) aborted the whole test binary: `has overflowed its stack` /
|
||||
`SIGABRT`. It is an abort, not a failed assertion, so no test could catch it.
|
||||
The loop passes, and still advances the counter on every candidate it tries.
|
||||
The second assertion, `category-20002`, pins that: a loop that restarted from
|
||||
a local counter on each call would answer `category-1`.
|
||||
|
||||
### model::id::tests::claim_refuses_an_id_that_only_begins_like_a_virtual_one
|
||||
|
||||
Found by the adversarial review of `689480bf`. With `claim` rewritten to
|
||||
exempt any id that begins with a virtual id (`VIRTUAL_IDS.iter().any(|v|
|
||||
id.starts_with(v))`), all 11 earlier tests passed, because
|
||||
`claim_refuses_a_duplicate_a_malformed_and_a_reserved_id` tries only `_draft`.
|
||||
This test fails on that rival (`left: Ok(())` for `_indexer`). The match is
|
||||
exact by the ADR's own wording: answer 23 says no other id may begin with `_`.
|
||||
|
||||
### model::id::tests::a_display_with_leading_spaces_never_slugs_into_the_reserved_space
|
||||
|
||||
Rival, from the same review: strip the leading `_` before the space step, so
|
||||
that ` Draft` becomes `_draft`. This test fails (`left: "_draft"`), and so does
|
||||
`generated_ids_are_distinct_well_formed_and_never_reserved`. Before this test,
|
||||
the proptest was the only thing that saw the rival.
|
||||
|
||||
## Displays beside keys (ADR 007 clause 2, improvise-cq1q.3, first slice)
|
||||
|
||||
Each renderer test below builds a projection whose display differs from its key.
|
||||
The test-only way to get one is `set_display`, since no production path makes
|
||||
them differ yet. Every test failed before the change, with the key drawn where
|
||||
the display should be: `left: "Region"` against `right: "Sales Area"` in the
|
||||
projection and wasm tests, and a missing "Sales Area" or "Overview" in the TUI
|
||||
buffers.
|
||||
|
||||
### ui::project::tests::the_category_panel_shows_a_display_and_keeps_the_key_commands_name
|
||||
|
||||
It finds the row by `name == "Region"` before asserting on the display. A rival
|
||||
that writes the display into `name` fails the lookup, which covers the half that
|
||||
keeps commands working. `_Measure` is checked to fall back to its own key.
|
||||
|
||||
### ui::view_panel::tests::a_view_row_draws_its_display_and_marks_the_active_view_by_its_key
|
||||
|
||||
Rival: the active marker compares `summary.display == active_view`. This test
|
||||
fails on that rival (no `▶ Overview`), because the active view is a key
|
||||
(`ViewState::active_view`) and its display differs from it.
|
||||
|
||||
### render::tests::the_panels_paint_displays_and_keep_keys_for_what_a_command_names
|
||||
|
||||
The browser half of the same three panels. It also asserts that the view row is
|
||||
still `active` when found by key.
|
||||
|
||||
@@ -204,10 +204,15 @@ mod tests {
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.buffers = &bufs;
|
||||
let effects = CommitExport.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2);
|
||||
// 3 effects: SetBuffer (clear), ChangeMode, ExportCsv (improvise-2mqz).
|
||||
assert_eq!(effects.len(), 3);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(dbg.contains("ExportCsv"), "Expected ExportCsv, got: {dbg}");
|
||||
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
|
||||
assert!(
|
||||
dbg.contains("SetBuffer { name: \"export\", value: \"\" }"),
|
||||
"Expected buffer clear, got: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.find("Normal").unwrap() < dbg.find("ExportCsv").unwrap(),
|
||||
"the mode change must precede the write an abort can reach: {dbg}"
|
||||
@@ -1270,9 +1275,13 @@ impl Cmd for CommitSave {
|
||||
effect::change_mode(AppMode::Normal),
|
||||
];
|
||||
}
|
||||
// The mode change comes **first**, or a failed `SaveAs` aborts with the
|
||||
// minibuffer still drawn over its error (Copilot, PR #27).
|
||||
// Clear before `SaveAs`, whose failure aborts; this command owns the clear
|
||||
// because its LocalThenRequest cannot live in a sequence (improvise-2mqz).
|
||||
vec![
|
||||
Box::new(effect::SetBuffer {
|
||||
name: "save".into(),
|
||||
value: String::new(),
|
||||
}),
|
||||
effect::change_mode(AppMode::Normal),
|
||||
Box::new(effect::SaveAs(std::path::PathBuf::from(path))),
|
||||
]
|
||||
@@ -1294,9 +1303,13 @@ impl Cmd for CommitExport {
|
||||
effect::change_mode(AppMode::Normal),
|
||||
];
|
||||
}
|
||||
// The mode change comes **first**, or a failed export aborts with the
|
||||
// minibuffer still drawn over its error (gotcha 21).
|
||||
// Clear before export, whose failure aborts; this command owns the clear
|
||||
// because its LocalThenRequest cannot live in a sequence (improvise-2mqz).
|
||||
vec![
|
||||
Box::new(effect::SetBuffer {
|
||||
name: "export".into(),
|
||||
value: String::new(),
|
||||
}),
|
||||
effect::change_mode(AppMode::Normal),
|
||||
Box::new(effect::ExportCsv(std::path::PathBuf::from(path))),
|
||||
]
|
||||
|
||||
@@ -137,6 +137,7 @@ mod tests {
|
||||
fn cat_name_works_for_both_variants() {
|
||||
let header = CatTreeEntry::Category {
|
||||
name: "Region".into(),
|
||||
display: "Region".into(),
|
||||
item_count: 2,
|
||||
expanded: false,
|
||||
axis: crate::view::Axis::None,
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::ui::app::App;
|
||||
use crate::ui::effect;
|
||||
use crate::ui::project::{Applied, Broadcast, Dirty, SessionId, Sessions};
|
||||
use crate::ui::reduce::{self, Checked, CheckedBatch};
|
||||
use improvise_protocol::{Command, Rejection};
|
||||
use improvise_protocol::{AppMode, Command, Rejection};
|
||||
|
||||
use improvise_protocol::{ClientFrame, ServerFrame};
|
||||
|
||||
@@ -87,6 +87,8 @@ impl Host {
|
||||
// resolution diverges (improvise-bpq2, gotcha 47).
|
||||
ClientFrame::Key { .. } => Vec::new(),
|
||||
ClientFrame::Save => self.save(id),
|
||||
ClientFrame::DownloadDocument { filename } => self.download_document(id, filename),
|
||||
ClientFrame::DownloadCsv { filename } => self.download_csv(id, filename),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +115,67 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the document to `.improv` format and return the bytes for download
|
||||
/// after closing the prompt in the session mirror (improvise-2mqz).
|
||||
fn download_document(&mut self, id: SessionId, filename: String) -> Vec<Addressed> {
|
||||
let Some(mut answer) = self.close_download_prompt(id, "save") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let bytes =
|
||||
improvise_io::persistence::format_md(&self.app.model_state.workbook).into_bytes();
|
||||
answer.push(Addressed {
|
||||
to: id,
|
||||
msg: ServerFrame::DocumentBytes { filename, bytes },
|
||||
});
|
||||
answer
|
||||
}
|
||||
|
||||
/// Render the requesting session's view as downloadable CSV bytes.
|
||||
/// Close its prompt mirror first; reject only if rendering fails (improvise-2mqz).
|
||||
fn download_csv(&mut self, id: SessionId, filename: String) -> Vec<Addressed> {
|
||||
let Some(view_name) = self
|
||||
.sessions
|
||||
.get(id)
|
||||
.map(|session| session.view().active_view.clone())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(mut answer) = self.close_download_prompt(id, "export") else {
|
||||
return Vec::new();
|
||||
};
|
||||
match improvise_io::persistence::render_csv(&self.app.model_state.workbook, &view_name) {
|
||||
Ok(bytes) => answer.push(Addressed {
|
||||
to: id,
|
||||
msg: ServerFrame::CsvBytes { filename, bytes },
|
||||
}),
|
||||
Err(_) => answer.push(Addressed {
|
||||
to: id,
|
||||
msg: ServerFrame::Rejected(Rejection::ExportFailed),
|
||||
}),
|
||||
}
|
||||
answer
|
||||
}
|
||||
|
||||
/// The browser folded these before sending the download request; mirror
|
||||
/// that fold so a later projection cannot restore the prompt.
|
||||
fn close_download_prompt(
|
||||
&mut self,
|
||||
id: SessionId,
|
||||
buffer: &'static str,
|
||||
) -> Option<Vec<Addressed>> {
|
||||
self.sessions.get(id)?;
|
||||
Some(self.commands(
|
||||
id,
|
||||
&[
|
||||
Command::SetBuffer {
|
||||
name: buffer.into(),
|
||||
value: String::new(),
|
||||
},
|
||||
Command::ChangeMode(AppMode::Normal),
|
||||
],
|
||||
))
|
||||
}
|
||||
|
||||
/// Fan out whatever moved, for a host that changed the document outside
|
||||
/// `handle` — a worker's own persist is the caller (improvise-z754).
|
||||
pub fn project_all(&mut self) -> Vec<Addressed> {
|
||||
@@ -435,9 +498,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Neither answer carries state: one applied nothing, and what
|
||||
// the other applied arrived as projections before it.
|
||||
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
|
||||
// These carry no foldable state: downloads are consumed by the
|
||||
// browser, while other results arrived as projections or did nothing.
|
||||
ServerFrame::Rejected(_)
|
||||
| ServerFrame::Accepted
|
||||
| ServerFrame::DocumentBytes { .. }
|
||||
| ServerFrame::CsvBytes { .. } => {}
|
||||
}
|
||||
}
|
||||
grid
|
||||
@@ -2713,6 +2779,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-2mqz: view selection belongs to a session, so an export must
|
||||
/// not silently use the document's default view or another client's view.
|
||||
#[test]
|
||||
fn a_csv_download_exports_the_requesting_sessions_active_view() {
|
||||
let mut host = host();
|
||||
let second = host.app.model_state.workbook.create_view("Second");
|
||||
second.set_axis("Region", Axis::Row);
|
||||
second.set_axis("_Measure", Axis::Column);
|
||||
second.hide_item("Region", "South");
|
||||
let (requester, _) = host.connect(10, 10);
|
||||
let (bystander, _) = host.connect(10, 10);
|
||||
|
||||
host.handle(
|
||||
requester,
|
||||
ClientFrame::command(Command::SwitchView("Second".into())),
|
||||
);
|
||||
assert_eq!(
|
||||
host.sessions().get(requester).unwrap().view().active_view,
|
||||
"Second",
|
||||
"setup: the requester must be on its session-local view"
|
||||
);
|
||||
assert_eq!(
|
||||
host.sessions().get(bystander).unwrap().view().active_view,
|
||||
"Default",
|
||||
"setup: the second session must remain on the default view"
|
||||
);
|
||||
assert_eq!(
|
||||
host.app().model_state.workbook.active_view,
|
||||
"Default",
|
||||
"setup: the document's active view must differ from the requester"
|
||||
);
|
||||
|
||||
let answer = host.handle(
|
||||
requester,
|
||||
ClientFrame::DownloadCsv {
|
||||
filename: "second.csv".into(),
|
||||
},
|
||||
);
|
||||
let expected =
|
||||
improvise_io::persistence::render_csv(&host.app().model_state.workbook, "Second")
|
||||
.unwrap();
|
||||
let default =
|
||||
improvise_io::persistence::render_csv(&host.app().model_state.workbook, "Default")
|
||||
.unwrap();
|
||||
assert_ne!(expected, default, "setup: the two exports must differ");
|
||||
assert_eq!(
|
||||
to(&answer, requester),
|
||||
vec![ServerFrame::CsvBytes {
|
||||
filename: "second.csv".into(),
|
||||
bytes: expected,
|
||||
}]
|
||||
);
|
||||
assert!(
|
||||
to(&answer, bystander).is_empty(),
|
||||
"the download is owed only to its requester: {answer:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── disconnect ──────────────────────────────────────────────────────
|
||||
|
||||
/// AC 4: a dropped client stops being projected to, and a late frame
|
||||
|
||||
@@ -426,6 +426,11 @@ fn non_item(category: &str, item: &str) -> CellId {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a person sees for a category key; the key itself when no category holds it.
|
||||
fn category_display<'a>(model: &'a Model, name: &'a str) -> &'a str {
|
||||
model.category(name).map_or(name, |c| c.display())
|
||||
}
|
||||
|
||||
/// The categories the category panel draws: **every** category, virtual ones
|
||||
/// included, with their items and their axis (improvise-vjms, gotcha 7).
|
||||
pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
|
||||
@@ -434,6 +439,7 @@ pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
|
||||
.into_iter()
|
||||
.map(|name| CategoryRow {
|
||||
name: name.to_string(),
|
||||
display: category_display(model, name).to_string(),
|
||||
items: model.effective_item_names(name),
|
||||
// `try_axis_of`, not `axis_of`: a view that has never seen a category
|
||||
// has no entry and `axis_of` panics. Unseen shows as `None`.
|
||||
@@ -463,6 +469,7 @@ pub fn view_summaries(app: &App) -> Vec<ViewSummary> {
|
||||
.iter()
|
||||
.map(|(name, view)| ViewSummary {
|
||||
name: name.clone(),
|
||||
display: view.display().to_string(),
|
||||
axes: view
|
||||
.category_axes
|
||||
.iter()
|
||||
@@ -484,6 +491,7 @@ pub fn tiles_of(model: &Model, view: &View) -> Vec<TileEntry> {
|
||||
.into_iter()
|
||||
.map(|cat| TileEntry {
|
||||
category: cat.to_string(),
|
||||
display: category_display(model, cat).to_string(),
|
||||
axis: view.try_axis_of(cat).unwrap_or(Axis::None),
|
||||
kind: model
|
||||
.category(cat)
|
||||
@@ -1793,6 +1801,46 @@ mod tests {
|
||||
app
|
||||
}
|
||||
|
||||
/// One category and the active view each shown under a display that differs from their key.
|
||||
fn app_with_displays_unlike_their_keys() -> App {
|
||||
let mut app = app_with_data();
|
||||
let wb = &mut app.model_state.workbook;
|
||||
wb.model
|
||||
.category_mut("Region")
|
||||
.unwrap()
|
||||
.set_display("Sales Area");
|
||||
wb.active_view_mut().set_display("Overview");
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_category_panel_shows_a_display_and_keeps_the_key_commands_name() {
|
||||
let app = app_with_displays_unlike_their_keys();
|
||||
let rows = category_rows(&app.model_state.workbook.model, app.view());
|
||||
let region = rows.iter().find(|r| r.name == "Region").unwrap();
|
||||
assert_eq!(region.display, "Sales Area");
|
||||
let measure = rows.iter().find(|r| r.name == "_Measure").unwrap();
|
||||
assert_eq!(measure.display, "_Measure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_shows_a_display_and_keeps_the_key_commands_name() {
|
||||
let app = app_with_displays_unlike_their_keys();
|
||||
let tiles = tiles_of(&app.model_state.workbook.model, app.view());
|
||||
let region = tiles.iter().find(|t| t.category == "Region").unwrap();
|
||||
assert_eq!(region.display, "Sales Area");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_list_shows_a_display_and_keeps_the_key_commands_name() {
|
||||
let app = app_with_displays_unlike_their_keys();
|
||||
let active = app.model_state.workbook.active_view.clone();
|
||||
let summaries = view_summaries(&app);
|
||||
let summary = summaries.iter().find(|s| s.name == active).unwrap();
|
||||
assert_eq!(summary.display, "Overview");
|
||||
assert_ne!(summary.name, "Overview");
|
||||
}
|
||||
|
||||
/// A pivot-shaped default view plus a records-shaped second one, so a
|
||||
/// switch between them crosses the boundary `settled_mode` watches.
|
||||
fn app_with_a_records_shaped_second_view() -> App {
|
||||
|
||||
@@ -16,8 +16,6 @@ enum Verdict {
|
||||
Frame(&'static str),
|
||||
/// Deliberately off the wire, and the argument.
|
||||
Off(&'static str),
|
||||
/// Missing, and the ticket that owns closing it.
|
||||
Gap(&'static str),
|
||||
}
|
||||
|
||||
/// Checked in all directions: an entry must name a real effect, must not be on
|
||||
@@ -29,14 +27,8 @@ const NOT_ON_THE_WIRE: &[(&str, Verdict)] = &[
|
||||
// Its text; the refusal flag is one host batch's outcome (context/code/cli.md §10).
|
||||
("Refuse", Verdict::Command("SetStatus")),
|
||||
("Save", Verdict::Frame("Save")),
|
||||
(
|
||||
"SaveAs",
|
||||
Verdict::Gap("improvise-2mqz: writes the host's Storage and has no frame"),
|
||||
),
|
||||
(
|
||||
"ExportCsv",
|
||||
Verdict::Gap("improvise-2mqz: writes the host's Storage and has no frame"),
|
||||
),
|
||||
("SaveAs", Verdict::Frame("DownloadDocument")),
|
||||
("ExportCsv", Verdict::Frame("DownloadCsv")),
|
||||
(
|
||||
"ApplyAndClearDrill",
|
||||
Verdict::Off("operand is one session's staged drill edits (gotcha 38, improvise-3x3u)"),
|
||||
@@ -134,7 +126,7 @@ fn the_verdict_list_names_only_real_effects_and_real_variants() {
|
||||
frames.iter().any(|f| f == variant),
|
||||
"{name}'s verdict names ClientFrame::{variant}, which does not exist"
|
||||
),
|
||||
Verdict::Off(why) | Verdict::Gap(why) => {
|
||||
Verdict::Off(why) => {
|
||||
assert!(!why.is_empty(), "{name}'s verdict gives no argument")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ serde = { version = "1", features = ["derive"] }
|
||||
# (ADR 002). Already in the tree as a transitive dependency; no_std and
|
||||
# wasm-clean.
|
||||
siphasher = "1"
|
||||
# Unicode full case folding for slugify (ADR 007 answer 26). no_std, no dependencies.
|
||||
unicase = "2.9"
|
||||
|
||||
[features]
|
||||
# Exposes the fold-evaluation counter to other crates' tests (improvise-0jw5).
|
||||
|
||||
@@ -8,19 +8,27 @@ pub type ItemId = usize;
|
||||
pub struct Item {
|
||||
pub id: ItemId,
|
||||
pub name: String,
|
||||
/// What a person sees; the key until a rename (ADR 007 clause 2).
|
||||
display: String,
|
||||
/// Parent group name, if any
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
pub fn new(id: ItemId, name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
Self {
|
||||
id,
|
||||
name: name.into(),
|
||||
display: name.clone(),
|
||||
name,
|
||||
group: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
pub fn with_group(mut self, group: impl Into<String>) -> Self {
|
||||
self.group = Some(group.into());
|
||||
self
|
||||
@@ -129,6 +137,8 @@ impl CategoryKind {
|
||||
pub struct Category {
|
||||
pub id: CategoryId,
|
||||
pub name: String,
|
||||
/// What a person sees; the key until a rename (ADR 007 clause 2).
|
||||
display: String,
|
||||
/// Items in insertion order
|
||||
pub items: IndexMap<String, Item>,
|
||||
/// Named groups (hierarchy nodes)
|
||||
@@ -141,9 +151,11 @@ pub struct Category {
|
||||
|
||||
impl Category {
|
||||
pub fn new(id: CategoryId, name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
Self {
|
||||
id,
|
||||
name: name.into(),
|
||||
display: name.clone(),
|
||||
name,
|
||||
items: IndexMap::new(),
|
||||
groups: Vec::new(),
|
||||
next_item_id: 0,
|
||||
@@ -151,6 +163,23 @@ impl Category {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
/// Changes what a person sees and nothing else: the key does not move.
|
||||
pub fn set_display(&mut self, display: impl Into<String>) {
|
||||
self.display = display.into();
|
||||
}
|
||||
|
||||
/// Changes an item's display; false when the category has no such item.
|
||||
pub fn set_item_display(&mut self, item: &str, display: impl Into<String>) -> bool {
|
||||
self.items
|
||||
.get_mut(item)
|
||||
.map(|i| i.display = display.into())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn with_kind(mut self, kind: CategoryKind) -> Self {
|
||||
self.kind = kind;
|
||||
self
|
||||
@@ -264,6 +293,20 @@ mod tests {
|
||||
Category::new(0, "Region")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_display_starts_as_the_key_and_changing_it_moves_no_key() {
|
||||
let mut c = cat();
|
||||
c.add_item("East");
|
||||
assert_eq!(c.display(), "Region");
|
||||
assert_eq!(c.items["East"].display(), "East");
|
||||
c.set_display("Sales Area");
|
||||
assert!(c.set_item_display("East", "Eastern"));
|
||||
assert_eq!((c.name.as_str(), c.display()), ("Region", "Sales Area"));
|
||||
assert_eq!(c.items["East"].display(), "Eastern");
|
||||
assert_eq!(c.items["East"].name, "East");
|
||||
assert!(!c.set_item_display("West", "Western"), "no such item");
|
||||
}
|
||||
|
||||
/// improvise-2lh: call sites ask `of_name` rather than compare literals,
|
||||
/// so both directions of the mapping must agree.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! How an id is made: `slugify`, and the document's `IdAllocator` (ADR 007 clause 4).
|
||||
//! Reasoning: `context/code/core.md` §20.
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// What a generated id names; spells the `<kind>` of an empty slug's `<kind>-<n>`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IdKind {
|
||||
Category,
|
||||
Item,
|
||||
Formula,
|
||||
View,
|
||||
}
|
||||
|
||||
impl IdKind {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
IdKind::Category => "category",
|
||||
IdKind::Item => "item",
|
||||
IdKind::Formula => "formula",
|
||||
IdKind::View => "view",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The only ids that may begin with `_` (answer 23).
|
||||
pub const VIRTUAL_IDS: [&str; 3] = ["_index", "_dim", "_measure"];
|
||||
|
||||
/// A display name's slug; empty when nothing survives (answers 16, 18, 21, 23, 26).
|
||||
pub fn slugify(display: &str) -> String {
|
||||
let folded = unicase::UniCase::new(display).to_folded_case();
|
||||
let kept: String = underscore_space_runs(&folded)
|
||||
.chars()
|
||||
.filter(|&c| in_id_alphabet(c))
|
||||
.collect();
|
||||
kept.trim_start_matches('_').to_string()
|
||||
}
|
||||
|
||||
fn underscore_space_runs(s: &str) -> String {
|
||||
let (out, _) = s
|
||||
.chars()
|
||||
.fold((String::new(), false), |(mut out, prev), c| {
|
||||
let space = c == ' ';
|
||||
if !(space && prev) {
|
||||
out.push(if space { '_' } else { c });
|
||||
}
|
||||
(out, space)
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn in_id_alphabet(c: char) -> bool {
|
||||
matches!(c, 'a'..='z' | '0'..='9' | '-' | '_')
|
||||
}
|
||||
|
||||
/// Why a hand-written id cannot enter the document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ClaimError {
|
||||
Duplicate(String),
|
||||
Malformed(String),
|
||||
Reserved(String),
|
||||
}
|
||||
|
||||
/// One document's ids: every id it has held this session, and the `gensym` counter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdAllocator {
|
||||
next: u64,
|
||||
seen: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Default for IdAllocator {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
next: 1,
|
||||
seen: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IdAllocator {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Admit a hand-written id verbatim: it is canonical and never renamed (answer 29).
|
||||
pub fn claim(&mut self, id: &str) -> Result<(), ClaimError> {
|
||||
let refusal = if id.is_empty() || !id.chars().all(in_id_alphabet) {
|
||||
Some(ClaimError::Malformed(id.to_string()))
|
||||
} else if id.starts_with('_') && !VIRTUAL_IDS.contains(&id) {
|
||||
Some(ClaimError::Reserved(id.to_string()))
|
||||
} else if self.seen.contains(id) {
|
||||
Some(ClaimError::Duplicate(id.to_string()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match refusal {
|
||||
Some(e) => Err(e),
|
||||
None => {
|
||||
self.seen.insert(id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh id for `display`, suffixed from the counter only when the slug is taken.
|
||||
pub fn generate(&mut self, kind: IdKind, display: &str) -> String {
|
||||
let slug = slugify(display);
|
||||
let id = if slug.is_empty() {
|
||||
self.suffixed(kind.name())
|
||||
} else if self.seen.contains(&slug) {
|
||||
self.suffixed(&slug)
|
||||
} else {
|
||||
slug
|
||||
};
|
||||
self.seen.insert(id.clone());
|
||||
id
|
||||
}
|
||||
|
||||
fn suffixed(&mut self, base: &str) -> String {
|
||||
loop {
|
||||
let candidate = format!("{base}-{}", self.next);
|
||||
self.next += 1;
|
||||
if !self.seen.contains(&candidate) {
|
||||
break candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn slugify_follows_the_adrs_examples() {
|
||||
let cases = [
|
||||
("East", "east"),
|
||||
("Time Period", "time_period"),
|
||||
("Coastal_East", "coastal_east"),
|
||||
("Q1-2024", "q1-2024"),
|
||||
("Region(code)", "regioncode"),
|
||||
("Café", "caf"),
|
||||
("_Draft", "draft"),
|
||||
("Ü", ""),
|
||||
];
|
||||
for (display, slug) in cases {
|
||||
assert_eq!(slugify(display), slug, "slugify({display:?})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_case_folds_rather_than_lowercases() {
|
||||
assert_eq!(slugify("Straße"), "strasse");
|
||||
assert_eq!(slugify("ſtate"), "state");
|
||||
assert_eq!(slugify("file"), "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_collapses_a_run_of_spaces_to_one_underscore() {
|
||||
assert_eq!(slugify("Time Period"), "time_period");
|
||||
assert_eq!(slugify("East "), "east_");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_keeps_an_underscore_the_display_already_had() {
|
||||
assert_eq!(slugify("a_ b"), "a__b");
|
||||
assert_eq!(slugify("a__b"), "a__b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_slug_takes_its_kind_and_the_shared_counter() {
|
||||
let mut ids = IdAllocator::new();
|
||||
assert_eq!(ids.generate(IdKind::Category, "Ü"), "category-1");
|
||||
assert_eq!(ids.generate(IdKind::Item, "Ö"), "item-2");
|
||||
assert_eq!(ids.generate(IdKind::View, ""), "view-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slug_taken_by_another_kind_takes_a_suffix() {
|
||||
let mut ids = IdAllocator::new();
|
||||
assert_eq!(ids.generate(IdKind::Category, "East"), "east");
|
||||
assert_eq!(ids.generate(IdKind::Item, "East"), "east-1");
|
||||
assert_eq!(ids.generate(IdKind::View, "Time Period"), "time_period");
|
||||
assert_eq!(
|
||||
ids.generate(IdKind::Formula, "Time_Period"),
|
||||
"time_period-2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_suffix_is_the_next_counter_value_not_the_lowest_free_number() {
|
||||
let mut ids = IdAllocator::new();
|
||||
assert_eq!(ids.generate(IdKind::Item, "East"), "east");
|
||||
assert_eq!(ids.generate(IdKind::Item, "West"), "west");
|
||||
assert_eq!(ids.generate(IdKind::Item, "East"), "east-1");
|
||||
assert_eq!(ids.generate(IdKind::Item, "West"), "west-2");
|
||||
assert_eq!(ids.generate(IdKind::Item, "EAST"), "east-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_claimed_id_keeps_its_spelling_and_generation_steps_past_it() {
|
||||
let mut ids = IdAllocator::new();
|
||||
assert_eq!(ids.claim("east-1"), Ok(()));
|
||||
assert_eq!(ids.generate(IdKind::Item, "East"), "east");
|
||||
assert_eq!(ids.generate(IdKind::Item, "East"), "east-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_refuses_a_duplicate_a_malformed_and_a_reserved_id() {
|
||||
let mut ids = IdAllocator::new();
|
||||
assert_eq!(ids.claim("east"), Ok(()));
|
||||
assert_eq!(ids.claim("east"), Err(ClaimError::Duplicate("east".into())));
|
||||
assert_eq!(ids.claim("East"), Err(ClaimError::Malformed("East".into())));
|
||||
assert_eq!(ids.claim(""), Err(ClaimError::Malformed(String::new())));
|
||||
assert_eq!(
|
||||
ids.claim("_draft"),
|
||||
Err(ClaimError::Reserved("_draft".into()))
|
||||
);
|
||||
assert_eq!(ids.claim("_measure"), Ok(()));
|
||||
let generated = ids.generate(IdKind::Category, "West");
|
||||
assert_eq!(
|
||||
ids.claim(&generated),
|
||||
Err(ClaimError::Duplicate("west".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_refuses_an_id_that_only_begins_like_a_virtual_one() {
|
||||
let mut ids = IdAllocator::new();
|
||||
for lookalike in ["_indexer", "_measure-1", "_dim_2", "_measures"] {
|
||||
assert_eq!(
|
||||
ids.claim(lookalike),
|
||||
Err(ClaimError::Reserved(lookalike.into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_display_with_leading_spaces_never_slugs_into_the_reserved_space() {
|
||||
assert_eq!(slugify(" Draft"), "draft");
|
||||
assert_eq!(slugify(" _ Draft"), "draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_claimed_run_of_suffixes_is_walked_without_recursion() {
|
||||
let mut ids = IdAllocator::new();
|
||||
for n in 1..=20_000 {
|
||||
assert_eq!(ids.claim(&format!("item-{n}")), Ok(()));
|
||||
}
|
||||
assert_eq!(ids.generate(IdKind::Item, "Ü"), "item-20001");
|
||||
assert_eq!(ids.generate(IdKind::Category, ""), "category-20002");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_allocators_fed_alike_generate_alike() {
|
||||
let displays = [
|
||||
"East",
|
||||
"east",
|
||||
"Ü",
|
||||
"Time Period",
|
||||
"Time_Period",
|
||||
"",
|
||||
"EAST",
|
||||
];
|
||||
let run = || {
|
||||
let mut ids = IdAllocator::new();
|
||||
displays
|
||||
.iter()
|
||||
.map(|d| ids.generate(IdKind::Item, d))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(run(), run());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn generated_ids_are_distinct_well_formed_and_never_reserved(
|
||||
displays in proptest::collection::vec(".{0,6}", 0..24)
|
||||
) {
|
||||
let mut ids = IdAllocator::new();
|
||||
let mut out = HashSet::new();
|
||||
for d in &displays {
|
||||
let id = ids.generate(IdKind::Item, d);
|
||||
prop_assert!(!id.is_empty() && !id.starts_with('_'), "{id:?}");
|
||||
prop_assert!(id.chars().all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '_')));
|
||||
prop_assert!(out.insert(id.clone()), "reissued {id:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod category;
|
||||
pub mod cell;
|
||||
pub mod id;
|
||||
pub mod record;
|
||||
pub mod symbol;
|
||||
pub mod types;
|
||||
|
||||
@@ -41,6 +41,8 @@ pub enum GroupFold {
|
||||
#[derive(Clone)]
|
||||
pub struct View {
|
||||
pub name: String,
|
||||
/// What a person sees; the key until a rename (ADR 007 clause 2).
|
||||
display: String,
|
||||
/// Axis assignment for each category
|
||||
pub category_axes: IndexMap<String, Axis>,
|
||||
/// For page axis: selected item per category
|
||||
@@ -65,6 +67,7 @@ impl std::fmt::Debug for View {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let View {
|
||||
name,
|
||||
display,
|
||||
category_axes,
|
||||
page_selections,
|
||||
hidden_items,
|
||||
@@ -86,6 +89,7 @@ impl std::fmt::Debug for View {
|
||||
.collect();
|
||||
f.debug_struct("View")
|
||||
.field("name", name)
|
||||
.field("display", display)
|
||||
.field("category_axes", category_axes)
|
||||
.field("page_selections", &pages)
|
||||
.field("hidden_items", &sorted(hidden_items))
|
||||
@@ -103,9 +107,20 @@ impl View {
|
||||
VIRTUAL_VIEW_NAMES.contains(&self.name.as_str())
|
||||
}
|
||||
|
||||
pub fn display(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
/// Changes what a person sees and nothing else: the key does not move.
|
||||
pub fn set_display(&mut self, display: impl Into<String>) {
|
||||
self.display = display.into();
|
||||
}
|
||||
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
Self {
|
||||
name: name.into(),
|
||||
display: name.clone(),
|
||||
name,
|
||||
category_axes: IndexMap::new(),
|
||||
page_selections: HashMap::new(),
|
||||
hidden_items: HashMap::new(),
|
||||
@@ -375,6 +390,14 @@ mod tests {
|
||||
use crate::view::Axis;
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
#[test]
|
||||
fn a_view_display_starts_as_the_key_and_changing_it_moves_no_key() {
|
||||
let mut v = View::new("Default");
|
||||
assert_eq!(v.display(), "Default");
|
||||
v.set_display("Overview");
|
||||
assert_eq!((v.name.as_str(), v.display()), ("Default", "Overview"));
|
||||
}
|
||||
|
||||
/// improvise-vw47: the count is the starting guess, never the answer.
|
||||
/// What a taken name costs: `context/test-evidence/core.md`.
|
||||
#[test]
|
||||
|
||||
@@ -22,9 +22,9 @@ pub fn export_csv_to(
|
||||
storage.write(path, &render_csv(workbook, view_name)?)
|
||||
}
|
||||
|
||||
/// Render a view as CSV bytes. Separated from writing so the bytes can go to
|
||||
/// any [`Storage`] backend, not just a file (improvise-fuj).
|
||||
fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
|
||||
/// Render a view as CSV bytes for any [`Storage`] backend or a download frame
|
||||
/// (improvise-fuj, improvise-2mqz).
|
||||
pub fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
|
||||
let view = workbook
|
||||
.views
|
||||
.get(view_name)
|
||||
|
||||
@@ -36,7 +36,7 @@ mod test_helpers {
|
||||
}
|
||||
}
|
||||
|
||||
pub use export::{export_csv, export_csv_to};
|
||||
pub use export::{export_csv, export_csv_to, render_csv};
|
||||
pub use format::format_md;
|
||||
pub use parse::parse_md;
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ pub enum Rejection {
|
||||
/// A [`ClientFrame::Save`](crate::ClientFrame::Save) whose write failed.
|
||||
/// No message: [`Rejection`] is `Copy` and an I/O error's text is not.
|
||||
SaveFailed,
|
||||
/// A [`ClientFrame::DownloadCsv`](crate::ClientFrame::DownloadCsv) failed
|
||||
/// to render the view (improvise-2mqz). Typically the view was not found.
|
||||
ExportFailed,
|
||||
}
|
||||
|
||||
/// The basis a client holds, folded from the stream like any other widget
|
||||
@@ -131,7 +134,7 @@ impl Rejection {
|
||||
| Self::ChainBroken(_)
|
||||
| Self::MutationRefused
|
||||
| Self::ReadOnly => true,
|
||||
Self::NoDocumentPath | Self::SaveFailed => false,
|
||||
Self::NoDocumentPath | Self::SaveFailed | Self::ExportFailed => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +163,8 @@ impl Rejection {
|
||||
Self::MutationRefused => Self::ReadOnly,
|
||||
Self::ReadOnly => Self::NoDocumentPath,
|
||||
Self::NoDocumentPath => Self::SaveFailed,
|
||||
Self::SaveFailed => Self::DocumentReplaced,
|
||||
Self::SaveFailed => Self::ExportFailed,
|
||||
Self::ExportFailed => Self::DocumentReplaced,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,6 +514,7 @@ mod tests {
|
||||
live.fold(&Command::Project(Projection::CategoryTree(
|
||||
CategoryTreeUpdate::Categories(vec![CategoryRow {
|
||||
name: "Region".into(),
|
||||
display: "Region".into(),
|
||||
items: vec!["North".into(), "South".into()],
|
||||
axis: Axis::Row,
|
||||
}]),
|
||||
@@ -523,11 +524,13 @@ mod tests {
|
||||
])));
|
||||
live.fold(&Command::Project(Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
kind: crate::CategoryKind::Regular,
|
||||
axis: Axis::Row,
|
||||
}])));
|
||||
live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row)],
|
||||
}])));
|
||||
live.fold(&Command::SetSelected { row: 1, col: 1 });
|
||||
|
||||
@@ -23,6 +23,12 @@ pub enum ClientFrame {
|
||||
code: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
},
|
||||
/// Ask for document bytes named by SavePrompt; the browser downloads them
|
||||
/// via Blob + `<a download>` (improvise-2mqz).
|
||||
DownloadDocument { filename: String },
|
||||
/// Ask for active-view CSV bytes named by ExportPrompt; the browser downloads
|
||||
/// them via Blob + `<a download>` (improvise-2mqz).
|
||||
DownloadCsv { filename: String },
|
||||
}
|
||||
|
||||
impl ClientFrame {
|
||||
@@ -55,6 +61,12 @@ pub enum ServerFrame {
|
||||
/// The asker's mutating frame landed whole. Carries no state: everything
|
||||
/// it moved arrived as projections first — `context/code/protocol.md` §4.
|
||||
Accepted,
|
||||
/// Downloadable `.improv` bytes addressed to the asker alone; the filename
|
||||
/// is echoed from its request (improvise-2mqz).
|
||||
DocumentBytes { filename: String, bytes: Vec<u8> },
|
||||
/// Downloadable active-view CSV bytes addressed to the asker alone; the
|
||||
/// filename is echoed from its request (improvise-2mqz).
|
||||
CsvBytes { filename: String, bytes: Vec<u8> },
|
||||
}
|
||||
|
||||
impl ServerFrame {
|
||||
@@ -83,6 +95,12 @@ mod tests {
|
||||
)),
|
||||
ClientFrame::key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||||
ClientFrame::Save,
|
||||
ClientFrame::DownloadDocument {
|
||||
filename: "doc.improv".into(),
|
||||
},
|
||||
ClientFrame::DownloadCsv {
|
||||
filename: "export.csv".into(),
|
||||
},
|
||||
] {
|
||||
let json = serde_json::to_string(&frame).expect("serializes");
|
||||
assert_eq!(
|
||||
@@ -105,6 +123,14 @@ mod tests {
|
||||
))]),
|
||||
ServerFrame::Rejected(Rejection::LayoutMoved),
|
||||
ServerFrame::Accepted,
|
||||
ServerFrame::DocumentBytes {
|
||||
filename: "test.improv".into(),
|
||||
bytes: b"v2025-04-09\n# Test\n".to_vec(),
|
||||
},
|
||||
ServerFrame::CsvBytes {
|
||||
filename: "export.csv".into(),
|
||||
bytes: b"col1,col2\na,b\n".to_vec(),
|
||||
},
|
||||
] {
|
||||
let json = serde_json::to_string(&frame).expect("serializes");
|
||||
assert_eq!(
|
||||
|
||||
@@ -927,11 +927,13 @@ impl Keymaps {
|
||||
),
|
||||
);
|
||||
|
||||
// LocalThenRequest cannot live in a sequence, so these commands own
|
||||
// their buffer clears and are rebound directly (improvise-2mqz).
|
||||
set.insert(
|
||||
ModeKey::ExportPrompt,
|
||||
Arc::new(
|
||||
TextEntry::new(&AppMode::export_prompt(), "normal", global.clone())
|
||||
.commit(KeyCode::Enter, "commit-export", vec![])
|
||||
.rebind(KeyCode::Enter, "commit-export")
|
||||
.finish(),
|
||||
),
|
||||
);
|
||||
@@ -940,7 +942,7 @@ impl Keymaps {
|
||||
ModeKey::SavePrompt,
|
||||
Arc::new(
|
||||
TextEntry::new(&AppMode::save_prompt(), "normal", global.clone())
|
||||
.commit(KeyCode::Enter, "commit-save", vec![])
|
||||
.rebind(KeyCode::Enter, "commit-save")
|
||||
.finish(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -556,10 +556,12 @@ mod tests {
|
||||
Command::Project(Projection::Formulas(vec!["Profit = Revenue - Cost".into()])),
|
||||
Command::Project(Projection::ViewList(vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row)],
|
||||
}])),
|
||||
Command::Project(Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
kind: crate::CategoryKind::Regular,
|
||||
axis: Axis::Page,
|
||||
}])),
|
||||
|
||||
@@ -759,6 +759,8 @@ impl WidgetCache for GridCache {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CategoryRow {
|
||||
pub name: String,
|
||||
/// What the panel shows; `name` stays what a command names (ADR 007).
|
||||
pub display: String,
|
||||
pub items: Vec<String>,
|
||||
pub axis: Axis,
|
||||
}
|
||||
@@ -857,6 +859,8 @@ impl WidgetCache for FormulaCache {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ViewSummary {
|
||||
pub name: String,
|
||||
/// What the view panel shows; `name` stays what a command names.
|
||||
pub display: String,
|
||||
/// `(category, axis)` for the categories this view displays.
|
||||
pub axes: Vec<(String, Axis)>,
|
||||
}
|
||||
@@ -881,6 +885,8 @@ impl WidgetCache for ViewListCache {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TileEntry {
|
||||
pub category: String,
|
||||
/// What the tile shows; `category` stays what a command names.
|
||||
pub display: String,
|
||||
pub axis: Axis,
|
||||
pub kind: CategoryKind,
|
||||
}
|
||||
@@ -1072,6 +1078,7 @@ mod tests {
|
||||
fn tile(category: &str, axis: Axis, kind: CategoryKind) -> TileEntry {
|
||||
TileEntry {
|
||||
category: category.to_string(),
|
||||
display: category.to_string(),
|
||||
axis,
|
||||
kind,
|
||||
}
|
||||
@@ -1156,6 +1163,7 @@ mod tests {
|
||||
fn cat(name: &str, items: &[&str], axis: Axis) -> CategoryRow {
|
||||
CategoryRow {
|
||||
name: name.into(),
|
||||
display: name.into(),
|
||||
items: items.iter().map(|s| (*s).to_string()).collect(),
|
||||
axis,
|
||||
}
|
||||
@@ -1801,12 +1809,14 @@ mod tests {
|
||||
|
||||
let views = ViewListCache::folded(&[vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row)],
|
||||
}]]);
|
||||
assert_eq!(views.views[0].name, "Default");
|
||||
|
||||
let tiles = TileBarCache::folded(&[vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: Axis::Page,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
}]]);
|
||||
@@ -2201,10 +2211,12 @@ mod tests {
|
||||
Projection::Formulas(vec!["Profit = Revenue - Cost".into()]),
|
||||
Projection::ViewList(vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::None)],
|
||||
}]),
|
||||
Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: Axis::Page,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
}]),
|
||||
@@ -2242,6 +2254,7 @@ mod tests {
|
||||
Projection::Formulas(vec!["Profit = Revenue - Cost".into()]),
|
||||
Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: Axis::Page,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
}]),
|
||||
|
||||
@@ -27,6 +27,12 @@ pub enum KeyOutcome {
|
||||
/// Resolved here as a **request**: the caller sends the frame and folds
|
||||
/// nothing. Why it is not a `Command`: `context/code/clients.md`.
|
||||
Request(Request),
|
||||
/// Fold commands locally, then send a request: mode changes here and
|
||||
/// download bytes come from the server (improvise-2mqz).
|
||||
LocalThenRequest {
|
||||
cmds: Vec<Command>,
|
||||
request: Request,
|
||||
},
|
||||
/// Bound to a command this client cannot compose. Forwarded to the host,
|
||||
/// which today answers nothing — `context/code/clients.md`.
|
||||
Upstream(KeyEvent),
|
||||
@@ -37,10 +43,16 @@ pub enum KeyOutcome {
|
||||
|
||||
/// What a key may ask its **host** to do, as opposed to what it may compose.
|
||||
/// Narrow on purpose — the argument is in `context/code/clients.md`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Request {
|
||||
/// Write the document back where the host keeps it (improvise-368h).
|
||||
Save,
|
||||
/// Get the document as bytes for download (improvise-2mqz). The filename
|
||||
/// is what the user typed in the SavePrompt minibuffer.
|
||||
DownloadDocument { filename: String },
|
||||
/// Get the active view as CSV bytes for download (improvise-2mqz). The filename
|
||||
/// is what the user typed in the ExportPrompt minibuffer.
|
||||
DownloadCsv { filename: String },
|
||||
}
|
||||
|
||||
impl Request {
|
||||
@@ -49,6 +61,8 @@ impl Request {
|
||||
pub fn frame(self) -> ClientFrame {
|
||||
match self {
|
||||
Request::Save => ClientFrame::Save,
|
||||
Request::DownloadDocument { filename } => ClientFrame::DownloadDocument { filename },
|
||||
Request::DownloadCsv { filename } => ClientFrame::DownloadCsv { filename },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +106,9 @@ pub fn resolve(state: &ClientState, event: KeyEvent) -> KeyOutcome {
|
||||
Binding::Cmd { name, args } => match step(state, event, name, args) {
|
||||
Step::Local(commands) | Step::Declined(commands) => KeyOutcome::of(commands),
|
||||
Step::Request(request) => KeyOutcome::Request(request),
|
||||
Step::LocalThenRequest { cmds, request } => {
|
||||
KeyOutcome::LocalThenRequest { cmds, request }
|
||||
}
|
||||
Step::Upstream => KeyOutcome::Upstream(event),
|
||||
},
|
||||
// All or nothing, each step resolved against the *unchanged* state.
|
||||
@@ -131,7 +148,7 @@ fn chord(state: &ClientState, event: KeyEvent, binding: Option<&Binding>) -> Key
|
||||
Step::Local(commands) | Step::Declined(commands) => Some(commands),
|
||||
// DISCARDED, which is why the guard refuses both classes rather
|
||||
// than counting them answered — `context/code/clients.md`.
|
||||
Step::Request(_) | Step::Upstream => None,
|
||||
Step::Request(_) | Step::LocalThenRequest { .. } | Step::Upstream => None,
|
||||
},
|
||||
Some(Binding::Sequence(steps)) => sequence(state, event, steps),
|
||||
};
|
||||
@@ -294,7 +311,7 @@ fn sequence(
|
||||
Step::Declined(said) => return Some(said),
|
||||
// A request is a frame of its own and a sequence is one frame, so
|
||||
// there is nowhere to put it. No binding does — clients.md.
|
||||
Step::Request(_) | Step::Upstream => return None,
|
||||
Step::Request(_) | Step::LocalThenRequest { .. } | Step::Upstream => return None,
|
||||
}
|
||||
}
|
||||
Some(commands)
|
||||
@@ -306,22 +323,31 @@ enum Step {
|
||||
Local(Vec<Command>),
|
||||
Declined(Vec<Command>),
|
||||
Request(Request),
|
||||
/// Fold commands locally, THEN send a request. For commit-save/commit-export:
|
||||
/// the mode change folds here, the download request goes to the server.
|
||||
LocalThenRequest {
|
||||
cmds: Vec<Command>,
|
||||
request: Request,
|
||||
},
|
||||
Upstream,
|
||||
}
|
||||
|
||||
/// One name, answered. Each list is checked before the next, so no name is in
|
||||
/// two of them and [`composed`] has no arm for any earlier one.
|
||||
fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Step {
|
||||
match requested(name) {
|
||||
Some(request) => Step::Request(request),
|
||||
None => match declined(name, args).or_else(|| refused_here(state, name)) {
|
||||
Some(said) => Step::Declined(vec![Command::SetStatus(said)]),
|
||||
None => match committed(state, name, args) {
|
||||
Some(answer) => answer,
|
||||
None => match composed(state, event, name, args) {
|
||||
Some(commands) => Step::Local(commands),
|
||||
None => Step::Upstream,
|
||||
},
|
||||
if let Some(request) = requested(name) {
|
||||
return Step::Request(request);
|
||||
}
|
||||
if let Some(step) = download_request(state, name) {
|
||||
return step;
|
||||
}
|
||||
match declined(name, args).or_else(|| refused_here(state, name)) {
|
||||
Some(said) => Step::Declined(vec![Command::SetStatus(said)]),
|
||||
None => match committed(state, name, args) {
|
||||
Some(answer) => answer,
|
||||
None => match composed(state, event, name, args) {
|
||||
Some(commands) => Step::Local(commands),
|
||||
None => Step::Upstream,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -365,6 +391,36 @@ fn requested(name: &str) -> Option<Request> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Download requests that need to read a buffer and fold a mode change locally
|
||||
/// before sending the request (improvise-2mqz).
|
||||
fn download_request(state: &ClientState, name: &str) -> Option<Step> {
|
||||
let (buffer_name, make_request): (&str, fn(String) -> Request) = match name {
|
||||
"commit-save" => ("save", |f| Request::DownloadDocument { filename: f }),
|
||||
"commit-export" => ("export", |f| Request::DownloadCsv { filename: f }),
|
||||
_ => return None,
|
||||
};
|
||||
let filename = read_buffer(&state.view, buffer_name).trim().to_string();
|
||||
if filename.is_empty() {
|
||||
return Some(Step::Declined(vec![Command::SetStatus(format!(
|
||||
"No filename given — not {}",
|
||||
if name == "commit-save" {
|
||||
"saved"
|
||||
} else {
|
||||
"exported"
|
||||
}
|
||||
))]));
|
||||
}
|
||||
// This owns the clear and mode change because LocalThenRequest cannot be
|
||||
// part of a Binding::Sequence (improvise-2mqz).
|
||||
Some(Step::LocalThenRequest {
|
||||
cmds: vec![
|
||||
write_buffer(buffer_name, String::new()),
|
||||
Command::ChangeMode(AppMode::Normal),
|
||||
],
|
||||
request: make_request(filename),
|
||||
})
|
||||
}
|
||||
|
||||
/// The commands this client cannot compose and answers **out loud**; the one
|
||||
/// home for the wording. Why silence was worse: clients.md (improvise-rmvx).
|
||||
fn declined(name: &str, args: &[String]) -> Option<String> {
|
||||
@@ -681,8 +737,8 @@ fn write_buffer(name: &str, value: String) -> Command {
|
||||
/// to decline by name. A dead room is worse than a locked door: clients.md.
|
||||
fn stranding(mode: &AppMode) -> Option<&'static str> {
|
||||
match mode {
|
||||
AppMode::SavePrompt { .. } => Some("saving to a path"),
|
||||
AppMode::ExportPrompt { .. } => Some("exporting"),
|
||||
// SavePrompt and ExportPrompt were stranded before improvise-2mqz added
|
||||
// DownloadDocument and DownloadCsv frames. Now they have wire forms.
|
||||
AppMode::CommandMode { .. } => Some("the command line"),
|
||||
_ => None,
|
||||
}
|
||||
@@ -1892,11 +1948,13 @@ mod tests {
|
||||
crate::CategoryTreeUpdate::Categories(vec![
|
||||
crate::CategoryRow {
|
||||
name: "Region".into(),
|
||||
display: "Region".into(),
|
||||
items: vec!["North".into(), "South".into()],
|
||||
axis: Axis::Row,
|
||||
},
|
||||
crate::CategoryRow {
|
||||
name: "Product".into(),
|
||||
display: "Product".into(),
|
||||
items: vec!["Shirts".into()],
|
||||
axis: Axis::Column,
|
||||
},
|
||||
@@ -1975,11 +2033,13 @@ mod tests {
|
||||
state.tiles.tiles = vec![
|
||||
crate::TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: Axis::Row,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
},
|
||||
crate::TileEntry {
|
||||
category: "Product".into(),
|
||||
display: "Product".into(),
|
||||
axis: Axis::Column,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
},
|
||||
@@ -2334,10 +2394,12 @@ mod tests {
|
||||
state.fold(&Command::Project(Projection::ViewList(vec![
|
||||
crate::ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![],
|
||||
},
|
||||
crate::ViewSummary {
|
||||
name: "Plan".into(),
|
||||
display: "Plan".into(),
|
||||
axes: vec![],
|
||||
},
|
||||
])));
|
||||
@@ -2617,6 +2679,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.map(|name| crate::CategoryRow {
|
||||
name: name.into(),
|
||||
display: name.into(),
|
||||
items: vec!["North".into(), "South".into()],
|
||||
axis: Axis::Row,
|
||||
})
|
||||
@@ -2635,6 +2698,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.map(|name| crate::ViewSummary {
|
||||
name: name.into(),
|
||||
display: name.into(),
|
||||
axes: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
@@ -2642,6 +2706,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.map(|category| crate::TileEntry {
|
||||
category: category.into(),
|
||||
display: category.into(),
|
||||
axis: Axis::Row,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
})
|
||||
@@ -2661,6 +2726,15 @@ mod tests {
|
||||
.insert("category".into(), "Product".into());
|
||||
state.view.buffers.insert("item".into(), "Shirts".into());
|
||||
state.view.buffers.insert("edit".into(), "42".into());
|
||||
// commit-save and commit-export need their buffers filled (improvise-2mqz).
|
||||
state
|
||||
.view
|
||||
.buffers
|
||||
.insert("save".into(), "data.improv".into());
|
||||
state
|
||||
.view
|
||||
.buffers
|
||||
.insert("export".into(), "data.csv".into());
|
||||
state
|
||||
}
|
||||
|
||||
@@ -2678,7 +2752,7 @@ mod tests {
|
||||
fn heard(state: &mut ClientState, outcome: KeyOutcome) -> Heard {
|
||||
let commands = match outcome {
|
||||
KeyOutcome::Upstream(_) | KeyOutcome::Ignored => return Heard::Silent,
|
||||
KeyOutcome::Request(_) => return Heard::Acts,
|
||||
KeyOutcome::Request(_) | KeyOutcome::LocalThenRequest { .. } => return Heard::Acts,
|
||||
KeyOutcome::Local(commands) => commands,
|
||||
};
|
||||
let quiet = |view: &ViewState| ViewState {
|
||||
@@ -2826,8 +2900,6 @@ mod tests {
|
||||
// Reached only from a mode `declined` refuses at the door, so a
|
||||
// browser is never standing in front of these.
|
||||
"COMMAND: Enter execute-command Silent",
|
||||
"EXPORT: Enter commit-export Silent",
|
||||
"SAVE AS: Enter commit-save Silent",
|
||||
// Honestly unavailable, and saying so in `declined`'s words.
|
||||
"NORMAL: > drill-into-cell Declines",
|
||||
"NORMAL: : enter-mode Declines",
|
||||
@@ -3482,37 +3554,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The refusal above is only half the property: the bug guarded is
|
||||
/// *entering a room with no exit*. See `context/test-evidence/clients.md`.
|
||||
/// A client must not enter a room with no exit. SavePrompt and ExportPrompt
|
||||
/// now have download frames; see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_prompt_this_client_cannot_commit_is_never_entered() {
|
||||
let state = in_mode(AppMode::Normal);
|
||||
for (key, mode, subject) in [
|
||||
let mode = "command-mode";
|
||||
let KeyOutcome::Local(said) = resolve(&state, ev(KeyCode::Char(':'))) else {
|
||||
panic!("{mode} must be refused *out loud*, not in silence");
|
||||
};
|
||||
let [Command::SetStatus(msg)] = &said[..] else {
|
||||
panic!("{mode}: expected one status, got {said:?}");
|
||||
};
|
||||
assert!(
|
||||
msg.contains("command line"),
|
||||
"{mode}: the refusal must name its own subject, got {msg}"
|
||||
);
|
||||
assert!(
|
||||
!said.iter().any(|c| matches!(c, Command::ChangeMode(_))),
|
||||
"{mode} must not be entered by a client that cannot commit it"
|
||||
);
|
||||
|
||||
// SavePrompt and ExportPrompt ARE now enterable (improvise-2mqz).
|
||||
for (key, mode_name) in [
|
||||
(
|
||||
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
|
||||
"save-prompt",
|
||||
"saving",
|
||||
),
|
||||
(
|
||||
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
|
||||
"export-prompt",
|
||||
"exporting",
|
||||
),
|
||||
(ev(KeyCode::Char(':')), "command-mode", "command line"),
|
||||
] {
|
||||
let KeyOutcome::Local(said) = resolve(&state, key) else {
|
||||
panic!("{mode} must be refused *out loud*, not in silence");
|
||||
};
|
||||
let [Command::SetStatus(msg)] = &said[..] else {
|
||||
panic!("{mode}: expected one status, got {said:?}");
|
||||
let outcome = resolve(&state, key);
|
||||
let KeyOutcome::Local(cmds) = outcome else {
|
||||
panic!("{mode_name} should resolve locally now that it has a wire form");
|
||||
};
|
||||
assert!(
|
||||
msg.contains(subject),
|
||||
"{mode}: the refusal must name its own subject, got {msg}"
|
||||
);
|
||||
assert!(
|
||||
!said.iter().any(|c| matches!(c, Command::ChangeMode(_))),
|
||||
"{mode} must not be entered by a client that cannot commit it"
|
||||
cmds.iter().any(|c| matches!(c, Command::ChangeMode(_))),
|
||||
"{mode_name} should be entered now that commit-save/commit-export have wire forms"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3747,6 +3827,7 @@ mod tests {
|
||||
let mut tiles = in_mode(AppMode::TileSelect);
|
||||
tiles.tiles.tiles = vec![crate::TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: improvise_core::view::Axis::Row,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
}];
|
||||
|
||||
@@ -11,9 +11,10 @@ use crate::render::{CategoryTreeCache, ViewSummary};
|
||||
/// so the entry list *is* what the panel draws.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CatTreeEntry {
|
||||
/// Category header row: name, item count, expanded?
|
||||
/// Category header row: name (the key), what it shows, item count, expanded?
|
||||
Category {
|
||||
name: String,
|
||||
display: String,
|
||||
item_count: usize,
|
||||
expanded: bool,
|
||||
axis: Axis,
|
||||
@@ -49,6 +50,7 @@ pub fn flatten_cat_tree(
|
||||
let is_expanded = expanded.contains(&row.name);
|
||||
entries.push(CatTreeEntry::Category {
|
||||
name: row.name.clone(),
|
||||
display: row.display.clone(),
|
||||
item_count: row.items.len(),
|
||||
expanded: is_expanded,
|
||||
axis: row.axis,
|
||||
@@ -94,6 +96,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|(name, items)| CategoryRow {
|
||||
name: (*name).to_string(),
|
||||
display: (*name).to_string(),
|
||||
items: items.iter().map(|i| i.to_string()).collect(),
|
||||
axis: Axis::None,
|
||||
})
|
||||
@@ -112,6 +115,7 @@ mod tests {
|
||||
tree,
|
||||
vec![CatTreeEntry::Category {
|
||||
name: "Region".into(),
|
||||
display: "Region".into(),
|
||||
item_count: 2,
|
||||
expanded: false,
|
||||
axis: Axis::None,
|
||||
|
||||
@@ -8,7 +8,7 @@ pub mod socket;
|
||||
pub use improvise_protocol::{Click, ClientState, KeyOutcome, Request, resolve};
|
||||
pub use keys::BrowserKey;
|
||||
pub use render::{RenderCatRow, RenderCell, RenderModel, RenderRow};
|
||||
pub use socket::EditTarget;
|
||||
pub use socket::{DownloadKind, EditTarget, PendingDownload};
|
||||
|
||||
use improvise_protocol::{CellId, ClientFrame, RecordCell, Snapshot};
|
||||
use wasm_bindgen::prelude::*;
|
||||
@@ -18,6 +18,8 @@ use wasm_bindgen::prelude::*;
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Client {
|
||||
state: ClientState,
|
||||
/// A download waiting for JavaScript to trigger (improvise-2mqz).
|
||||
pending_download: Option<PendingDownload>,
|
||||
}
|
||||
|
||||
/// An empty view slice and one empty cache per widget. Not exported: the
|
||||
@@ -92,6 +94,17 @@ impl Client {
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Take any pending download, returning it as JSON for JavaScript to handle.
|
||||
/// Returns `None` if there is no pending download (improvise-2mqz).
|
||||
#[wasm_bindgen]
|
||||
pub fn take_pending_download(&mut self) -> Result<Option<String>, JsError> {
|
||||
self.pending_download
|
||||
.take()
|
||||
.map(|d| serde_json::to_string(&d))
|
||||
.transpose()
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Everything the page draws, with every rendering decision already
|
||||
/// made. See [`render`].
|
||||
#[wasm_bindgen]
|
||||
@@ -243,6 +256,12 @@ impl Client {
|
||||
// Nothing to fold: a request moves no state of this client's, and
|
||||
// what it moves on the host comes back as a projection.
|
||||
KeyOutcome::Request(request) => Some(request.frame()),
|
||||
// Fold the commands locally (mode change), then send the request
|
||||
// for download bytes (improvise-2mqz).
|
||||
KeyOutcome::LocalThenRequest { cmds, request } => {
|
||||
self.state.fold_outgoing(&cmds);
|
||||
Some(request.frame())
|
||||
}
|
||||
KeyOutcome::Upstream(event) => Some(ClientFrame::key(event)),
|
||||
KeyOutcome::Ignored => None,
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ fn view_rows(state: &ClientState) -> Option<Vec<RenderViewRow>> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, summary)| RenderViewRow {
|
||||
name: summary.name.clone(),
|
||||
name: summary.display.clone(),
|
||||
axes: improvise_protocol::viewmodel::view_axis_summary(summary),
|
||||
active: summary.name == state.view.active_view,
|
||||
selected: i == state.view.view_panel_cursor,
|
||||
@@ -396,7 +396,7 @@ fn tile_row(state: &ClientState) -> Vec<RenderTile> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, tile)| RenderTile {
|
||||
category: tile.category.clone(),
|
||||
category: tile.display.clone(),
|
||||
axis: tile.axis.plain_label(),
|
||||
selected: selecting && i == state.view.tile_cat_idx,
|
||||
})
|
||||
@@ -468,12 +468,13 @@ impl RenderCatRow {
|
||||
fn of(entry: &CatTreeEntry, selected: bool) -> Self {
|
||||
match entry {
|
||||
CatTreeEntry::Category {
|
||||
name,
|
||||
display,
|
||||
item_count,
|
||||
expanded,
|
||||
axis,
|
||||
..
|
||||
} => Self {
|
||||
label: name.clone(),
|
||||
label: display.clone(),
|
||||
is_category: true,
|
||||
axis: axis.initial().to_string(),
|
||||
item_count: Some(*item_count),
|
||||
@@ -806,11 +807,13 @@ mod tests {
|
||||
CategoryTreeUpdate::Categories(vec![
|
||||
CategoryRow {
|
||||
name: "Region".into(),
|
||||
display: "Region".into(),
|
||||
items: vec!["North".into(), "South".into()],
|
||||
axis: Axis::Row,
|
||||
},
|
||||
CategoryRow {
|
||||
name: "Measure".into(),
|
||||
display: "Measure".into(),
|
||||
items: vec!["Revenue".into()],
|
||||
axis: Axis::Column,
|
||||
},
|
||||
@@ -843,6 +846,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panels_paint_displays_and_keep_keys_for_what_a_command_names() {
|
||||
let mut client = init();
|
||||
let state = client.state_mut();
|
||||
state.fold(&Command::Project(Projection::CategoryTree(
|
||||
CategoryTreeUpdate::Categories(vec![CategoryRow {
|
||||
name: "Region".into(),
|
||||
display: "Sales Area".into(),
|
||||
items: vec!["North".into()],
|
||||
axis: Axis::Row,
|
||||
}]),
|
||||
)));
|
||||
state.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Overview".into(),
|
||||
axes: vec![],
|
||||
}])));
|
||||
state.fold(&Command::Project(Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Sales Area".into(),
|
||||
axis: Axis::Row,
|
||||
kind: improvise_protocol::CategoryKind::Regular,
|
||||
}])));
|
||||
state.view.view_panel_open = true;
|
||||
state.view.active_view = "Default".into();
|
||||
let model = client.model();
|
||||
assert_eq!(model.categories[0].label, "Sales Area");
|
||||
assert_eq!(model.tiles[0].category, "Sales Area");
|
||||
let views = model.views.expect("the view panel is open");
|
||||
assert_eq!(views[0].name, "Overview");
|
||||
assert!(views[0].active, "the active view is still found by its key");
|
||||
}
|
||||
|
||||
fn client_with_panels() -> Client {
|
||||
let mut client = init();
|
||||
client
|
||||
@@ -850,10 +886,12 @@ mod tests {
|
||||
.fold(&Command::Project(Projection::ViewList(vec![
|
||||
ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row), ("_Dim".into(), Axis::Column)],
|
||||
},
|
||||
ViewSummary {
|
||||
name: "Plan".into(),
|
||||
display: "Plan".into(),
|
||||
axes: vec![],
|
||||
},
|
||||
])));
|
||||
@@ -867,11 +905,13 @@ mod tests {
|
||||
.fold(&Command::Project(Projection::TileBar(vec![
|
||||
TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: Axis::Row,
|
||||
kind: improvise_protocol::CategoryKind::Regular,
|
||||
},
|
||||
TileEntry {
|
||||
category: "Product".into(),
|
||||
display: "Product".into(),
|
||||
axis: Axis::None,
|
||||
kind: improvise_protocol::CategoryKind::Regular,
|
||||
},
|
||||
@@ -1192,11 +1232,13 @@ mod tests {
|
||||
.fold(&Command::Project(Projection::TileBar(vec![
|
||||
TileEntry {
|
||||
category: "Quarter".into(),
|
||||
display: "Quarter".into(),
|
||||
axis: Axis::Page,
|
||||
kind: improvise_protocol::CategoryKind::Regular,
|
||||
},
|
||||
TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: Axis::Column,
|
||||
kind: improvise_protocol::CategoryKind::Regular,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,24 @@ use improvise_protocol::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A `DocumentBytes` or `CsvBytes` download held until JavaScript retrieves it
|
||||
/// through `Client::take_pending_download` (improvise-2mqz).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PendingDownload {
|
||||
pub filename: String,
|
||||
pub bytes: Vec<u8>,
|
||||
pub kind: DownloadKind,
|
||||
}
|
||||
|
||||
/// What kind of content is being downloaded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DownloadKind {
|
||||
/// The document in `.improv` format.
|
||||
Document,
|
||||
/// The active view as CSV.
|
||||
Csv,
|
||||
}
|
||||
|
||||
/// Everything an editor captures when it opens, held opaquely by the page.
|
||||
/// A struct so a field such as the seed can join it — `context/code/clients.md`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -72,6 +90,7 @@ fn rejection_message(rejection: &Rejection) -> String {
|
||||
"this host has nowhere to write the document — it was not saved".into()
|
||||
}
|
||||
Rejection::SaveFailed => "the document could not be written".into(),
|
||||
Rejection::ExportFailed => "the view could not be exported".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +101,8 @@ impl Client {
|
||||
ClientFrame::Viewport { rows, cols }
|
||||
}
|
||||
|
||||
/// Fold one server frame, in arrival order.
|
||||
/// Fold one server frame in order. Downloads leave the revision unchanged
|
||||
/// and wait for JavaScript; other frames return the new revision.
|
||||
pub fn fold_frame(&mut self, frame: ServerFrame) -> u64 {
|
||||
match frame {
|
||||
ServerFrame::Snapshot(snapshot) => self.bootstrap(*snapshot),
|
||||
@@ -100,6 +120,22 @@ impl Client {
|
||||
state.notify(message);
|
||||
}
|
||||
ServerFrame::Accepted => self.state_mut().accepted(),
|
||||
// Download frames carry bytes the browser needs to trigger a download.
|
||||
// Store them for JavaScript to retrieve via `take_pending_download`.
|
||||
ServerFrame::DocumentBytes { filename, bytes } => {
|
||||
self.pending_download = Some(PendingDownload {
|
||||
filename,
|
||||
bytes,
|
||||
kind: DownloadKind::Document,
|
||||
});
|
||||
}
|
||||
ServerFrame::CsvBytes { filename, bytes } => {
|
||||
self.pending_download = Some(PendingDownload {
|
||||
filename,
|
||||
bytes,
|
||||
kind: DownloadKind::Csv,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.revision()
|
||||
}
|
||||
@@ -223,7 +259,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A rejection that refused an **edit** must say nothing was applied; the
|
||||
/// two `Save` answers refused none. Evidence: clients.md (improvise-8uj8).
|
||||
/// save/export answers refused none. Evidence: clients.md (improvise-8uj8).
|
||||
#[test]
|
||||
fn every_rejection_message_says_what_it_refused() {
|
||||
let hash = surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879");
|
||||
@@ -238,9 +274,11 @@ mod tests {
|
||||
// Not "not applied": nothing was asked to apply. It must still
|
||||
// say the thing it *did* refuse did not happen.
|
||||
false => assert!(
|
||||
msg.contains("not saved") || msg.contains("could not be written"),
|
||||
"{rejection:?} refused a save but is worded as {msg:?}, \
|
||||
which does not say the document was not written"
|
||||
msg.contains("not saved")
|
||||
|| msg.contains("could not be written")
|
||||
|| msg.contains("could not be exported"),
|
||||
"{rejection:?} refused a save/export but is worded as {msg:?}, \
|
||||
which does not say the operation failed"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,6 +621,103 @@ fn ctrl_s_in_the_browser_writes_the_hosts_document() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Codex on PR #329: committing a download closed the browser's prompt but
|
||||
/// left the host mirror in it, so a later projection could restore stale state.
|
||||
#[test]
|
||||
fn a_download_commit_keeps_the_host_session_mirror_in_lockstep() {
|
||||
use improvise_command::ui::reduce::caught_up;
|
||||
use improvise_protocol::{AppMode, ModelCommand, ServerFrame};
|
||||
|
||||
let mut host = host();
|
||||
let mut client = Client::new();
|
||||
let (id, snapshot) = host.connect(10, 10);
|
||||
deliver(&mut client, &[snapshot]);
|
||||
|
||||
let open = client
|
||||
.key(&BrowserKey {
|
||||
key: "w".into(),
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
})
|
||||
.expect("Ctrl-W opens the save prompt");
|
||||
let opened = host.handle(id, open);
|
||||
deliver(&mut client, &opened);
|
||||
for key in ["d", "o", "c", ".", "i", "m", "p", "r", "o", "v"] {
|
||||
press(&mut host, id, &mut client, key);
|
||||
}
|
||||
assert!(matches!(
|
||||
client.state().view.mode,
|
||||
AppMode::SavePrompt { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
host.sessions().get(id).expect("attached").view().mode,
|
||||
client.state().view.mode,
|
||||
"setup: both ends must be in the prompt before Enter"
|
||||
);
|
||||
|
||||
let commit = client
|
||||
.key(&BrowserKey::new("Enter"))
|
||||
.expect("Enter composes a download request");
|
||||
assert!(
|
||||
matches!(
|
||||
&commit,
|
||||
ClientFrame::DownloadDocument { filename } if filename == "doc.improv"
|
||||
),
|
||||
"the USER RULING is a bytes request, got {commit:?}"
|
||||
);
|
||||
let answer = host.handle(id, commit);
|
||||
assert!(
|
||||
answer
|
||||
.iter()
|
||||
.any(|frame| matches!(frame.msg, ServerFrame::DocumentBytes { .. })),
|
||||
"the request must still return bytes: {answer:?}"
|
||||
);
|
||||
deliver(&mut client, &answer);
|
||||
|
||||
let mirrored = host.sessions().get(id).expect("attached").view();
|
||||
assert_eq!(
|
||||
mirrored.mode,
|
||||
AppMode::Normal,
|
||||
"the host must close the prompt in its session mirror"
|
||||
);
|
||||
assert_eq!(
|
||||
mirrored.buffers.get("save").map(String::as_str),
|
||||
Some(""),
|
||||
"the host must clear the same minibuffer as the client"
|
||||
);
|
||||
|
||||
// Force a subsequent document projection from another session, then fold
|
||||
// what it addresses to the requester.
|
||||
let (bystander, _) = host.connect(10, 10);
|
||||
let write = caught_up(
|
||||
host.app(),
|
||||
ModelCommand::SetCell {
|
||||
view: "Default".into(),
|
||||
cell: client
|
||||
.record_cell_at(0, 0)
|
||||
.expect("North/Revenue is stored"),
|
||||
value: CellValue::Number(77.0),
|
||||
},
|
||||
);
|
||||
let projected = host.handle(bystander, ClientFrame::command(write));
|
||||
let for_requester: Vec<Addressed> = projected
|
||||
.into_iter()
|
||||
.filter(|frame| frame.to == id)
|
||||
.collect();
|
||||
assert!(
|
||||
!for_requester.is_empty(),
|
||||
"the regression must process a later projection"
|
||||
);
|
||||
deliver(&mut client, &for_requester);
|
||||
assert_eq!(
|
||||
&client.state().view,
|
||||
host.sessions().get(id).expect("attached").view(),
|
||||
"the later projection must leave the browser and host mirror in lockstep"
|
||||
);
|
||||
}
|
||||
|
||||
/// A refused frame must leave this client where the **host** says it is, and
|
||||
/// the host says so in its own mirror. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
|
||||
@@ -1595,6 +1595,9 @@ fn deliver(
|
||||
}
|
||||
improvise_protocol::ServerFrame::Rejected(r) => client.refused(r),
|
||||
improvise_protocol::ServerFrame::Accepted => client.accepted(),
|
||||
// Download responses are not processed in this test context (improvise-2mqz).
|
||||
improvise_protocol::ServerFrame::DocumentBytes { .. }
|
||||
| improvise_protocol::ServerFrame::CsvBytes { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3731,6 +3734,9 @@ fn heard(
|
||||
}
|
||||
improvise_protocol::ServerFrame::Rejected(r) => client.refused(r),
|
||||
improvise_protocol::ServerFrame::Accepted => client.accepted(),
|
||||
// Download responses are not processed in this test context (improvise-2mqz).
|
||||
improvise_protocol::ServerFrame::DocumentBytes { .. }
|
||||
| improvise_protocol::ServerFrame::CsvBytes { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
|
||||
// Download responses are not processed in this test context (improvise-2mqz).
|
||||
ServerFrame::DocumentBytes { .. } | ServerFrame::CsvBytes { .. } => {}
|
||||
}
|
||||
}
|
||||
grid.clone()
|
||||
|
||||
@@ -113,6 +113,8 @@ impl MainThread {
|
||||
}
|
||||
}
|
||||
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
|
||||
// Download responses are not processed in this test context (improvise-2mqz).
|
||||
ServerFrame::DocumentBytes { .. } | ServerFrame::CsvBytes { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,11 +353,17 @@ fn the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable() {
|
||||
}
|
||||
// The third column is `Profit`, which a formula owns: the page must not
|
||||
// offer it, so it carries no surrogate to offer (improvise-ir7l).
|
||||
for row in 0..main.grid.row_frame.len() {
|
||||
for (row, cells) in main
|
||||
.grid
|
||||
.cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(main.grid.row_frame.len())
|
||||
{
|
||||
assert!(
|
||||
matches!(main.grid.cells[row][2].id, Some(CellId::Derived { .. })),
|
||||
matches!(cells[2].id, Some(CellId::Derived { .. })),
|
||||
"row {row}'s Profit cell must be Derived, got {:?}",
|
||||
main.grid.cells[row][2].id
|
||||
cells[2].id
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -297,11 +297,11 @@
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1790046670,
|
||||
"narHash": "sha256-MYiI+CzL0tuWgRPjGsKCDHqYs2T3OzMlMQWOYWG0qso=",
|
||||
"lastModified": 1790185690,
|
||||
"narHash": "sha256-xJ+X4hBtOcAFGBOe5nAMyMUeF9foJBmIOu3NjBqBycU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6774f7bc253789b113a4f39285dc0fa100abeacc",
|
||||
"rev": "4975466d324710c576dc11ad614684e6bd8cad8e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -368,11 +368,11 @@
|
||||
"nixpkgs": "nixpkgs_5"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1790062494,
|
||||
"narHash": "sha256-Mds1vuoczq1fRm1rf+Iar8IKDBHNjOMo2JqqPWJUlng=",
|
||||
"lastModified": 1790234952,
|
||||
"narHash": "sha256-tjQa1vDoHJir4lasZTakz/xgmsfB6SRjeQqaIbefFOk=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "b88764907c1b2de6c0e206d8d9d9634d91c81334",
|
||||
"rev": "ed34466c59b766481248959a21a6e46cf0c60468",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -47,13 +47,13 @@
|
||||
wasmBindgenCli = pkgs.buildWasmBindgenCli rec {
|
||||
src = pkgs.fetchCrate {
|
||||
pname = "wasm-bindgen-cli";
|
||||
version = "0.2.128";
|
||||
hash = "sha256-a7lcXJnnZkYReja+iUO7NqqrWyv3toxnUgQb8s4IS5s=";
|
||||
version = "0.2.129";
|
||||
hash = "sha256-pcecKQd7E8Opw6bkFoE569epUi7gh5qpQF1e5PJY6V8=";
|
||||
};
|
||||
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
|
||||
inherit src;
|
||||
inherit (src) pname version;
|
||||
hash = "sha256-R1Tas33Ursy8kqsxguAkG0ZhNed2n5uFTAhw1l2qlLY=";
|
||||
hash = "sha256-vmUrWVU7kPJJxO5qIVeAkwQyWDELO1Z4Z5gitz2kco8=";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Generated
+31
-14
@@ -187,6 +187,7 @@ dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"siphasher",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -238,9 +239,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.105"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -471,12 +472,27 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
@@ -485,9 +501,9 @@ checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -498,19 +514,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.78"
|
||||
version = "0.4.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
|
||||
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -518,9 +535,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -531,18 +548,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.105"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
|
||||
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stack-agnostic agent bootstrap for this repository.
|
||||
#
|
||||
# This script prepares any coding agent (Claude Code, Cursor, etc.) to work
|
||||
# on this repo. It handles:
|
||||
# 1. Nix flake input seeding (so `nix develop` works in cloud environments)
|
||||
# 2. bd (beads) installation and pin verification
|
||||
# 3. Issue tracker sync from the Dolt remote
|
||||
# 4. Optional cargo workspace warming (inside nix develop)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/agent-bootstrap.sh [--skip-cargo-warm] [--skip-nix-seed]
|
||||
#
|
||||
# The script is idempotent and safe to re-run. Nothing here writes to tracked
|
||||
# files, stages anything, or creates a commit.
|
||||
#
|
||||
# IMPORTANT: This repo requires `nix develop` (or direnv) for builds/tests.
|
||||
# Bare system cargo that fails edition2024 is a misconfigured session.
|
||||
#
|
||||
# See also: `.claude/hooks/session-start.sh` (Claude Code wrapper),
|
||||
# `.cursor/environment.json` (Cursor cloud config)
|
||||
set -uo pipefail
|
||||
|
||||
SKIP_CARGO_WARM=0
|
||||
SKIP_NIX_SEED=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-cargo-warm) SKIP_CARGO_WARM=1; shift ;;
|
||||
--skip-nix-seed) SKIP_NIX_SEED=1; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ROOT="${AGENT_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}}"
|
||||
cd "$ROOT" || exit 0
|
||||
|
||||
log() { printf '[agent-bootstrap] %s\n' "$*"; }
|
||||
|
||||
nix_status=0
|
||||
|
||||
ensure_nix_daemon() {
|
||||
command -v nix >/dev/null 2>&1 || return 1
|
||||
|
||||
# Single-user Nix does not need a daemon; multi-user/cloud installs do.
|
||||
# If the store already answers, leave the environment alone.
|
||||
if nix --extra-experimental-features 'nix-command' store ping >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! command -v nix-daemon >/dev/null 2>&1; then
|
||||
log "ERROR: Nix is installed, but the store is unavailable and nix-daemon is not on PATH"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "starting nix-daemon for bootstrap…"
|
||||
nix-daemon >/tmp/nix-daemon.log 2>&1 &
|
||||
|
||||
# The daemon is started asynchronously; wait briefly for its socket before
|
||||
# invoking the seed/build steps that depend on it.
|
||||
for _ in {1..50}; do
|
||||
if nix --extra-experimental-features 'nix-command' store ping --store daemon >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
log "ERROR: nix-daemon did not become ready; see /tmp/nix-daemon.log"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Start the daemon during cloud-agent installation, before any required Nix
|
||||
# operation. This is harmless for single-user Nix because nix-store --ping
|
||||
# succeeds without starting a daemon.
|
||||
if [ "$SKIP_NIX_SEED" -eq 0 ] || [ "$SKIP_CARGO_WARM" -eq 0 ]; then
|
||||
if ! ensure_nix_daemon; then
|
||||
nix_status=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 0. Seed nix flake inputs (required for cloud environments) ─────────────
|
||||
# GitHub tarball endpoints are blocked in cloud containers. This seeds the
|
||||
# flake inputs over git so `nix develop` can build the dev shell.
|
||||
if [ "$SKIP_NIX_SEED" -eq 0 ]; then
|
||||
if [ "$nix_status" -ne 0 ]; then
|
||||
log "ERROR: cannot seed flake inputs because Nix is unavailable"
|
||||
elif ! command -v nix >/dev/null 2>&1; then
|
||||
log "ERROR: nix not available; required flake input seeding cannot run"
|
||||
nix_status=1
|
||||
else
|
||||
# Set SSL cert for proxy environments
|
||||
if [ -z "${NIX_SSL_CERT_FILE:-}" ] && [ -r /root/.ccr/ca-bundle.crt ]; then
|
||||
export NIX_SSL_CERT_FILE=/root/.ccr/ca-bundle.crt
|
||||
fi
|
||||
|
||||
if [ ! -f "$ROOT/scripts/nix-seed-flake-inputs.sh" ]; then
|
||||
log "ERROR: required scripts/nix-seed-flake-inputs.sh is missing"
|
||||
nix_status=1
|
||||
else
|
||||
log "seeding nix flake inputs (GitHub tarballs blocked in cloud)…"
|
||||
if "$ROOT/scripts/nix-seed-flake-inputs.sh" >/tmp/nix-seed.log 2>&1; then
|
||||
log "flake inputs seeded"
|
||||
else
|
||||
log "ERROR: nix-seed-flake-inputs.sh failed; see /tmp/nix-seed.log"
|
||||
log " 'nix develop' is not validated for this session."
|
||||
nix_status=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 1. The bd on PATH must be the bd flake.nix pins ────────────────────────
|
||||
beads_pin() {
|
||||
sed -n 's|.*beads\.url[[:space:]]*=[[:space:]]*"github:[^/]*/[^/]*/v\{0,1\}\([^"]*\)".*|\1|p' \
|
||||
flake.nix 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
bd_running_version() {
|
||||
bd version 2>/dev/null | sed -n 's/^bd version \([0-9][0-9.]*\).*/\1/p' | head -1
|
||||
}
|
||||
|
||||
bd_pin_banner() {
|
||||
{
|
||||
printf '\n[agent-bootstrap] ===== BEADS VERSION SKEW =====\n'
|
||||
printf '[agent-bootstrap] %s\n' "$@"
|
||||
printf '[agent-bootstrap] =============================\n\n'
|
||||
} >&2
|
||||
}
|
||||
|
||||
assert_bd_pin() {
|
||||
command -v bd >/dev/null 2>&1 || return 0
|
||||
|
||||
pinned=$(beads_pin)
|
||||
running=$(bd_running_version)
|
||||
|
||||
case "$pinned" in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*)
|
||||
bd_pin_banner "cannot read the beads pin out of flake.nix" \
|
||||
"read: '${pinned:-<nothing>}'" \
|
||||
"The pin moved to a ref this hook cannot parse (a branch or a" \
|
||||
"commit, perhaps). Teach beads_pin() to read it, or the check" \
|
||||
"silently protects nothing."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$running" ]; then
|
||||
bd_pin_banner "cannot read the version of the bd on PATH" \
|
||||
"bd: $(command -v bd)" \
|
||||
"'bd version' did not answer in the expected shape. Until it does," \
|
||||
"nothing here can tell whether that build matches the pin."
|
||||
return 1
|
||||
fi
|
||||
|
||||
[ "$running" = "$pinned" ] && return 0
|
||||
|
||||
bd_pin_banner "the bd on PATH is NOT the bd this repo pins" \
|
||||
"pinned (flake.nix): $pinned" \
|
||||
"running: $running ($(command -v bd))" \
|
||||
"" \
|
||||
"Reading is safe. Do NOT run a mutating bd command — create, update," \
|
||||
"close, dep, dolt push. Version skew between builds empties the" \
|
||||
"dependency graph in .beads/issues.jsonl and reports success." \
|
||||
"" \
|
||||
"Fix now: nix develop (in this directory), then re-run the" \
|
||||
" command; or 'direnv allow' here." \
|
||||
"Fix for good: worktrees are created fresh per dispatch, so allowing" \
|
||||
" them one at a time never catches the next one. Put the" \
|
||||
" repo root under [whitelist] prefix in" \
|
||||
" ~/.config/direnv/direnv.toml."
|
||||
return 1
|
||||
}
|
||||
|
||||
pin_status=0
|
||||
assert_bd_pin || pin_status=2
|
||||
|
||||
# Install a verified database at .beads/embeddeddolt.
|
||||
install_tracker() {
|
||||
staged=$1
|
||||
live=.beads/embeddeddolt
|
||||
incoming=$live.incoming
|
||||
previous=$live.previous
|
||||
|
||||
rm -rf "$incoming" "$previous" || return 1
|
||||
cp -rf "$staged" "$incoming" || { rm -rf "$incoming"; return 1; }
|
||||
|
||||
if [ -d "$live" ] && ! mv -f "$live" "$previous"; then
|
||||
rm -rf "$incoming"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if mv -f "$incoming" "$live"; then
|
||||
rm -rf "$previous"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -d "$previous" ] && ! mv -f "$previous" "$live"; then
|
||||
log "ERROR: tracker left at $previous — move it back to $live by hand"
|
||||
fi
|
||||
rm -rf "$incoming"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── 2. bd on PATH ──────────────────────────────────────────────────────────
|
||||
export PATH="$PATH:/root/go/bin:$HOME/go/bin"
|
||||
|
||||
BD_INSTALLER_REF=f45b249ce6b40ba62aecc03949e6371e8f7c79d8 # tag v1.3.0
|
||||
BD_INSTALLER_URL="https://raw.githubusercontent.com/steveyegge/beads/$BD_INSTALLER_REF/scripts/install.sh"
|
||||
|
||||
if ! command -v bd >/dev/null 2>&1; then
|
||||
log "installing bd (beads v1.3.0)…"
|
||||
if ! curl -fsSL "$BD_INSTALLER_URL" -o /tmp/bd-install.sh 2>/tmp/bd-install.log; then
|
||||
log "WARNING: could not download the bd installer; see /tmp/bd-install.log."
|
||||
elif bash /tmp/bd-install.sh >>/tmp/bd-install.log 2>&1; then
|
||||
log "bd installed"
|
||||
else
|
||||
log "WARNING: bd install failed; see /tmp/bd-install.log (the script that"
|
||||
log " ran is at /tmp/bd-install.sh). Issue tracking unavailable."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Export PATH for the calling environment if AGENT_ENV_FILE is set
|
||||
if [ -n "${AGENT_ENV_FILE:-${CLAUDE_ENV_FILE:-}}" ]; then
|
||||
env_file="${AGENT_ENV_FILE:-${CLAUDE_ENV_FILE}}"
|
||||
echo 'export PATH="$PATH:/root/go/bin:$HOME/go/bin"' >> "$env_file"
|
||||
fi
|
||||
|
||||
if [ "$pin_status" -eq 0 ]; then
|
||||
assert_bd_pin || pin_status=2
|
||||
fi
|
||||
|
||||
# Do not run any tracker/config mutation through a bd binary whose version does
|
||||
# not match the repository pin.
|
||||
if [ "$pin_status" -eq 0 ] && command -v bd >/dev/null 2>&1; then
|
||||
bd metrics off >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# ── 3. Sync the issue tracker from the Dolt remote ─────────────────────────
|
||||
if [ "$pin_status" -eq 0 ] && command -v bd >/dev/null 2>&1; then
|
||||
DB_NAME="$(jq -r '.dolt_database // "improvise"' .beads/metadata.json 2>/dev/null || echo improvise)"
|
||||
ORIGIN="$(git remote get-url origin 2>/dev/null || true)"
|
||||
case "$ORIGIN" in
|
||||
git@github.com:*)
|
||||
ORIGIN="https://github.com/${ORIGIN#git@github.com:}"
|
||||
git remote add gh "$ORIGIN"
|
||||
;;
|
||||
esac
|
||||
ORIGIN="${ORIGIN%.git}"
|
||||
|
||||
if [ -n "$ORIGIN" ] && TMP=$(mktemp -d) && [ -d "$TMP" ]; then
|
||||
log "syncing issue tracker from $ORIGIN …"
|
||||
STAGE="$TMP/$DB_NAME"
|
||||
mkdir -p "$STAGE"
|
||||
|
||||
sync_ok=0
|
||||
if (
|
||||
cd "$STAGE" && git init -q . &&
|
||||
BD_NON_INTERACTIVE=1 bd init --non-interactive --skip-agents --skip-hooks -q \
|
||||
--remote "git+$ORIGIN"
|
||||
) >/tmp/bd-sync.log 2>&1 && [ -d "$STAGE/.beads/embeddeddolt" ]; then
|
||||
listing="$( (cd "$STAGE" && bd list --all 2>/dev/null) || true )"
|
||||
case "$listing" in
|
||||
"" | "No issues found."*) ;;
|
||||
*) sync_ok=1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ "$sync_ok" -eq 1 ] && install_tracker "$STAGE/.beads/embeddeddolt"; then
|
||||
log "issue tracker synced — 'bd ready' now reflects the remote, not issues.jsonl"
|
||||
elif [ -d .beads/embeddeddolt ]; then
|
||||
log "WARNING: tracker sync failed (see /tmp/bd-sync.log); kept the previous database."
|
||||
log " It may be out of date — verify any bead against the code."
|
||||
else
|
||||
log "WARNING: tracker sync failed; see /tmp/bd-sync.log."
|
||||
log " .beads/issues.jsonl is a STALE export — verify any bead against the code."
|
||||
fi
|
||||
rm -rf "$TMP"
|
||||
elif [ -n "$ORIGIN" ]; then
|
||||
log "WARNING: could not create a temporary directory; skipped the tracker sync."
|
||||
log " .beads/issues.jsonl is a STALE export — verify any bead against the code."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 4. Warm the build inside nix develop (optional) ────────────────────────
|
||||
# Uses nix develop to ensure the correct toolchain (rust from flake, wasm target, etc.)
|
||||
if [ "$SKIP_CARGO_WARM" -eq 0 ]; then
|
||||
if [ "$nix_status" -ne 0 ]; then
|
||||
log "ERROR: skipping required cargo warm because Nix bootstrap already failed"
|
||||
elif ! command -v nix >/dev/null 2>&1; then
|
||||
log "ERROR: nix not available; required cargo warm cannot run"
|
||||
nix_status=1
|
||||
else
|
||||
log "warming cargo build inside nix develop…"
|
||||
NIX_FLAGS=(--extra-experimental-features 'nix-command flakes')
|
||||
if nix "${NIX_FLAGS[@]}" develop --command cargo build --workspace --tests >/tmp/cargo-warm.log 2>&1; then
|
||||
log "workspace built (nix develop toolchain)"
|
||||
else
|
||||
log "ERROR: cargo build failed; see /tmp/cargo-warm.log"
|
||||
log " Required nix develop validation failed."
|
||||
nix_status=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 5. Environment notes ───────────────────────────────────────────────────
|
||||
cat <<'NOTES'
|
||||
[agent-bootstrap] Environment notes (see context/cloud-agents.md):
|
||||
• This repo REQUIRES `nix develop` (or direnv) for builds and tests.
|
||||
Bare system cargo that fails edition2024 is a misconfigured session.
|
||||
• The bootstrap seeded flake inputs over git (GitHub tarballs are blocked
|
||||
in cloud). If nix develop still fails, check /tmp/nix-seed.log.
|
||||
• `bd dolt push` is blocked outbound (403). Bead status changes stay local.
|
||||
• Never run a bare `bd init` in this repo: it rewrites AGENTS.md, CLAUDE.md
|
||||
and .claude/settings.json, and commits. Use --skip-agents --skip-hooks.
|
||||
• Use `scripts/kb` for knowledge base operations, not Prolog MCP.
|
||||
• Run commands inside `nix develop` or prefix with `nix develop --command`.
|
||||
NOTES
|
||||
|
||||
exit_status="$nix_status"
|
||||
if [ "$pin_status" -ne 0 ]; then
|
||||
exit_status="$pin_status"
|
||||
fi
|
||||
exit "$exit_status"
|
||||
+46
-9
@@ -5,36 +5,47 @@ set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>]
|
||||
usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>] [--reviews|--no-reviews]
|
||||
|
||||
--expect <sha> fail if the PR head is not this (prefix ok) at any poll
|
||||
--timeout <sec> give up after this long (default 1800)
|
||||
--interval <sec> seconds between polls (default 20)
|
||||
--reviews after checks complete, print reviews/comments (default)
|
||||
--no-reviews skip the reviews/comments printout
|
||||
|
||||
exit 0 every check COMPLETED, none failed, and GitHub says MERGEABLE with a
|
||||
state of CLEAN, UNSTABLE or HAS_HOOKS
|
||||
state of CLEAN, UNSTABLE or HAS_HOOKS (and reviews fetched if --reviews)
|
||||
exit 1 a check failed, or the PR is not mergeable (any other known state)
|
||||
exit 2 the head moved during the wait, or is not --expect
|
||||
exit 3 timed out with checks still running, or mergeability still UNKNOWN,
|
||||
or still BLOCKED with every registered check green
|
||||
exit 4 usage or environment error
|
||||
exit 5 the PR is MERGED or CLOSED, so there is nothing left to wait for
|
||||
exit 6 checks green/mergeable, but pr-reviews failed (fetch error, not "no reviews")
|
||||
|
||||
After exit 0, reviews and comments are printed (unless --no-reviews). Reviews are
|
||||
a separate API surface from comments; Codex/ChatGPT reviews arrive as *reviews*
|
||||
and are easy to miss if you only read the comment thread.
|
||||
EOF
|
||||
exit 4
|
||||
}
|
||||
|
||||
PR=""; EXPECT=""; TIMEOUT=1800; INTERVAL=20
|
||||
PR=""; EXPECT=""; TIMEOUT=1800; INTERVAL=20; SHOW_REVIEWS=1
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--expect) EXPECT="${2:-}"; shift 2 ;;
|
||||
--timeout) TIMEOUT="${2:-}"; shift 2 ;;
|
||||
--interval) INTERVAL="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) [ -z "$PR" ] || usage; PR="$1"; shift ;;
|
||||
--expect) EXPECT="${2:-}"; shift 2 ;;
|
||||
--timeout) TIMEOUT="${2:-}"; shift 2 ;;
|
||||
--interval) INTERVAL="${2:-}"; shift 2 ;;
|
||||
--reviews) SHOW_REVIEWS=1; shift ;;
|
||||
--no-reviews) SHOW_REVIEWS=0; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) [ -z "$PR" ] || usage; PR="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$PR" ] || usage
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# `gh` is shell-aliased to a different tool in this repo's shells, so the name
|
||||
# is never trusted: a real gh is located explicitly.
|
||||
fetch() {
|
||||
@@ -130,4 +141,30 @@ echo "pr-watch: head=$head $mergeable/$state $total checks, $failed failed"
|
||||
# An allowlist, so a state GitHub adds later fails closed. DIRTY and BLOCKED
|
||||
# can wear a MERGEABLE flag: context/code/tooling.md, "pr-watch and mergeability".
|
||||
[ "$mergeable" = MERGEABLE ] || exit 1
|
||||
case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) exit 0 ;; *) exit 1 ;; esac
|
||||
case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) ;; *) exit 1 ;; esac
|
||||
|
||||
# ── Reviews and comments ───────────────────────────────────────────────────
|
||||
# Checks are green and mergeable. Show reviews/comments so the agent sees any
|
||||
# feedback that needs answering before merge. This does not change the exit
|
||||
# code for success -- answering reviews is your responsibility. However, if
|
||||
# pr-reviews itself fails (usage error, network, etc.), we warn and exit 6.
|
||||
if [ "$SHOW_REVIEWS" -eq 1 ]; then
|
||||
echo ""
|
||||
echo "pr-watch: fetching reviews and comments (--no-reviews to skip)…"
|
||||
reviews_exit=0
|
||||
if [ -n "${PR_WATCH_FIXTURE_DIR:-}" ]; then
|
||||
PR_REVIEWS_FIXTURE_DIR="$PR_WATCH_FIXTURE_DIR" "$SCRIPT_DIR/pr-reviews" "$PR" || reviews_exit=$?
|
||||
else
|
||||
"$SCRIPT_DIR/pr-reviews" "$PR" || reviews_exit=$?
|
||||
fi
|
||||
echo ""
|
||||
if [ "$reviews_exit" -eq 0 ]; then
|
||||
echo "pr-watch: reviews shown above. Answer substantive feedback before merging."
|
||||
else
|
||||
echo "pr-watch: WARNING: pr-reviews failed (exit $reviews_exit). Reviews may not be shown." >&2
|
||||
echo "pr-watch: Run './scripts/pr-reviews $PR' manually to fetch reviews." >&2
|
||||
exit 6
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -20,8 +20,39 @@ run_case() {
|
||||
[ "$got" = "$want" ] && ok "$name" || bad "$name" "$got" "$want"
|
||||
}
|
||||
|
||||
# Like run_case but writes output to a caller-provided file.
|
||||
# Caller must NOT use command substitution (subshell loses ok/bad counters).
|
||||
# $1 name, $2 expected exit, $3 fixture dir, $4 output file, rest: extra args
|
||||
run_case_capture() {
|
||||
local name="$1" want="$2" dir="$3" outfile="$4"; shift 4
|
||||
PR_WATCH_FIXTURE_DIR="$dir" "$WATCH" 1 --interval 0 --timeout 1 "$@" >"$outfile" 2>&1
|
||||
local got=$?
|
||||
[ "$got" = "$want" ] && ok "$name" || bad "$name" "$got" "$want"
|
||||
}
|
||||
|
||||
mk() { mkdir -p "$1"; cat > "$1/$2"; }
|
||||
|
||||
# Create empty pr-reviews fixtures so pr-watch can call pr-reviews in fixture mode.
|
||||
# $1 dir, $2 head (must match the pr-watch fixture's headRefOid)
|
||||
mk_reviews_empty() {
|
||||
local dir="$1" head="$2"
|
||||
mkdir -p "$dir"
|
||||
printf '{"headRefOid":"%s","reviews":[]}\n' "$head" > "$dir/last.pr.json"
|
||||
echo '[]' > "$dir/last.inline.json"
|
||||
echo '[]' > "$dir/last.issue.json"
|
||||
}
|
||||
|
||||
# Create pr-reviews fixtures with a review. $1 dir, $2 head, $3 review commit
|
||||
mk_reviews_with_review() {
|
||||
local dir="$1" head="$2" review_commit="$3"
|
||||
mkdir -p "$dir"
|
||||
cat > "$dir/last.pr.json" <<EOF
|
||||
{"headRefOid":"$head","reviews":[{"submittedAt":"2026-09-25T12:00:00Z","state":"COMMENTED","author":{"login":"copilot"},"commit":{"oid":"$review_commit"},"body":"Test review body"}]}
|
||||
EOF
|
||||
echo '[]' > "$dir/last.inline.json"
|
||||
echo '[]' > "$dir/last.issue.json"
|
||||
}
|
||||
|
||||
# The state a fresh push is in: the PR exists, no check has registered yet.
|
||||
# A watcher keyed on "nothing pending" calls this finished and green.
|
||||
mk "$work/none" last.json <<'EOF'
|
||||
@@ -43,8 +74,43 @@ mk "$work/green" last.json <<'EOF'
|
||||
{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"},
|
||||
{"name":"skipped","status":"COMPLETED","conclusion":"SKIPPED"}]}
|
||||
EOF
|
||||
mk_reviews_empty "$work/green" "aaaaaaaaaaaa"
|
||||
run_case "all completed and green" 0 "$work/green"
|
||||
|
||||
# ── Reviews integration ────────────────────────────────────────────────────
|
||||
# pr-watch now calls pr-reviews after checks are green. Test that it works.
|
||||
mk "$work/green-reviews" last.json <<'EOF'
|
||||
{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","statusCheckRollup":[
|
||||
{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]}
|
||||
EOF
|
||||
mk_reviews_with_review "$work/green-reviews" "aaaaaaaaaaaa" "aaaaaaaaaaaa"
|
||||
run_case_capture "green with reviews shows review output" 0 "$work/green-reviews" "$work/out1.txt"
|
||||
if grep -q "pr-reviews:" "$work/out1.txt"; then
|
||||
ok "green with reviews prints pr-reviews output"
|
||||
else
|
||||
bad "green with reviews prints pr-reviews output" "no pr-reviews output" "pr-reviews: in output"
|
||||
fi
|
||||
|
||||
# --no-reviews skips the review printout
|
||||
run_case_capture "--no-reviews skips review output" 0 "$work/green-reviews" "$work/out2.txt" --no-reviews
|
||||
if grep -q "pr-reviews:" "$work/out2.txt"; then
|
||||
bad "--no-reviews skips review output" "pr-reviews in output" "no pr-reviews"
|
||||
else
|
||||
ok "--no-reviews actually skips review output"
|
||||
fi
|
||||
|
||||
# pr-reviews failure: if pr-reviews fails, pr-watch exits 6 (not 0)
|
||||
# Create a green fixture with MALFORMED review fixtures so pr-reviews fails
|
||||
mk "$work/green-bad-reviews" last.json <<'EOF'
|
||||
{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","statusCheckRollup":[
|
||||
{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]}
|
||||
EOF
|
||||
# Create malformed JSON that will cause jq to fail in pr-reviews
|
||||
echo 'NOT VALID JSON' > "$work/green-bad-reviews/last.pr.json"
|
||||
echo '[]' > "$work/green-bad-reviews/last.inline.json"
|
||||
echo '[]' > "$work/green-bad-reviews/last.issue.json"
|
||||
run_case "pr-reviews failure exits 6" 6 "$work/green-bad-reviews"
|
||||
|
||||
mk "$work/red" last.json <<'EOF'
|
||||
{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"UNSTABLE","statusCheckRollup":[
|
||||
{"name":"build","status":"COMPLETED","conclusion":"FAILURE"}]}
|
||||
@@ -112,6 +178,10 @@ for m in MERGEABLE CONFLICTING UNKNOWN; do
|
||||
fi
|
||||
sv="\"$s\""; [ "$s" = null ] && sv=null
|
||||
state "$work/table-$m-$s" last.json "\"$m\"" "$sv" "$green_rollup"
|
||||
# Add review fixtures for cases that exit 0 (pr-watch calls pr-reviews)
|
||||
if [ "$want" = 0 ]; then
|
||||
mk_reviews_empty "$work/table-$m-$s" "aaaaaaaaaaaa"
|
||||
fi
|
||||
run_case "table: $m/$s" "$want" "$work/table-$m-$s" --timeout 0
|
||||
done
|
||||
done
|
||||
@@ -148,6 +218,7 @@ gap_state "$work/gap" 1.json MERGEABLE BLOCKED "$not_yet | $build_running"
|
||||
gap_state "$work/gap" 2.json MERGEABLE BLOCKED "$not_yet"
|
||||
gap_state "$work/gap" 3.json MERGEABLE BLOCKED "$harness_running"
|
||||
gap_state "$work/gap" last.json MERGEABLE CLEAN '.'
|
||||
mk_reviews_empty "$work/gap" "aaaaaaaaaaaa"
|
||||
run_case "the needs-gap before the required check registers is not a failure" 0 "$work/gap" --timeout 60
|
||||
|
||||
gap_state "$work/gap-never" last.json MERGEABLE BLOCKED "$not_yet"
|
||||
|
||||
@@ -73,14 +73,15 @@ impl PanelContent for CategoryContent {
|
||||
|
||||
match &self.tree[index] {
|
||||
CatTreeEntry::Category {
|
||||
name,
|
||||
display,
|
||||
item_count,
|
||||
expanded,
|
||||
axis,
|
||||
..
|
||||
} => {
|
||||
let indicator = if *expanded { "▼" } else { "▶" };
|
||||
let (axis_str, axis_style) = axis_display(*axis);
|
||||
let name_part = format!("{indicator} {name} ({item_count})");
|
||||
let name_part = format!("{indicator} {display} ({item_count})");
|
||||
let axis_part = format!(" [{axis_str}]");
|
||||
|
||||
buf.set_string(inner.x, y, &name_part, base_style);
|
||||
@@ -114,6 +115,7 @@ mod tests {
|
||||
let cache = CategoryTreeCache {
|
||||
categories: vec![CategoryRow {
|
||||
name: name.to_string(),
|
||||
display: name.to_string(),
|
||||
axis: Axis::Row,
|
||||
items: vec!["x".to_string()],
|
||||
}],
|
||||
@@ -129,6 +131,29 @@ mod tests {
|
||||
buf.content()[31..59].iter().map(|c| c.symbol()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_category_header_draws_its_display_not_its_key() {
|
||||
let cache = CategoryTreeCache {
|
||||
categories: vec![CategoryRow {
|
||||
name: "Region".to_string(),
|
||||
display: "Sales Area".to_string(),
|
||||
axis: Axis::Row,
|
||||
items: vec!["x".to_string()],
|
||||
}],
|
||||
};
|
||||
let area = Rect::new(0, 0, 30, 4);
|
||||
let mut buf = Buffer::empty(area);
|
||||
Panel::new(
|
||||
CategoryContent::new(&cache, &Default::default()),
|
||||
&AppMode::Normal,
|
||||
0,
|
||||
)
|
||||
.render(area, &mut buf);
|
||||
let row: String = buf.content()[31..59].iter().map(|c| c.symbol()).collect();
|
||||
assert!(row.contains("Sales Area"), "{row}");
|
||||
assert!(!row.contains("Region"), "{row}");
|
||||
}
|
||||
|
||||
/// improvise-8l0c — `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn the_axis_tag_sits_beside_a_multibyte_category_name() {
|
||||
|
||||
+18
-1
@@ -79,7 +79,7 @@ impl<'a> Widget for TileBar<'a> {
|
||||
.iter()
|
||||
.map(|tile| {
|
||||
let (axis_symbol, _) = TileBar::axis_display(tile.axis);
|
||||
format!(" [{} {axis_symbol}] ", tile.category)
|
||||
format!(" [{} {axis_symbol}] ", tile.display)
|
||||
})
|
||||
.collect();
|
||||
let widths: Vec<u16> = labels.iter().map(|l| l.width() as u16).collect();
|
||||
@@ -190,6 +190,23 @@ mod tests {
|
||||
assert_eq!(was, now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_draws_its_display_not_its_key() {
|
||||
let mut app = app_with_two_categories();
|
||||
let wb = &mut app.model_state.workbook;
|
||||
wb.model
|
||||
.category_mut("Region")
|
||||
.unwrap()
|
||||
.set_display("Sales Area");
|
||||
let cache = project::tile_bar_of(&app);
|
||||
let area = Rect::new(0, 0, 80, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
TileBar::new(&cache, &AppMode::Normal, 0).render(area, &mut buf);
|
||||
let text = buf_text(&buf);
|
||||
assert!(text.contains("Sales Area"), "{text}");
|
||||
assert!(!text.contains("Region"), "{text}");
|
||||
}
|
||||
|
||||
/// Pins the rendered line. The equivalence test above says the *inputs*
|
||||
/// are unchanged by the migration; this says the drawing of them is.
|
||||
#[test]
|
||||
|
||||
+21
-1
@@ -62,7 +62,7 @@ impl PanelContent for ViewContent<'_> {
|
||||
};
|
||||
|
||||
let prefix = if is_active_view { "▶ " } else { " " };
|
||||
let name_text = format!("{prefix}{}", summary.name);
|
||||
let name_text = format!("{prefix}{}", summary.display);
|
||||
let y = inner.y + index as u16;
|
||||
buf.set_string(inner.x, y, &name_text, style);
|
||||
|
||||
@@ -98,6 +98,7 @@ mod tests {
|
||||
let cache = ViewListCache {
|
||||
views: vec![ViewSummary {
|
||||
name: view_name.to_string(),
|
||||
display: view_name.to_string(),
|
||||
axes: vec![("Region".to_string(), Axis::Row)],
|
||||
}],
|
||||
};
|
||||
@@ -119,6 +120,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_view_row_draws_its_display_and_marks_the_active_view_by_its_key() {
|
||||
use crate::protocol::ViewSummary;
|
||||
use crate::ui::panel::Panel;
|
||||
use ratatui::{buffer::Buffer, widgets::Widget};
|
||||
let cache = ViewListCache {
|
||||
views: vec![ViewSummary {
|
||||
name: "Default".to_string(),
|
||||
display: "Overview".to_string(),
|
||||
axes: vec![],
|
||||
}],
|
||||
};
|
||||
let area = Rect::new(0, 0, 30, 4);
|
||||
let mut buf = Buffer::empty(area);
|
||||
Panel::new(ViewContent::new(&cache, "Default"), &AppMode::Normal, 0).render(area, &mut buf);
|
||||
let row: String = buf.content()[31..59].iter().map(|c| c.symbol()).collect();
|
||||
assert!(row.contains("▶ Overview"), "{row}");
|
||||
}
|
||||
|
||||
fn app_with_two_views() -> App {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
|
||||
+26
@@ -155,9 +155,35 @@ function onFrame(text) {
|
||||
console.error("improvise: frame rejected", text, err);
|
||||
return;
|
||||
}
|
||||
// Check for pending downloads (improvise-2mqz). The WASM client stores
|
||||
// download bytes from DocumentBytes/CsvBytes frames; we trigger the download
|
||||
// here via Blob + <a download>.
|
||||
try {
|
||||
const downloadJson = client.take_pending_download();
|
||||
if (downloadJson) {
|
||||
const download = JSON.parse(downloadJson);
|
||||
triggerDownload(download.filename, download.bytes, download.kind);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("improvise: download failed", err);
|
||||
}
|
||||
repaint();
|
||||
}
|
||||
|
||||
/** Trigger a browser download from bytes (improvise-2mqz). */
|
||||
function triggerDownload(filename, bytes, kind) {
|
||||
const mimeType = kind === "Document" ? "text/plain" : "text/csv";
|
||||
const blob = new Blob([new Uint8Array(bytes)], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Transports.
|
||||
|
||||
/** The in-page worker: the standalone deployment, no server process. */
|
||||
|
||||
@@ -91,6 +91,9 @@ test("the page still reaches the client through a handful of calls", () => {
|
||||
"on_key_event",
|
||||
"render_model",
|
||||
"set_viewport",
|
||||
// improvise-2mqz: download frames stay in the Rust fold until the page
|
||||
// takes the bytes and hands them to Blob + <a download>.
|
||||
"take_pending_download",
|
||||
"typed_cell",
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user