ail: merge-prose subcommand + PROSE_ROUNDTRIP.md (iter 20d)

Closes family 20: prose-edit cycle is end-to-end. ail merge-prose
<original.ail.json> <edited.prose.txt> composes a shaped LLM prompt
to stdout. User pipes to their LLM (Claude/etc.), gets new .ail.json,
runs ail check, saves.

No built-in API client — AILang stays a compiler + tooling, the LLM
mediator is supplied externally. PROSE_ROUNDTRIP.md documents the
6-step cycle, the prompt contract (preserve modes/effects/tail
flags from original), failure modes, and the design choice to omit
an API client.

Family 20 now closes: 20a renderer + 20b polish + 20d mediator
shipped; 20c rolled into 20a. Three deferred polishes (let-inlining,
print sugar, doc-wrap widow control) wait for corpus signal.
This commit is contained in:
2026-05-08 18:22:46 +02:00
parent 9cf0e3e81c
commit 9c09dfbc8d
4 changed files with 510 additions and 0 deletions
+110
View File
@@ -9057,3 +9057,113 @@ re-rendered to incorporate the wrapping.
corpus shows it's needed.
- **`do io/print_int(x)``print(x)`** — needs type context
to be safe; deferred.
## 2026-05-08 — Iter 20d: prose round-trip mediator shipped
The closing piece of family 20: a documented prompt template and a
thin CLI helper that compose the prompt for the prose-edit cycle. No
LLM client of our own — the user pipes the prompt to whichever model
they prefer.
### The cycle
```
1. ail prose foo.ail.json > foo.prose.txt
2. $EDITOR foo.prose.txt # human edits freely
3. ail merge-prose foo.ail.json foo.prose.txt > prompt.txt
4. cat prompt.txt | <your-llm-cli> > foo.new.ail.json
5. ail check foo.new.ail.json
6. mv foo.new.ail.json foo.ail.json # if check is clean
```
Step 1 was 20a/20b. Step 3 is this iter. Steps 5/6 are existing
`ail check` + plain `mv`. The mediator is the LLM, not the
compiler — which is why the cycle is iterative (re-run step 4 with
the diagnostic pasted in if `ail check` rejects the result).
### What was built
- **`docs/PROSE_ROUNDTRIP.md`** — new top-level doc, 153 lines.
Sections: *Why* (prose is lossy, re-integration needs LLM
mediation), *The cycle* (the 7-step pipeline above), *The
prompt template* (the literal text the CLI emits, so a human
can compose by hand), *Failure modes* (markdown fences,
invalid JSON, schema-valid-but-`ail check`-fails — fix in each
case is to paste the corrective note and re-prompt), *Why no
built-in API client* (deliberate scope: AILang stays a
compiler, the user already has clients).
- **`Cmd::MergeProse { original, edited }`** in
`crates/ail/src/main.rs`. Reads both files, calls the helper,
prints to stdout. Errors via `anyhow` context on file-read
failures.
- **`fn compose_merge_prose_prompt(orig_ail_json: &str,
edited_prose: &str) -> String`** — pure helper in `main.rs`.
Both payloads insert verbatim between heredoc-style markers
(`<<<ORIGINAL_AIL_JSON ... ORIGINAL_AIL_JSON`,
`<<<EDITED_PROSE ... EDITED_PROSE`); the framing is the
role/contract/output/schema-essentials sections that match
the prose in `PROSE_ROUNDTRIP.md` byte-for-byte.
### Test surface
- **3 unit tests** in `main.rs` (`#[cfg(test)] mod tests`):
`merge_prose_prompt_contains_both_inputs_verbatim`,
`merge_prose_prompt_has_all_framing_sections`,
`merge_prose_prompt_is_deterministic`.
- **1 e2e CLI smoke test** in `tests/e2e.rs`:
`merge_prose_prints_framed_prompt` — writes two temp files,
invokes `ail merge-prose`, asserts exit 0 and that stdout
carries both inputs verbatim plus the role-statement
landmark.
### Why no built-in API client
AILang stays a compiler + tooling. Shipping an HTTP client to a
specific LLM vendor would mean an API-key story, rate limiting,
streaming semantics, error mapping, and version tracking — all
carrying zero language-level value. Users already have Claude
Code, the Anthropic SDK, the OpenAI CLI, `curl`, or any other
client they prefer; `ail merge-prose` composes the prompt and
gets out of the way. The Unix-pipe shape
(`ail merge-prose ... | client | ail check`) makes the cycle
scriptable without lock-in to one vendor. This is documented as a
design choice in `docs/PROSE_ROUNDTRIP.md` so it stays a
deliberate stance, not a gap.
### Files
- `docs/PROSE_ROUNDTRIP.md` — new (153 lines).
- `crates/ail/src/main.rs``Cmd::MergeProse` variant,
dispatch arm, `compose_merge_prose_prompt` helper, inline
`mod tests` (3 unit tests). +~180 lines.
- `crates/ail/tests/e2e.rs``merge_prose_prints_framed_prompt`
smoke test (~50 lines).
### Build/test status
- `cargo build --workspace` — green, no warnings.
- `cargo test --workspace` — all green: 3 new unit tests on the
`ail` binary, e2e count 69 → 70, prose-unit / prose-snapshot /
ailang-check / ailang-surface all unchanged-green.
### State of family 20
20a + 20b + 20d done. 20c was rolled into 20a (the `ail prose`
subcommand was a natural unit with the renderer skeleton, not a
separate iter). The family closes pending real-corpus signal on
20b's deferred polishes (let-inlining, `do io/print_int(x)`
`print(x)`, doc-wrap widow control). If those don't turn up in
practice the family stays as-shipped.
### Out of scope (explicit, not deferred)
- Built-in API client. AILang shells out to no LLM. The Unix
pipe is the integration surface.
- AST-aware merge (semantic diff between original and re-emitted
`.ail.json`). The mediator is the LLM; if a structural diff
becomes useful later, `ail diff` already exists for that
purpose post-merge.
- Schema validation in the `merge-prose` CLI itself. `ail check`
validates the merge product; `merge-prose` only composes the
prompt.