The owner’s decision (2026-10-28): self-hosting is finished as a complete
bootstrap. At most two parallel move lanes, one lane for lowering defects,
one for compiler bugs; parallelism that lowers quality is counterproductive,
and token cost counts. This file is the plan; the supervisor’s State
(docs/supervisor.md) and the plan rows (docs/selfhosting-plan.md) stay
where they are and point here.
The goal, as the owner set it (2026-10-28):
crates/orchestrator moves completely, or each part that stays Rust has
a concrete reason (what Rust-only thing it needs, why std cannot offer it,
what adding it would cost). A missing std entry is no reason: it is work to
plan.crates/nikaia-std is out of scope. Nothing in it moves, not even the
logic that is neither glue nor a wrapper: moving single parts complicates
things for little gain. If std is ever taken up, then whole modules in one
piece.std crate. Its .nika stood in
crates/nikaia-std/src/tools and read from outside as part of the standard
library; they move to crates/nikaia-compiler/src (R0, #921, ADR-294’s new D21),
with the thin Rust adapter crates/nikaia beside it.Counts are code lines of scripts/self_hosting.py (no comments, no blanks);
the current figure is the badge in README.md. self_hosting.py also counts
the hand-written crates/nikaia-compiler/src/lib.rs.
Stage 2 is reached, and held by CI, when the bootstrap_compiler job of
.github/workflows/nikaia.yml passes a step named Bootstrap (ADR-294 D5,
Stage 2) on a develop commit that promote brings to main. That step
runs, after Build the workspace:
NIKAIA_CACHE_DIR=$(mktemp -d) target/release/nikaia lower-std (cold: no
cached file may stand in for a lowering), then
git diff --exit-code -- crates/nikaia-compiler/src/lowered crates/nikaia-compiler/src/lowered.rs crates/nikaia-std/src/runtime.
The binary was built from the committed lowered Rust plus the adapters, so
an empty diff is the fixpoint ADR-294 D5 names: the lowered compiler lowers
its own sources to byte-identical Rust. (A second generation needs no run:
identical sources build the identical compiler.) The existing test
the_committed_rust_is_what_this_compiler_lowers
(crates/nikaia/tests/it/sysroot.rs) already compares the same bytes; the
step adds the cold cache and the name.target/release/nikaia run scripts/bootstrap.nika, a new checker in
Nikaia (CLAUDE.md: tools in Nikaia): it reads scripts/bootstrap-adapters.txt
(the table below, one file function max_lines reason row each) and fails
if any hand-written .rs under crates/nikaia/src or
crates/nikaia-compiler/src (not lowered/) holds a function or a type not
on the list, or a file holds more code lines than its row allows, or
crates/orchestrator exists. Module declarations (mod,
pub use nikaia_compiler::...) are free. std is not scanned (point 3).So Stage 2 = Stage 1 (only the adapters below are Rust) + that step green. Stage 1 alone is what the second check proves; Stage 2 is the first.
Scope: crates/nikaia/src (what ADR-294 D4 measures),
crates/nikaia-compiler/src’s hand-written lib.rs, and crates/orchestrator
(to zero: the crate is deleted, #923). Not in scope: crates/nikaia-std (point
3; it gains only the new entries #920 and #922 the orchestrator needs, see
D-7), crates/unsafe/* (ADR-218 D1, D4), and the Rust tests under crates/nikaia/tests (#821 goes on, not in
this plan). The #[cfg(test)] modules inside the compiler and the
orchestrator are in scope: each leaves with the code it tests, as a
tests/compiler program where the moved code is not already covered, else
deleted.
The adapters allowed to remain (the fixed list; at most about 2,950 lines in all). Each is Rust only because a crate or an OS call stands behind it; everything it decides is a call into Nikaia.
| File | What may stay | At most | Reason |
|---|---|---|---|
main.rs |
Cli, Command, CacheAction, Verb (the clap derives), main turning them into one call of a Nikaia entry per subcommand, std::process::exit |
200 | clap |
parser.rs (was parser/mod.rs) |
the entry points parse_to_ast, parse_expression_from calling the lowered grammar; the conversion of a winnow error into the Nikaia ParseError; Parsed’s caches only if a tools file cannot hold a lock |
200 | winnow, winnow-grammar |
ast/mod.rs |
int_value, int_literal |
70 | i128 (ADR-294 D6) |
manifest.rs |
Manifest::read/parse: toml::Value into the Nikaia manifest view; rooted (from project.rs); the same reading for nikaia.lock and Cargo.lock (from the orchestrator, #923) |
190 | toml; a TOML reader in Nikaia would be a grammar of 400-600 lines after #557, not part of this plan, and std gains no TOML module (point 3) |
sysroot.rs |
Sysroot::resolve, COMPILER_IDENTITY, rlib_cache (env!), rustc_cpus (a rustc process), in_parallel (scoped threads with a 256 MB stack), sha256_hex until #922 step 1, Phases |
250 | env!, std::thread |
comptime_run.rs, grammar_run.rs, build_time.rs, run_fast.rs |
compiling and running a generated program (rustc/cargo processes with environment and working directory), the AHEAD thread_local |
350 together | processes until #920; thread_local |
project.rs |
the test binaries’ spawns with stdin, working directory and exit status (until #920 step 2), Sandbox, current_exe |
250 | processes, temporary directories |
contracts/mod.rs |
the trait impls LedgerOps, SignatureOps, VariantOps, SyncOps (one-line adapters), std_ledger (OnceLock), toolchain (env!) |
150 | Rust traits over nikaia_std types, env! |
emit/mod.rs |
Build with host_target/default_target; the rayon fan-out in with_contracts |
150 | impl Default of a nikaia_std type (E12-4), env!, rayon |
diagnostics/mod.rs |
the terminal and colour decision, the serde_json writer |
80 | serde_json, the terminal |
describe.rs, cexport.rs, libraries.rs, assets.rs |
the cargo/node-gyp/rustc invocations, sha2 digests |
150 together | processes, sha2 |
crates/nikaia-compiler/src/lib.rs |
Span’s usize doors, the generated parser’s entry file, impl Display for Ty, the imports the lowered files need (sysroot::HAND_WRITTEN) |
150 | usize (ADR-294 D6), winnow, Rust traits |
| any file | anyhow context at a crate boundary, catch_unwind |
in the rows above | anyhow |
A process the Nikaia std::process can start is not an adapter: it moves.
process::run takes dir:, env:, env_remove:, env_clear: and
within: and captures both outputs, with stdin closed. What it cannot do yet
is #920 (inherited streams, one stream captured and the other passed through,
a stdin text); only those spawns are adapters, and each row says until when.
A missing std entry met on the way is filed (ready) and its row’s adapter
is named in the issue, so the list shrinks rather than grows.
nikaia-std: out of scope, and why that is honestNothing in crates/nikaia-std moves (point 3). std is the library every
Nikaia program runs on: the types the emitter writes calls into
(nikaia_std::index::get, Bytes, Map; Part I 6.5 excludes the lifetimes
they are built of), the scheduler and locks below the language (Part II 12.2),
the calls to the OS and to crates. Its Nikaia half (text.nika,
supervisor.nika, runtime/http1.nika) stays where it is. The only std
change is new thin entries over Rust that the orchestrator’s
move needs (#920 spawns, #922 sha256, environment, file locks, file times):
glue by design, the kind point 1 keeps in Rust (D-7).
crates/orchestrator: moves completely, the crate is deleted (#923)1,228 code lines (812 more in tests). Its Nikaia goes into the compiler’s
package as orchestrator_cache.nika and orchestrator_project.nika (the
prefix rule), after R0 in crates/nikaia-compiler/src; the Rust callers in
crates/nikaia call nikaia_compiler::orchestrator_*. No part has to stay
Rust inside it. What it needs that Nikaia cannot say is a std entry
(#920, #922) or the compiler’s toml adapter row; nothing is left for a crate.
Part (crates/orchestrator/src/) |
Verdict | Needs first |
|---|---|---|
lib.rs:1-64 Cli, LanguageFrontend, Orchestrator |
deleted: no caller | - |
cache.rs:32-62 domains, sha256_hex |
moves; the digest itself is hash::sha256_hex |
#922 step 1 |
cache.rs:64-154 Choices |
moves | - |
cache.rs:156-259 UnitRecord, Lockfile (new, save render TOML text in Nikaia, as CargoProject::render does) |
moves; load’s TOML parse goes through manifest.rs’s toml row |
- |
cache.rs:261-418 Key::build, Key::sysroot, KeyBuilder |
moves | #922 step 1 |
cache.rs:420-614 Artifacts, Store, Cache (lookup, record, save) |
moves; the store’s JSON object of texts (:559, :587) is our own format, written and read in Nikaia (about 50 lines) |
- |
cache.rs:616-794 Layout (user_cache_dir, where_tools_start, resolve, unit_name), limit, parse_size, show_size |
moves | #922 step 2 (env::var, env::temp_dir) |
cache.rs:796-856 HELD, ADDED, use_entry, hold, open_lock, settle |
moves; the two process-wide statics become a value the driver keeps and hands to settle |
#922 step 3 (fs::lock) |
cache.rs:858-960 Pruned, prune, Entry, entries, size_of |
moves | #922 step 4 (modified) |
cache.rs:962-973 first_link |
moves | #922 step 4 (links, file_id) |
cache.rs:975-1031 cargo_holds, mod lock |
moves; mod lock is the std entry |
#922 steps 3-4 |
cache.rs:1033-1154 clean, touch, sweep_programs, profile_dirs, used_at |
moves (fs::remove/create_dir take recursive:) |
#922 step 4 |
cache.rs:1156-1774 tests |
tests/compiler programs where it/cache.rs does not cover them, else deleted |
with O1 |
project.rs:35-333 Package, Profile, CrateKind, CargoProject, Workspace, string, key, write_if_changed |
moves | - |
project.rs:335-460 Cargo, toolchain_is_new_enough, toolchain_can_build_for, installed_rustc, rustc_version, version_parts |
moves (captured rustc runs with dir:); the once-per-process memo of rustc_version becomes the caller’s |
- |
project.rs:461-560 Cargo::run, Cargo::messages |
moves | #920 step 1 (inherited; stdout captured with stderr inherited) |
project.rs:562-571 cargo_binary |
moves | #922 step 2 |
project.rs:573-724 Invocation (parse, source_path, replace_source, dep_info, is_a_probe, run) |
moves | #920 steps 1-2 (run: inherited, stdin closed for a probe) |
project.rs:726-770 resolved_versions |
moves; the TOML parse of Cargo.lock through manifest.rs’s row |
- |
project.rs:772-813 record_extra_dependencies, escape_make |
moves | - |
project.rs:815-1230 tests |
as cache.rs’s |
with O2 |
The one piece of the orchestrator’s work that stays Rust anywhere is the TOML
parse (about 40 lines in manifest.rs): the toml crate, a format the
compiler reads but does not define; a Nikaia TOML grammar is 400-600 lines and
waits for #557’s grammar features, so it is filed separately if the owner
wants it.
Compiler adapters <= 2,950 (with crates/nikaia-compiler/src/lib.rs);
orchestrator 0, the crate deleted; about 150 new lines of std glue (#920,
#922). Out of scope and unchanged: crates/nikaia-std (12,088 hand-written
code lines), crates/unsafe 2,515 (ADR-218). By self_hosting.py’s measure
the compiler is then about 97 % Nikaia (about 106,000 lines against at most
2,950), every remaining Rust line on a list with its reason, and
scripts/bootstrap.nika holds CI to the list.
The compiler is Nikaia, in its own crate crates/nikaia-compiler, except a
fixed list of adapters to Rust crates and to the OS in crates/nikaia. The
orchestrator (caches, Cargo projects, the rustc wrapper) is Nikaia too. The
standard library is not part of this claim: it is the layer under every Nikaia
program (the types the compiler lowers to, the scheduler, the locks, the
system calls), and self-hosted toolchains keep such a layer: GHC’s runtime
system is C, Go’s runtime has assembly, Rust’s core has compiler intrinsics.
What makes it a bootstrap is the fixpoint (section 1), and that the compiler’s
remaining Rust is small, listed with a reason per row, and checked by CI.
Order inside each lane: leaves of the Rust call graph first. Most of
the remaining Rust is either a driver (the run of analyses, the project
loop, main) or a crossing: a thin Rust function that turns &Parsed and
Ledger into the Nikaia call (contracts/order.rs is 71 lines around
order.nika’s 790). A crossing goes when its last Rust caller moves, so each
slice takes a driver together with the crossings only it calls.
The parser. parser/mod.rs holds the compiler’s grammar as one
grammar! block over winnow-grammar (about 390 rules) plus helpers. #557
steps 1-3 (the grammar language features it needs) are done. The rest is
ported to Nikaia (owner, 2026-11-02, #1188). The check whether the Rust
block can call rules of the lowered Nikaia grammar answered no (it needs a
sub-rule export in winnow-grammar), so the two grammars run side by side;
first the helpers and the actions’ logic move (about 680 lines);
then the rules in chunks into tools/parser_grammar.nika, each compared on
every tests/compiler and tests/errors program; the last commit removes
the grammar! block and switches (#557 steps 4-5). Decided here (architect, within ADR-339 and ADR-294 D7): the
compiler’s own grammar runs over the source as Bytes, so span(p) gives
the Span of two u32 the span table needs; the text form (text::Match,
text::Pos) waits for #743 and is not on this path.
The check/* and emit/* cores are the top of the analyses and come
last in lane 2 by the call graph; their crossings fall away with them. The
architect writes their briefs from the call graph at the time.
| Checkout | Path | Branch | Lanes | target/ |
|---|---|---|---|---|
| C1 | /home/user/Nikaia |
develop |
lane 1 (the driver, scripts/drive.nika); architect and supervisor docs edits (no builds) |
yes |
| C2 | /home/user/nikaia-lane2 |
detached HEAD |
lane 2 | yes |
| C3 | /home/user/nikaia-fix |
detached HEAD |
lowering lane and compiler lane, one agent at a time | yes |
Why three: every move and every fix changes tools/*.nika, and lower-std
lowers the whole package. A half-written .nika of one agent makes the next
agent’s cargo build or lower-std fail in a shared checkout, so two agents
that build cannot share one. The worktrees are `git worktree add –detach