From 35c6eb5736ba7ee20eed7db29db2347ea4a9f128 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 12 May 2026 00:16:23 +0200 Subject: [PATCH] =?UTF-8?q?plan:=20iter=2023.5=20prelude=20free=20fns=20+?= =?UTF-8?q?=20E2E=20=E2=80=94=208=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-05-12-iter-23.5.md | 1416 ++++++++++++++++++++++++++++ 1 file changed, 1416 insertions(+) create mode 100644 docs/plans/2026-05-12-iter-23.5.md diff --git a/docs/plans/2026-05-12-iter-23.5.md b/docs/plans/2026-05-12-iter-23.5.md new file mode 100644 index 0000000..899896f --- /dev/null +++ b/docs/plans/2026-05-12-iter-23.5.md @@ -0,0 +1,1416 @@ +# Iter 23.5 — Prelude free fns + E2E — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-11-23-eq-ord-prelude.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` +> to run this plan. Steps use `- [ ]` checkboxes for tracking. No +> task contains a `git commit` step — the implementer never commits +> (disc.1 discipline). The Boss commits the iter at the end. + +**Goal:** Ship the five polymorphic free fns `ne`/`lt`/`le`/`gt`/`ge` +in `examples/prelude.ail.json` plus the three E2E fixtures +(positive, user-ADT, negative-Float) that close milestone 23. + +**Architecture:** All work rides on top of the unified mono pass +shipped in iter 23.4 (commit `a1692a4`). The five free fns are +polymorphic `Def::Fn`s with class constraints; `monomorphise_workspace` +already specialises them via the free-fn arm. Bodies are LLM-natural +expressions: `ne` calls the `not` builtin over `eq`; `lt`/`le`/`gt`/`ge` +pattern-match on `compare` against `LT`/`GT` plus a wildcard. The +Float-NoInstance acceptance is a typecheck-time gate — the mono pass +never runs because constraint resolution fails first. + +**Tech Stack:** + +- `examples/prelude.ail.json` — five new top-level `kind: "fn"` entries +- `examples/eq_ord_polymorphic.ail.json` (new) — positive E2E fixture +- `examples/eq_ord_user_adt.ail.json` (new) — user-ADT E2E fixture +- `examples/eq_float_noinstance.ail.json` (new) — negative-Float fixture +- `crates/ail/tests/prelude_free_fns.rs` (new) — workspace tests per fn +- `crates/ail/tests/eq_ord_e2e.rs` (new) — three E2E binary-run tests +- `crates/ail/tests/eq_float_noinstance.rs` (new) — diagnostic pin +- `crates/ailang-check/src/lib.rs:1607-1613` — Float-aware NoInstance + message addendum at construction site +- `docs/DESIGN.md:1702` — §"Decision 11 / Prelude classes" amendment +- `docs/roadmap.md:44-64` — Post-22 Prelude entry split + +--- + +## Files this plan creates or modifies + +**Create:** + +- `examples/prelude.ail.json` — extend with 5 new `Def::Fn`s (modify, not create) +- `examples/eq_ord_polymorphic.ail.json` — positive E2E +- `examples/eq_ord_user_adt.ail.json` — user-ADT E2E +- `examples/eq_float_noinstance.ail.json` — negative-Float E2E +- `crates/ail/tests/prelude_free_fns.rs` — workspace tests per fn +- `crates/ail/tests/eq_ord_e2e.rs` — binary-run E2E tests +- `crates/ail/tests/eq_float_noinstance.rs` — diagnostic pin + +**Modify:** + +- `examples/prelude.ail.json:221` — insertion point for the five free fns is between line 220 (`}` closing last instance) and line 221 (`]` closing `defs`) +- `crates/ailang-check/src/lib.rs:1607-1613` — Float-aware NoInstance addendum at construction site (current bare-message anchor at `lib.rs:563-566`) +- `docs/DESIGN.md:1702-1704` — insertion of Milestone-23 amendment paragraph between "see typeclasses spec amendments..." and "Primitive output goes through io/print_int..." +- `docs/roadmap.md:44-64` — split the Post-22 Prelude entry into shipped Eq/Ord half (`[x]`) and pending Show/print half (`[ ]`) + +**Test:** + +- `crates/ail/tests/prelude_free_fns.rs` — 5 functions: `ne_at_int`, `lt_at_int`, `le_at_str`, `gt_at_bool`, `ge_at_int` (and a hash-stability pin for `ne__Int` mirroring `mono_hash_stability.rs:34-41`) +- `crates/ail/tests/eq_ord_e2e.rs` — 3 functions: `eq_ord_polymorphic_runs_end_to_end`, `eq_ord_user_adt_runs_end_to_end`, `eq_ord_user_adt_eq_intbox_hash_stable` +- `crates/ail/tests/eq_float_noinstance.rs` — 1 function: `eq_at_float_fires_float_aware_noinstance` + +--- + +## Task 1: Add `ne` free fn to prelude + +The simplest free fn: `ne : forall a. Eq a => (a borrow, a borrow) -> Bool` +with body `not(eq(x, y))`. Uses the `not` builtin (registered at +`crates/ailang-check/src/builtins.rs:127`, signature `(Bool) -> Bool`). +Tests `ne` at Int (true and false cases) and pins `ne__Int`'s mono-symbol hash. + +**Files:** + +- Modify: `examples/prelude.ail.json:221` (insert before closing `]` of `defs`) +- Create: `crates/ail/tests/prelude_free_fns.rs` (this task scaffolds the file; later tasks extend it) + +- [ ] **Step 1: Write the failing test** + +Create `crates/ail/tests/prelude_free_fns.rs`: + +```rust +use ailang_core::workspace::load_workspace; +use ailang_check::{check_workspace, monomorphise_workspace}; +use std::path::PathBuf; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../examples") + .join(name) +} + +fn mono_symbol_names(workspace_name: &str) -> Vec { + let ws = load_workspace(&fixture(workspace_name)).expect("load"); + let diags = check_workspace(&ws); + assert!(diags.is_empty(), "typecheck failed: {:#?}", diags); + let mono = monomorphise_workspace(&ws).expect("mono"); + mono.modules + .iter() + .flat_map(|m| m.defs.iter()) + .filter_map(|d| match d { + ailang_core::ast::Def::Fn(f) => Some(f.name.clone()), + _ => None, + }) + .collect() +} + +#[test] +fn ne_at_int_produces_mono_symbol() { + let names = mono_symbol_names("ne_at_int_smoke.ail.json"); + assert!( + names.iter().any(|n| n == "ne__Int"), + "expected ne__Int in workspace, found: {names:#?}" + ); +} +``` + +Also create `examples/ne_at_int_smoke.ail.json`: + +```json +{ + "schema": "ailang/v0", + "name": "ne_at_int_smoke", + "imports": [], + "defs": [ + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "body": { + "t": "do", "op": "io/print_int", + "args": [{ + "t": "if", + "cond": { + "t": "app", + "fn": { "t": "var", "name": "ne" }, + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 3 } }, + { "t": "lit", "lit": { "kind": "int", "value": 5 } } + ] + }, + "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } } + }] + } + } + ] +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --workspace -p ail --test prelude_free_fns ne_at_int_produces_mono_symbol` + +Expected: FAIL with "typecheck failed" (the prelude has no `ne` yet so `ne` is unresolved). + +- [ ] **Step 3: Write minimal implementation** + +Edit `examples/prelude.ail.json`. Before the closing `]` of `defs` +(line 221), insert a new top-level `Def::Fn`: + +```json + , + { + "kind": "fn", + "name": "ne", + "doc": "Polymorphic disequality. `ne x y` ≡ not (eq x y). Ships in milestone 23 as the Eq-class free helper.", + "type": { + "k": "forall", + "vars": ["a"], + "constraints": [{ "class": "Eq", "type": { "k": "var", "name": "a" } }], + "body": { + "k": "fn", + "params": [ + { "k": "var", "name": "a" }, + { "k": "var", "name": "a" } + ], + "param_modes": ["borrow", "borrow"], + "ret": { "k": "con", "name": "Bool" }, + "effects": [] + } + }, + "params": ["x", "y"], + "body": { + "t": "app", + "fn": { "t": "var", "name": "not" }, + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "eq" }, + "args": [ + { "t": "var", "name": "x" }, + { "t": "var", "name": "y" } + ] + }] + } + } +``` + +(The leading comma terminates the previous `instance` entry.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --workspace -p ail --test prelude_free_fns ne_at_int_produces_mono_symbol` + +Expected: PASS + +--- + +## Task 2: Add `lt` / `le` / `gt` / `ge` free fns to prelude + +Four polymorphic free fns with the same structural pattern: +`forall a. Ord a => (a borrow, a borrow) -> Bool`, body +pattern-matches `compare(x, y)` against an `Ordering` ctor and a wildcard. +The match-term shape mirrors the existing `examples/cmp_max_smoke.ail.json:21-32` +precedent (proven case-on-compare; bare ctor `LT` works without prelude qualification per `examples/ordering_match.ail.json`). + +Pattern table: + +| Fn | Hit-ctor | Hit-body | Wildcard-body | +|-----|----------|----------|---------------| +| `lt` | `LT` | `true` | `false` | +| `le` | `GT` | `false` | `true` | +| `gt` | `GT` | `true` | `false` | +| `ge` | `LT` | `false` | `true` | + +**Files:** + +- Modify: `examples/prelude.ail.json` (insert four more `Def::Fn`s after `ne`) +- Modify: `crates/ail/tests/prelude_free_fns.rs` (extend with four tests) + +- [ ] **Step 1: Write the failing tests** + +Extend `crates/ail/tests/prelude_free_fns.rs` with: + +```rust +#[test] +fn lt_at_int_produces_mono_symbol() { + let names = mono_symbol_names("lt_at_int_smoke.ail.json"); + assert!( + names.iter().any(|n| n == "lt__Int"), + "expected lt__Int in workspace, found: {names:#?}" + ); +} + +#[test] +fn le_at_str_produces_mono_symbol() { + let names = mono_symbol_names("le_at_str_smoke.ail.json"); + assert!( + names.iter().any(|n| n == "le__Str"), + "expected le__Str in workspace, found: {names:#?}" + ); +} + +#[test] +fn gt_at_bool_produces_mono_symbol() { + let names = mono_symbol_names("gt_at_bool_smoke.ail.json"); + assert!( + names.iter().any(|n| n == "gt__Bool"), + "expected gt__Bool in workspace, found: {names:#?}" + ); +} + +#[test] +fn ge_at_int_produces_mono_symbol() { + let names = mono_symbol_names("ge_at_int_smoke.ail.json"); + assert!( + names.iter().any(|n| n == "ge__Int"), + "expected ge__Int in workspace, found: {names:#?}" + ); +} +``` + +Create the four corresponding smoke fixtures. Each follows the same +shape as `ne_at_int_smoke.ail.json` from Task 1, with the +`fn` name and arg types changed appropriately. For example, +`examples/lt_at_int_smoke.ail.json`: + +```json +{ + "schema": "ailang/v0", + "name": "lt_at_int_smoke", + "imports": [], + "defs": [ + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "body": { + "t": "do", "op": "io/print_int", + "args": [{ + "t": "if", + "cond": { + "t": "app", + "fn": { "t": "var", "name": "lt" }, + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 3 } }, + { "t": "lit", "lit": { "kind": "int", "value": 5 } } + ] + }, + "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } } + }] + } + } + ] +} +``` + +For `le_at_str_smoke.ail.json`, swap `"name": "lt"` → `"name": "le"` +and the two int literals for two str literals: +`{ "t": "lit", "lit": { "kind": "str", "value": "abc" } }` and +`{ "t": "lit", "lit": { "kind": "str", "value": "abd" } }`. + +For `gt_at_bool_smoke.ail.json`, use `"name": "gt"` and two bool +literals: `{ "t": "lit", "lit": { "kind": "bool", "value": true } }` +and `{ "t": "lit", "lit": { "kind": "bool", "value": false } }`. + +For `ge_at_int_smoke.ail.json`, use `"name": "ge"` and two int +literals. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --workspace -p ail --test prelude_free_fns lt_at_int_produces_mono_symbol` + +Expected: FAIL with "typecheck failed" (`lt` not yet in prelude). + +Same for `le_at_str`, `gt_at_bool`, `ge_at_int`. All four FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Edit `examples/prelude.ail.json`. After the `ne` entry from Task 1 +(before the closing `]` of `defs`), insert four more `Def::Fn`s. +First, `lt`: + +```json + , + { + "kind": "fn", + "name": "lt", + "doc": "Polymorphic strict-less-than. `lt x y` ≡ case compare x y of LT -> True; _ -> False. Ships in milestone 23 as the Ord-class free helper.", + "type": { + "k": "forall", + "vars": ["a"], + "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }], + "body": { + "k": "fn", + "params": [ + { "k": "var", "name": "a" }, + { "k": "var", "name": "a" } + ], + "param_modes": ["borrow", "borrow"], + "ret": { "k": "con", "name": "Bool" }, + "effects": [] + } + }, + "params": ["x", "y"], + "body": { + "t": "match", + "scrutinee": { + "t": "app", + "fn": { "t": "var", "name": "compare" }, + "args": [ + { "t": "var", "name": "x" }, + { "t": "var", "name": "y" } + ] + }, + "arms": [ + { + "pat": { "p": "ctor", "ctor": "LT", "fields": [] }, + "body": { "t": "lit", "lit": { "kind": "bool", "value": true } } + }, + { + "pat": { "p": "wild" }, + "body": { "t": "lit", "lit": { "kind": "bool", "value": false } } + } + ] + } + } +``` + +Then `le` (hit-ctor `GT`, hit-body `false`, wild-body `true`): + +```json + , + { + "kind": "fn", + "name": "le", + "doc": "Polymorphic less-than-or-equal. `le x y` ≡ case compare x y of GT -> False; _ -> True.", + "type": { + "k": "forall", + "vars": ["a"], + "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }], + "body": { + "k": "fn", + "params": [ + { "k": "var", "name": "a" }, + { "k": "var", "name": "a" } + ], + "param_modes": ["borrow", "borrow"], + "ret": { "k": "con", "name": "Bool" }, + "effects": [] + } + }, + "params": ["x", "y"], + "body": { + "t": "match", + "scrutinee": { + "t": "app", + "fn": { "t": "var", "name": "compare" }, + "args": [ + { "t": "var", "name": "x" }, + { "t": "var", "name": "y" } + ] + }, + "arms": [ + { + "pat": { "p": "ctor", "ctor": "GT", "fields": [] }, + "body": { "t": "lit", "lit": { "kind": "bool", "value": false } } + }, + { + "pat": { "p": "wild" }, + "body": { "t": "lit", "lit": { "kind": "bool", "value": true } } + } + ] + } + } +``` + +Then `gt` (hit-ctor `GT`, hit-body `true`, wild-body `false`): + +```json + , + { + "kind": "fn", + "name": "gt", + "doc": "Polymorphic strict-greater-than. `gt x y` ≡ case compare x y of GT -> True; _ -> False.", + "type": { + "k": "forall", + "vars": ["a"], + "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }], + "body": { + "k": "fn", + "params": [ + { "k": "var", "name": "a" }, + { "k": "var", "name": "a" } + ], + "param_modes": ["borrow", "borrow"], + "ret": { "k": "con", "name": "Bool" }, + "effects": [] + } + }, + "params": ["x", "y"], + "body": { + "t": "match", + "scrutinee": { + "t": "app", + "fn": { "t": "var", "name": "compare" }, + "args": [ + { "t": "var", "name": "x" }, + { "t": "var", "name": "y" } + ] + }, + "arms": [ + { + "pat": { "p": "ctor", "ctor": "GT", "fields": [] }, + "body": { "t": "lit", "lit": { "kind": "bool", "value": true } } + }, + { + "pat": { "p": "wild" }, + "body": { "t": "lit", "lit": { "kind": "bool", "value": false } } + } + ] + } + } +``` + +Then `ge` (hit-ctor `LT`, hit-body `false`, wild-body `true`): + +```json + , + { + "kind": "fn", + "name": "ge", + "doc": "Polymorphic greater-than-or-equal. `ge x y` ≡ case compare x y of LT -> False; _ -> True.", + "type": { + "k": "forall", + "vars": ["a"], + "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }], + "body": { + "k": "fn", + "params": [ + { "k": "var", "name": "a" }, + { "k": "var", "name": "a" } + ], + "param_modes": ["borrow", "borrow"], + "ret": { "k": "con", "name": "Bool" }, + "effects": [] + } + }, + "params": ["x", "y"], + "body": { + "t": "match", + "scrutinee": { + "t": "app", + "fn": { "t": "var", "name": "compare" }, + "args": [ + { "t": "var", "name": "x" }, + { "t": "var", "name": "y" } + ] + }, + "arms": [ + { + "pat": { "p": "ctor", "ctor": "LT", "fields": [] }, + "body": { "t": "lit", "lit": { "kind": "bool", "value": false } } + }, + { + "pat": { "p": "wild" }, + "body": { "t": "lit", "lit": { "kind": "bool", "value": true } } + } + ] + } + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --workspace -p ail --test prelude_free_fns` + +Expected: all 5 tests (`ne_at_int`, `lt_at_int`, `le_at_str`, `gt_at_bool`, `ge_at_int`) PASS. + +--- + +## Task 3: Positive E2E — `eq_ord_polymorphic` + +End-to-end test that compiles a workspace with a user-defined +polymorphic helper invoking the new prelude fns at three primitive +types (Int / Bool / Str), runs the resulting binary, and pins +stdout. The helper deliberately combines `lt` + `eq` to exercise +both Eq and Ord routes through the unified mono pass. + +Helper signature: `fn at_most : forall a. Ord a => (a borrow, a borrow) -> Bool` +with body `not (gt x y)` — same as `le x y` but composed from +prelude fns explicitly (proves composition works, not just direct +prelude-fn calls). + +**Files:** + +- Create: `examples/eq_ord_polymorphic.ail.json` +- Create: `crates/ail/tests/eq_ord_e2e.rs` + +- [ ] **Step 1: Write the failing test** + +Create `crates/ail/tests/eq_ord_e2e.rs`: + +```rust +use std::path::PathBuf; +use std::process::Command; + +fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../examples") + .join(name) +} + +fn build_and_run(fixture: &str) -> String { + let src = fixture_path(fixture); + let out = std::env::temp_dir().join(format!("ail_{}.bin", fixture.replace('.', "_"))); + + let build = Command::new(env!("CARGO_BIN_EXE_ail")) + .args(["build", src.to_str().unwrap(), "-o", out.to_str().unwrap()]) + .output() + .expect("ail build"); + assert!( + build.status.success(), + "ail build failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr), + ); + + let run = Command::new(&out).output().expect("run binary"); + assert!( + run.status.success(), + "binary exited non-zero:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr), + ); + String::from_utf8_lossy(&run.stdout).trim().to_string() +} + +#[test] +fn eq_ord_polymorphic_runs_end_to_end() { + let stdout = build_and_run("eq_ord_polymorphic.ail.json"); + // Helper `at_most(3, 5)` at Int = true → 1 + // `at_most(true, false)` at Bool = false → 0 + // `at_most("abc", "abd")` at Str = true → 1 + // Concatenated via three io/print_int = "101" + assert_eq!(stdout, "101", "expected 101, got {stdout:?}"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --workspace -p ail --test eq_ord_e2e eq_ord_polymorphic_runs_end_to_end` + +Expected: FAIL with "ail build failed" (fixture file does not exist yet). + +- [ ] **Step 3: Write minimal implementation** + +Create `examples/eq_ord_polymorphic.ail.json`: + +```json +{ + "schema": "ailang/v0", + "name": "eq_ord_polymorphic", + "imports": [], + "defs": [ + { + "kind": "fn", + "name": "at_most", + "doc": "Composition of prelude `gt` with `not` — semantically equivalent to `le`. Used to exercise polymorphic-helper-over-prelude-fns composition through the unified mono pass.", + "type": { + "k": "forall", + "vars": ["a"], + "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }], + "body": { + "k": "fn", + "params": [ + { "k": "var", "name": "a" }, + { "k": "var", "name": "a" } + ], + "param_modes": ["borrow", "borrow"], + "ret": { "k": "con", "name": "Bool" }, + "effects": [] + } + }, + "params": ["x", "y"], + "body": { + "t": "app", + "fn": { "t": "var", "name": "not" }, + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "gt" }, + "args": [ + { "t": "var", "name": "x" }, + { "t": "var", "name": "y" } + ] + }] + } + }, + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "body": { + "t": "seq", + "lhs": { + "t": "do", "op": "io/print_int", + "args": [{ + "t": "if", + "cond": { + "t": "app", + "fn": { "t": "var", "name": "at_most" }, + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 3 } }, + { "t": "lit", "lit": { "kind": "int", "value": 5 } } + ] + }, + "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } } + }] + }, + "rhs": { + "t": "seq", + "lhs": { + "t": "do", "op": "io/print_int", + "args": [{ + "t": "if", + "cond": { + "t": "app", + "fn": { "t": "var", "name": "at_most" }, + "args": [ + { "t": "lit", "lit": { "kind": "bool", "value": true } }, + { "t": "lit", "lit": { "kind": "bool", "value": false } } + ] + }, + "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } } + }] + }, + "rhs": { + "t": "do", "op": "io/print_int", + "args": [{ + "t": "if", + "cond": { + "t": "app", + "fn": { "t": "var", "name": "at_most" }, + "args": [ + { "t": "lit", "lit": { "kind": "str", "value": "abc" } }, + { "t": "lit", "lit": { "kind": "str", "value": "abd" } } + ] + }, + "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } } + }] + } + } + } + } + ] +} +``` + +NOTE: `"t": "seq"` is binary (`lhs`/`rhs`), not `items`-array. The +right-nested chain above matches the existing precedent in +`examples/eq_primitives_smoke.ail.json:34-90` and +`examples/compare_primitives_smoke.ail.json`. The `io/print_int` +sequence does NOT print a separator — the three printed ints +concatenate into one string. Helper to flatten an N-step chain: +write the first as `lhs`, then the rest as a right-nested `seq` in +`rhs`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --workspace -p ail --test eq_ord_e2e eq_ord_polymorphic_runs_end_to_end` + +Expected: PASS, stdout "101". + +--- + +## Task 4: User-ADT E2E — `eq_ord_user_adt` + +End-to-end test that defines a user ADT `IntBox = MkIntBox Int`, gives +it user-written `instance Eq IntBox` and `instance Ord IntBox`, +invokes a polymorphic helper at `IntBox`, and pins both the binary +stdout and the mono-symbol `eq__IntBox` hash for stability. + +Verifies that the unified mono pass synthesises `eq__IntBox` from the +*user* instance body — not from the primitive `==` operator (which has +no IntBox dispatch). Mirrors the hash-pin pattern from +`crates/ail/tests/mono_hash_stability.rs:34-41`. + +**Files:** + +- Create: `examples/eq_ord_user_adt.ail.json` +- Modify: `crates/ail/tests/eq_ord_e2e.rs` (add two tests) + +- [ ] **Step 1: Write the failing tests** + +Extend `crates/ail/tests/eq_ord_e2e.rs` with: + +```rust +use ailang_core::workspace::load_workspace; +use ailang_check::{check_workspace, monomorphise_workspace}; +use ailang_core::canonical::type_hash; +use ailang_core::ast::Def; + +#[test] +fn eq_ord_user_adt_runs_end_to_end() { + let stdout = build_and_run("eq_ord_user_adt.ail.json"); + // Helper invoked at IntBox(3) vs IntBox(3) → eq = true → 1 + // Helper invoked at IntBox(3) vs IntBox(5) → eq = false → 0 + assert_eq!(stdout, "10", "expected 10, got {stdout:?}"); +} + +#[test] +fn eq_ord_user_adt_eq_intbox_hash_stable() { + let ws = load_workspace(&fixture_path("eq_ord_user_adt.ail.json")).expect("load"); + let diags = check_workspace(&ws); + assert!(diags.is_empty(), "typecheck failed: {diags:#?}"); + let mono = monomorphise_workspace(&ws).expect("mono"); + + let eq_intbox = mono.modules.iter() + .flat_map(|m| m.defs.iter()) + .find_map(|d| match d { + Def::Fn(f) if f.name == "eq__IntBox" => Some(f), + _ => None, + }) + .expect("eq__IntBox not synthesised"); + + // Pin the canonical hash. If this changes, either the user-instance + // body emission shifted (legitimate refactor — re-record) or the + // unification accidentally rewired IntBox eq to the wrong source + // (regression — investigate). + let h = type_hash(&Def::Fn(eq_intbox.clone())); + assert_eq!( + h, "PIN_AFTER_FIRST_GREEN_RUN", + "eq__IntBox hash drifted — see mono_hash_stability.rs for the resolution pattern" + ); +} +``` + +The hash string `"PIN_AFTER_FIRST_GREEN_RUN"` is a deliberate +placeholder; Step 4 records the actual hash after first green run +(see Step 4 instruction). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --workspace -p ail --test eq_ord_e2e eq_ord_user_adt` + +Expected: BOTH fail. `eq_ord_user_adt_runs_end_to_end` fails with +"ail build failed" (fixture missing). `eq_ord_user_adt_eq_intbox_hash_stable` +fails with "load" panic (same reason). + +- [ ] **Step 3: Write minimal implementation** + +Create `examples/eq_ord_user_adt.ail.json`: + +```json +{ + "schema": "ailang/v0", + "name": "eq_ord_user_adt", + "imports": [], + "defs": [ + { + "kind": "type", + "name": "IntBox", + "vars": [], + "doc": "A one-field box around an Int, used to demonstrate that the unified mono pass synthesises eq__IntBox from a user-written instance, not from the primitive == operator.", + "ctors": [ + { "name": "MkIntBox", "fields": [{ "k": "con", "name": "Int" }] } + ] + }, + { + "kind": "instance", + "class": "Eq", + "type": { "k": "con", "name": "IntBox" }, + "doc": "Eq IntBox by structural unwrap of the inner Int.", + "methods": [ + { + "name": "eq", + "body": { + "t": "lam", + "params": ["a", "b"], + "paramTypes": [ + { "k": "con", "name": "IntBox" }, + { "k": "con", "name": "IntBox" } + ], + "retType": { "k": "con", "name": "Bool" }, + "body": { + "t": "match", + "scrutinee": { "t": "var", "name": "a" }, + "arms": [{ + "pat": { "p": "ctor", "ctor": "MkIntBox", "fields": [{ "p": "var", "name": "ai" }] }, + "body": { + "t": "match", + "scrutinee": { "t": "var", "name": "b" }, + "arms": [{ + "pat": { "p": "ctor", "ctor": "MkIntBox", "fields": [{ "p": "var", "name": "bi" }] }, + "body": { + "t": "app", + "fn": { "t": "var", "name": "==" }, + "args": [ + { "t": "var", "name": "ai" }, + { "t": "var", "name": "bi" } + ] + } + }] + } + }] + } + } + } + ] + }, + { + "kind": "instance", + "class": "Ord", + "type": { "k": "con", "name": "IntBox" }, + "doc": "Ord IntBox by delegating to Int's compare on the inner field.", + "methods": [ + { + "name": "compare", + "body": { + "t": "lam", + "params": ["a", "b"], + "paramTypes": [ + { "k": "con", "name": "IntBox" }, + { "k": "con", "name": "IntBox" } + ], + "retType": { "k": "con", "name": "Ordering" }, + "body": { + "t": "match", + "scrutinee": { "t": "var", "name": "a" }, + "arms": [{ + "pat": { "p": "ctor", "ctor": "MkIntBox", "fields": [{ "p": "var", "name": "ai" }] }, + "body": { + "t": "match", + "scrutinee": { "t": "var", "name": "b" }, + "arms": [{ + "pat": { "p": "ctor", "ctor": "MkIntBox", "fields": [{ "p": "var", "name": "bi" }] }, + "body": { + "t": "app", + "fn": { "t": "var", "name": "compare" }, + "args": [ + { "t": "var", "name": "ai" }, + { "t": "var", "name": "bi" } + ] + } + }] + } + }] + } + } + } + ] + }, + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "body": { + "t": "seq", + "lhs": { + "t": "do", "op": "io/print_int", + "args": [{ + "t": "if", + "cond": { + "t": "app", + "fn": { "t": "var", "name": "eq" }, + "args": [ + { + "t": "ctor", "type": "eq_ord_user_adt.IntBox", "ctor": "MkIntBox", + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 3 } }] + }, + { + "t": "ctor", "type": "eq_ord_user_adt.IntBox", "ctor": "MkIntBox", + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 3 } }] + } + ] + }, + "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } } + }] + }, + "rhs": { + "t": "do", "op": "io/print_int", + "args": [{ + "t": "if", + "cond": { + "t": "app", + "fn": { "t": "var", "name": "eq" }, + "args": [ + { + "t": "ctor", "type": "eq_ord_user_adt.IntBox", "ctor": "MkIntBox", + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 3 } }] + }, + { + "t": "ctor", "type": "eq_ord_user_adt.IntBox", "ctor": "MkIntBox", + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 5 } }] + } + ] + }, + "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } } + }] + } + } + } + ] +} +``` + +NOTE on `Term::Ctor` shape (verified against `ast.rs:436-446`): +`{ "t": "ctor", "type": "", "ctor": "", "args": [...] }`. +The `"type"` value may be qualified (`"."`) or +unqualified. Qualified form is used above; if the auto-loaded prelude +or the user-ADT module is in scope, the bare form `"type": "IntBox"` +also works. + +- [ ] **Step 4: Run tests to verify they pass + record hash** + +Run: `cargo test --workspace -p ail --test eq_ord_e2e eq_ord_user_adt_runs_end_to_end` + +Expected: PASS, stdout "10". + +Then run: `cargo test --workspace -p ail --test eq_ord_e2e eq_ord_user_adt_eq_intbox_hash_stable` + +Expected: FAIL with assertion message showing the *actual* hash +(e.g. `assertion failed: ... actual: "ab12cd34..."`). + +Extract the actual hash from the failure message, edit +`crates/ail/tests/eq_ord_e2e.rs` to replace `"PIN_AFTER_FIRST_GREEN_RUN"` +with the extracted hash, re-run: + +Run: `cargo test --workspace -p ail --test eq_ord_e2e eq_ord_user_adt_eq_intbox_hash_stable` + +Expected: PASS. + +(This is the same record-then-pin pattern used in +`crates/ail/tests/mono_hash_stability.rs` from iter 23.4.) + +--- + +## Task 5: Negative Float-NoInstance E2E + diagnostic addendum + +Add a Float-aware addendum to the `NoInstance` diagnostic message +emitted at `crates/ailang-check/src/lib.rs:1607-1613`. When the +unresolved constraint is `Eq Float` or `Ord Float`, append a +cross-reference to DESIGN.md §"Float semantics" so the LLM author +immediately learns the partial-Float story. Pin the addended message +verbatim via a new fixture + test. + +**Open commitment (spec §336-338):** The exact wording is drafted +by the implementer in this Step. Constraint: the addendum MUST contain +the literal substring `Float` (so the message-contains-`Float` +assertion works regardless of phrasing) and SHOULD cross-reference +DESIGN.md §"Float semantics". + +**Files:** + +- Create: `examples/eq_float_noinstance.ail.json` +- Create: `crates/ail/tests/eq_float_noinstance.rs` +- Modify: `crates/ailang-check/src/lib.rs:1607-1613` (NoInstance construction site) + +- [ ] **Step 1: Write the failing test** + +Create `crates/ail/tests/eq_float_noinstance.rs`: + +```rust +use ailang_core::workspace::load_workspace; +use ailang_check::check_workspace; +use std::path::PathBuf; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../examples") + .join(name) +} + +#[test] +fn eq_at_float_fires_float_aware_noinstance() { + let ws = load_workspace(&fixture("eq_float_noinstance.ail.json")).expect("load"); + let diags = check_workspace(&ws); + + assert!(!diags.is_empty(), "expected NoInstance diagnostic, got none"); + + let no_inst = diags.iter().find(|d| d.code == "no-instance") + .expect(&format!("expected diagnostic with code 'no-instance', got: {diags:#?}")); + + assert!( + no_inst.message.contains("Float"), + "expected Float-aware NoInstance diagnostic, got message: {:?}", + no_inst.message + ); + + // Cross-ref to DESIGN.md §Float semantics — the LLM-author should + // be pointed at the canonical explanation of partial Float orderability. + assert!( + no_inst.message.contains("Float semantics") || no_inst.message.contains("DESIGN"), + "expected NoInstance message to cross-reference DESIGN.md §Float semantics, got: {:?}", + no_inst.message + ); +} +``` + +Create `examples/eq_float_noinstance.ail.json`: + +```json +{ + "schema": "ailang/v0", + "name": "eq_float_noinstance", + "imports": [], + "defs": [ + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "body": { + "t": "do", "op": "io/print_int", + "args": [{ + "t": "if", + "cond": { + "t": "app", + "fn": { "t": "var", "name": "eq" }, + "args": [ + { "t": "lit", "lit": { "kind": "float", "bits": "3ff0000000000000" } }, + { "t": "lit", "lit": { "kind": "float", "bits": "4000000000000000" } } + ] + }, + "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } } + }] + } + } + ] +} +``` + +(Float literal shape `{ "kind": "float", "bits": "" }` +verified against `examples/floats.ail.json:26-27`. `3ff0000000000000` += 1.0; `4000000000000000` = 2.0. Use raw hex IEEE 754 bits, not a +decimal value — the schema requires bit-stable hex form.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --workspace -p ail --test eq_float_noinstance eq_at_float_fires_float_aware_noinstance` + +Expected: FAIL with "expected Float-aware NoInstance diagnostic, got message: +'fn `main` calls `eq` requiring `instance Eq Float`, but no such instance exists in the workspace'" +— the current bare message has no "Float semantics" reference. + +- [ ] **Step 3: Write minimal implementation** + +Modify `crates/ailang-check/src/lib.rs:1607-1613` (the `NoInstance` +construction site for `CheckError::NoInstance`). + +The current construction emits the bare `thiserror`-templated message +from `lib.rs:563-566`: + +``` +"fn `{def}` calls `{method}` requiring `instance {class} {at_type}`, but no such instance exists in the workspace" +``` + +Refactor the construction site to append a Float-aware addendum +when the unresolved type is `Float` AND the class is `Eq` or `Ord`. +Pseudocode (the implementer adapts to the actual local variable +names at lib.rs:1607-1613): + +```rust +let mut diag = Diagnostic::from(CheckError::NoInstance { + def: def_name.clone(), + method: method_name.clone(), + class: class_name.clone(), + at_type: at_type_surface.clone(), +}); + +// Float-aware addendum (iter 23.5): when the unresolved instance +// is on Float, point the LLM-author at the canonical Float +// semantics so they immediately learn the partial-Float story. +let is_float = matches!(&r_ty, Type::Con { name, .. } if name == "Float"); +let is_eq_or_ord = class_name == "Eq" || class_name == "Ord"; +if is_float && is_eq_or_ord { + diag.message = format!( + "{} — Float has no Eq/Ord instance by design; see DESIGN.md §\"Float semantics\".", + diag.message + ); +} + +diagnostics.push(diag); +``` + +Two structural choices the implementer must make: + +1. **Mutating `diag.message` after construction.** If the `Diagnostic` + struct is constructed via `Display`-on-`CheckError` (and `message` + is therefore not directly assignable), the implementer either: + (a) makes `message` a public mutable field, or + (b) introduces a new `CheckError::NoInstanceFloat { ... }` variant + with its own `#[error(...)]` template and selects between + `NoInstance` / `NoInstanceFloat` at construction time. + Option (a) is the simpler diff; option (b) is the more typed/structural + solution. Either is acceptable — pick based on what the codebase + already does elsewhere for "diagnostic with situational addendum". + +2. **Exact wording.** Constraint: must contain the substring "Float" + AND must contain either "Float semantics" or "DESIGN" (for the + cross-reference assertion). Beyond that, the implementer drafts. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --workspace -p ail --test eq_float_noinstance eq_at_float_fires_float_aware_noinstance` + +Expected: PASS. + +Also run the full workspace check to confirm no existing test relied +on the bare NoInstance message: + +Run: `cargo test --workspace` + +Expected: all pre-existing tests still PASS. If any existing test +asserted on a NoInstance message verbatim (none found in recon, but +possible), update its expected substring to match the new wording. + +--- + +## Task 6: DESIGN.md amendment — §"Decision 11 / Prelude classes" + +Amend `docs/DESIGN.md:1690-1742` to record that Eq/Ord shipped in +milestone 23. The "Milestone 22 ships no built-in Prelude classes" +wording (line 1692) stays as historical accuracy; the amendment is +inserted as a new paragraph between line 1702 (end of "see typeclasses +spec amendments...") and line 1704 (start of "Primitive output goes +through io/print_int..."), mirroring the tone of the iter-23.4 +DESIGN.md amendment at `DESIGN.md:1513-1517`. + +**Files:** + +- Modify: `docs/DESIGN.md:1702` (insertion point) + +- [ ] **Step 1: Write the failing test** + +Pure-prose change; no Rust test is meaningful here. Substitute a +grep-based assertion: `git grep -l "Milestone 23 amends" docs/DESIGN.md` +must return `docs/DESIGN.md` after the edit. + +Run: `git grep -l 'Milestone 23 amends' docs/DESIGN.md` + +Expected (pre-edit): no output (exit 1). + +- [ ] **Step 2: Verify the grep currently fails** + +Same command as Step 1. Confirm empty output. (This is the "RED" state.) + +- [ ] **Step 3: Write minimal implementation** + +Edit `docs/DESIGN.md`. After line 1702 (the line ending with +`"... see `docs/specs/2026-05-09-22-typeclasses.md` 'Amendments' for the substantive rationale."`), +insert two blank lines and the following paragraph block: + +```markdown +Milestone 23 amends the above: the prelude now ships the `Ordering` +ADT, the `Eq` and `Ord` classes, primitive `Eq Int/Bool/Str` and +`Ord Int/Bool/Str` instances, and the five polymorphic free-fn helpers +`ne` / `lt` / `le` / `gt` / `ge`. `Show`, operator routing through +`Eq`/`Ord`, and `print`-rewire remain out of scope per their original +substantive reasons (heap-`Str` ABI, bench rebaseline). Float has +neither `Eq` nor `Ord` instance per §"Float semantics"; a polymorphic +helper invoked at Float fires `NoInstance` at typecheck with a +Float-aware diagnostic cross-referencing this section. +``` + +Leave line 1704 onwards (`Primitive output goes through io/print_int...`) +unchanged — it remains accurate after the amendment. + +- [ ] **Step 4: Verify the grep now passes** + +Run: `git grep -l 'Milestone 23 amends' docs/DESIGN.md` + +Expected: `docs/DESIGN.md` (exit 0). + +Visual-review the inserted paragraph for prose quality; fix any +typo or awkward phrasing inline. + +--- + +## Task 7: Roadmap update — split Post-22 Prelude entry + +Edit `docs/roadmap.md:44-64`. Per spec acceptance §8 and recon +finding (split is preferred over single-entry-checked): split the +existing single `[ ] Post-22 Prelude — ship Show/Eq/Ord ...` entry +into two adjacent entries — one `[x]` for the shipped Eq/Ord half, +one `[ ]` for the pending Show + print rewire half. + +**Files:** + +- Modify: `docs/roadmap.md:44-64` + +- [ ] **Step 1: Write the failing test** + +Grep-based assertion: post-edit, `docs/roadmap.md` must contain both +`[x] **\[milestone\]** Post-22 Prelude — Eq/Ord` AND +`[ ] **\[milestone\]** Post-22 Prelude — Show + print rewire`. + +Run: `git grep -c 'Post-22 Prelude' docs/roadmap.md` + +Expected (pre-edit): `1` (the single existing entry). + +- [ ] **Step 2: Verify the count is currently 1** + +Same command. Confirm output `1`. + +- [ ] **Step 3: Write minimal implementation** + +Edit `docs/roadmap.md`. Replace the entire block from line 44 through +line 64 (the existing single Post-22 Prelude entry with all its +sub-bullets) with the following two-entry block: + +```markdown +- [x] **\[milestone\]** Post-22 Prelude — Eq/Ord — shipped milestone + 23 (2026-05-12). `Ordering` ADT, `Eq`/`Ord` classes, primitive + instances on Int / Bool / Str, polymorphic free fns + `ne`/`lt`/`le`/`gt`/`ge`, runtime `ail_str_eq` + `ail_str_compare`, + end-to-end coverage including user-ADT-instance integration. Float + has neither instance by design; polymorphic helpers at Float fire + Float-aware `NoInstance` per DESIGN.md §"Float semantics". + +- [ ] **\[milestone\]** Post-22 Prelude — Show + print rewire — ship + `Show` typeclass with `Show Int/Bool/Str/Float` instances; rewire + `print` through `Show.show`. Gated on heap-`Str` ABI runtime work + (`int_to_str` needs to allocate). Originally queued as 22b.4 in + the typeclass milestone, kept on hold pending demand and the + heap-`Str` prerequisite. + - context: brainstorm 2026-05-12 (iter 23.5 wrap). Heap-`Str` ABI + is the load-bearing prerequisite; until it ships, `Show Int` + cannot allocate a `Str` to return. +``` + +- [ ] **Step 4: Verify the count is now 2** + +Run: `git grep -c 'Post-22 Prelude' docs/roadmap.md` + +Expected: `2` (the two split entries). + +Also confirm one is checked and one is not: + +Run: `git grep '\[x\] \*\*\\\[milestone\\\]\*\* Post-22 Prelude — Eq/Ord' docs/roadmap.md` + +Expected: exactly one match. + +Run: `git grep '\[ \] \*\*\\\[milestone\\\]\*\* Post-22 Prelude — Show' docs/roadmap.md` + +Expected: exactly one match. + +--- + +## Task 8: Bench-regression check + +Final gate before the iter is handed back to the Boss. The three +bench scripts must exit 0 (no regression) or be audit-ratified per +spec §248-249. Same gate iter 23.4 cleared (a1692a4) — same scripts. + +**Files:** + +- (no edits — verification only) + +- [ ] **Step 1: Establish the gate** + +The "test" for this task is the exit code of the three bench scripts. +No additional RED-state setup is needed; we simply run them. + +- [ ] **Step 2: Run baseline check (pre-mono-extension)** + +Run: `python3 bench/check.py` + +Expected: exit 0. + +Run: `python3 bench/compile_check.py` + +Expected: exit 0. + +Run: `python3 bench/cross_lang.py` + +Expected: exit 0. + +If any of the three exits non-zero with regression diagnostics, +inspect the regression: is it caused by the prelude growing by +five `Def::Fn`s (expected — the mono pass now has more work per +workspace that uses the prelude)? If so, the regression is +audit-ratifiable per spec §248-249. Record the regression magnitude +in the journal Concerns section; the Boss decides whether to +rebaseline at iter close. + +- [ ] **Step 3: Verify cross-language ratio still holds** + +`bench/cross_lang.py` compares AILang against the C reference. If +the ratio shifted measurably, note it in journal Concerns; if the +shift is within audit tolerance, no action needed. + +- [ ] **Step 4: Mark gate cleared** + +In the per-iter journal (written by the orchestrator in Phase 4), +record per-script exit codes in the `Stats` section. + +--- + +## Self-review checklist + +(Run by the planner before handoff; results recorded inline.) + +1. **Spec coverage:** + - Acceptance §3 (prelude free fns) → Tasks 1 + 2 ✓ + - Acceptance §4 (DESIGN.md Decision 11 amendment) → Task 6 ✓ + - Acceptance §5 (workspace tests + E2E green) → Tasks 1, 2, 3, 4 ✓ + - Acceptance §6 (bench regression ratified) → Task 8 ✓ + - Acceptance §7 (polymorphic helper at three primitives + Float-NoInstance) → Tasks 3 + 5 ✓ + - Acceptance §8 (roadmap update) → Task 7 ✓ + +2. **Placeholder scan:** + - No "TBD" / "TODO" / "implement later" / "fill in later". + - The one `"PIN_AFTER_FIRST_GREEN_RUN"` placeholder in Task 4 is + deliberate (record-then-pin pattern from `mono_hash_stability.rs`) + and Step 4 of Task 4 instructs the implementer to replace it + with the actual hash. This is not a plan failure — it's the + standard hash-pin procedure. + +3. **Type consistency:** + - `mono_symbol_names` helper defined in Task 1, reused implicitly + by Task 2's tests (same file). ✓ + - `build_and_run` / `fixture_path` helpers defined in Task 3, + reused by Task 4's tests (same file). ✓ + - All fixture filenames match between fixture-creation and test-load. ✓ + +4. **Step granularity:** + - Every step is 2-5 minutes. Task 4 Step 3 (the user-ADT fixture + JSON) is the longest; it is one logical action (write the + fixture JSON) of ~5 minutes typing. + +5. **No commit steps:** confirmed — no task contains `git commit`. + +## Handoff + +Plan file: `docs/plans/2026-05-12-iter-23.5.md`. + +Hand off to `skills/implement` with: this plan path. Boss commits the +plan with subject `plan: iter 23.5 prelude free fns + E2E — 8 tasks` +before dispatching the implement orchestrator.