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.
6.4 KiB
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-runail 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_jsonrejects. Re-run with the parse error pasted into the prompt as a corrective note. - Schema-valid but
ail checkfails. Re-run with the diagnostic messages pasted in. Common cases: missingparam_modes,Term::Appinstead ofTerm::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 1–3 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.