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
+153
View File
@@ -0,0 +1,153 @@
# Prose round-trip cycle
## Why
The prose surface (`ail prose`, Iter 20a/20b) is a deliberately
**lossy** projection of a `.ail.json` module: it strips `(con T)`
wrappings, drops redundant parentheses, infixes arithmetic, suppresses
schema rigor that the LLM can re-derive. That makes prose pleasant to
read and edit — but it also means there is no syntactic parser that
turns edited prose back into a `.ail.json`.
Re-integration of free-text edits therefore requires an **LLM
mediator**: a model that understands both the prose intent and the
load-bearing detail in the original `.ail.json`, and emits a new
`.ail.json` that respects both. This document describes the workflow,
the prompt template `ail merge-prose` composes for that mediator, and
the failure modes to watch for.
## 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 is the deterministic projection (Iter 20a/20b).
Step 3 is the prompt composer this iter ships.
Step 4 is the user's external LLM client — Claude Code, the
Anthropic API, OpenAI's CLI, anything that takes a prompt on stdin and
emits text on stdout. AILang ships no client of its own; see *Why no
built-in API client* below.
Steps 5 and 6 reuse existing tooling: `ail check` validates schema +
typechecks, then the user accepts the new module.
## The prompt template
`ail merge-prose <original.ail.json> <edited.prose.txt>` reads both
files and prints the following text to stdout. Nothing more, nothing
less — no fences, no JSON envelope. The shape is exactly what a human
would compose by hand if they wanted to drive the same cycle without
the CLI helper.
```
You are integrating prose edits back into an AILang module.
ROLE
Your job is to produce an updated AILang JSON-AST (.ail.json) that
reflects the human's prose edits while preserving the load-bearing
semantic detail from the original .ail.json.
CONTRACT
The prose is the source of intent: the human edited it to express
what the program should now do. The original .ail.json carries
load-bearing detail that the prose surface elides — preserve those
details unless the prose explicitly contradicts them. Specifically:
- Mode annotations on fn parameters (`own T`, `borrow T`).
These are hard contracts (memory model). The prose shows them,
but if the prose is ambiguous, default to the original.
- Effect annotations on return types (`with IO`, `with Diverge`).
Prose shows these too, but again: if uncertain, keep what the
original had.
- `tail` flags on calls. The prose prints `tail f(x)`; if the
edit moved a call, decide whether the new position is still in
tail position and flag accordingly.
- Doc strings (`///` lines).
- Type annotations on signatures and lambdas.
- Constructor names and arities (the prose `Cons(h, t)` must
round-trip to a `Term::Ctor` with the right `type` field).
OUTPUT
Output ONLY the new .ail.json bytes. No commentary, no markdown
fences, no preamble or postscript. The output must be valid JSON
parsable by `ail parse` (i.e. by `serde_json` against the
`ailang/v0` schema). The first byte should be `{` and the last
non-whitespace byte should be `}`.
SCHEMA ESSENTIALS
A full reference lives in docs/DESIGN.md ("Data model (MVP)"). The
shape is:
- Module: { "schema": "ailang/v0", "name": ..., "imports": [...],
"defs": [...] }
- Defs are tagged via `kind` ("fn" | "type" | "const").
- Types are tagged via `k` ("con" | "fn" | "var" | "forall").
- Terms are tagged via `t` ("lit" | "var" | "app" | "let" | "if"
| "do" | "ctor" | "match" | "lam" | "seq").
- Patterns are tagged via `p` ("var" | "lit" | "ctor" | "wild").
- On `fn-type`: `param_modes` (one of "own"/"borrow" per
parameter) and `effects` are mandatory. `ret_mode` is mandatory
when the return type carries a mode.
- Constructor application uses `Term::Ctor` (`"t": "ctor"`,
fields `type` + `ctor` + `args`), NOT `Term::App`. The prose
surface elides the `type` tag (`Cons(h, t)` instead of
`(term-ctor IntList Cons h t)`) — re-introduce it.
- Base type names that appear bare in prose (`Int`, `Bool`,
`Unit`, user types like `IntList`) must be wrapped as
`{"k": "con", "name": "Int"}` etc. in the JSON.
ORIGINAL .ail.json
<<<ORIGINAL_AIL_JSON
{original_ail_json_here}
ORIGINAL_AIL_JSON
EDITED PROSE
<<<EDITED_PROSE
{edited_prose_here}
EDITED_PROSE
Emit the updated .ail.json now.
```
The two payloads (original JSON, edited prose) are inserted verbatim
between the heredoc-style markers. `merge-prose` does not strip,
re-encode, or otherwise transform either input — both go in
byte-for-byte.
## Failure modes
The mediator is an LLM, so the cycle is intentionally iterative.
Common failure modes and their fix:
- **Output wrapped in markdown fences (` ```json ... ``` `).** Strip
the fences (`sed -n '/^{/,/^}/p'`) and re-run `ail check`. If the
LLM does this consistently, paste a corrective note ("emit raw
JSON, no fences") at the top of the next prompt.
- **Output contains commentary before/after the JSON.** Same
treatment. Most modern LLMs respect "OUTPUT ONLY" but not all.
- **Invalid JSON / `serde_json` rejects.** Re-run with the parse
error pasted into the prompt as a corrective note.
- **Schema-valid but `ail check` fails.** Re-run with the
diagnostic messages pasted in. Common cases: missing
`param_modes`, `Term::App` instead of `Term::Ctor`, bare type
names instead of `{"k":"con","name":"…"}`.
- **Preserved detail dropped (mode flipped, doc string lost).**
Re-run with a corrective note naming the missing detail.
The cycle converges in 13 rounds for typical edits.
## Why no built-in API client
AILang stays a compiler + tooling. Shipping an HTTP client to one
particular LLM vendor would mean an API key story, rate limiting,
streaming semantics, error mapping, version tracking — all carrying
zero language-level value. The user already has 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`)
also makes the cycle scriptable without lock-in to one vendor.