Commit Graph

3 Commits

Author SHA1 Message Date
Edward Langley 64bcb821da fix(cli): a script's command is built against the cursor it will run against (improvise-6uva)
`improvise cmd` and `improvise script` resolved every command through
`CmdRegistry::parse` — the pure arm, which builds `CursorState::default()`: row
0, col 0, and a grid of zero rows and zero columns. The interactive arm, which
builds `CursorState::from_ctx(ctx)`, was never reached from a script. So every
`Move` and `enter-advance` snapshotted a zeroed cursor at PARSE time, clamped to
(0, 0) and emitted `SetSelected(0, 0)` — worse than a no-op, since it RESETS the
cursor. Nothing could position it, so every cursor-reading command was pinned to
the origin.

Reproduced at a9cad15 on a document with Region on row carrying North, South,
East: `move-selection 2 0 . hide-selected-row-item` and a bare
`hide-selected-row-item` both wrote `hidden: Region/North`. After the fix the
first writes `hidden: Region/East`.

The repair is `parse::segments` + `parse::parse_segment_interactive`, with the
headless loop re-parsing per segment, because command n must see command n-1's
cursor. Line atomicity is preserved by keeping the existing whole-line
`parse_line` as a syntax gate, so a line that parsed before still parses and the
only behavioural change is which cursor the commands see.

Two things the ticket did not predict. The TUI's `:` line had the identical
defect — `ExecuteCommand` also used the pure arm — and is fixed the same way.
And doing that surfaced a second instance of the same shape one level down:
`register_pure` built an interactive arm that refused EMPTY args while its own
pure arm accepted them, so `w` with no path parsed one way and refused the
other, breaking `improvise cmd 'w'` and `:w` the moment either re-parsed
interactively. Both arms are now `parse`.

Evidence: context/test-evidence/cli.md; reasoning: context/code/cli.md section 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 18:59:08 -07:00
Edward Langley 42cbf0932b docs: name the follow-up, and stop the scratch dir looking like a ticket id
improvise-udt2 is the ticket context/code/cli.md section 3 points at: a status
message carries no severity, so a refusal that does not abort cannot set a
non-zero exit. It names both disproved classifiers, so the next attempt does
not rediscover them.

And the per-test scratch directory was named `improvise-cli-<tag>`, which
matches the ticket-id shape the AGENTS.md audit greps for. An ID-shaped
non-ID is worse than a dead ID, because the diagnosis is identical and the
repair is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 08:20:48 -07:00
Edward Langley c13466f1d9 fix(cli): the headless commands were silent whether they worked or not
improvise-q914 and improvise-j6x8 are one bug seen twice, and the second
measured it: `run_headless_commands` applied effects and never read
`app.view_state.status_msg`, setting `exit_code` only for parse errors. A
successful command and a refused one therefore produced byte-identical
output — nothing — and exit 0 for both. `improvise script` is the same
function, so it shared the hole exactly.

Three decisions, argued in `context/code/cli.md`:

**Everything to stderr.** The tempting split — stdout for a report, stderr
for a refusal — cannot be built honestly, because `status_msg` is a bare
`String` with no severity and both candidate classifiers are disproved by
code already in the tree. "A refusal aborted" is false: `AddItem` writes its
`Err` to the status and does not abort, and that is q914's own example. "A
refusal is anything that spoke" is false too: `cmd/fit.rs` reports the seed
that reproduces a fit on **success**. Guessing between them is the adjacent
check design-principles §7 names.

**Exit 1 on a parse error or an aborted batch, and no further.** Both are
real flags. A refusal that only writes a status is now printed but still
exits 0, because nothing can classify it; the README says so plainly rather
than over-promising, and closing it needs a severity on `ViewState`, which
is a wire change reaching the browser.

**No quiet flag.** Silence is never the default and `2>/dev/null` already
exists.

Also: a run that changed nothing no longer rewrites the document. Saving
unconditionally normalised it — `1e20` to its twenty digits — which turns a
hand-edited fixture into a silent no-op. The gate accumulates
`model_state.dirty` per command rather than reading it once at the end,
because `effect::Save` clears the flag and `[add-item, w elsewhere]` would
otherwise drop the edit.

And the three clap doc strings advertised JSON, which the parser refuses.

`tests/headless_cli.rs` pins all of it at the process boundary, where an
exit code and a stream exist. Six of its twelve tests fail at bf83294. The
rival worth injecting — print on the happy path, stay silent on the
refusal — fails five of them.

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