# zyginit Visual Pass Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Implement the unified visual identity defined in `docs/superpowers/specs/2026-05-12-zyginit-visual-pass-design.md`: boot rolling tape, progress bar, spinner, final cards, shutdown mirror, and `zygctl status` banner.

**Architecture:** Single new module `src/ui.reef` owns all rendering — palette, glyphs, sigil, TTY/TERM detection, ANSI sequences, rolling tape state, progress bar, spinner. Existing `println` lifecycle sites in `main.reef` and `supervisor.reef` route through it. zygctl client detects its own TTY and asks the daemon for `STATUS PLAIN` when piped. A `--ui-demo <scenario>` flag on zyginit drives snapshot-based tests against canned event sequences (no daemon needed).

**Tech Stack:** Reef 0.4+; FFI via `extern "C"` to a new `zyginit_isatty` helper in `src/helpers.c`; SGR / VT100 sequences for rich rendering; existing integration shell harness for end-to-end tests; Mercurial for VCS.

---

## File Structure

**Create:**
- `src/ui.reef` — rendering module (all UI state and procedures)
- `tests/integration/snapshots/` — golden output files for snapshot tests
- `tests/integration/ui_tests.sh` — shell-based snapshot test runner

**Modify:**
- `src/helpers.c` — add `zyginit_isatty(int fd)` (returns 1/0)
- `src/main.reef` — wire `ui_init`, `ui_boot_start`, `ui_tier_start/done`, `ui_boot_complete`, `ui_shutdown_start/complete`, `ui_tick`; add `--ui-demo <scenario>` flag dispatch
- `src/supervisor.reef` — replace ~8 lifecycle `println` sites with `ui_event_*` calls
- `src/socket.reef` — `cmd_status` / `cmd_list` delegate to `ui_render_*`; parse `PLAIN` modifier
- `tools/zygctl/src/main.reef` — pre-flight `detect_rich_mode`; send `STATUS` or `STATUS PLAIN`
- `tests/integration/run_tests.sh` — `export ZYGINIT_NO_UI=1` for diff stability
- `reef.toml`, `tools/zygctl/reef.toml`, `tools/sysv-wrapper/Makefile`, `src/version.reef`, `tools/zygctl/src/version.reef` — version bump 0.1.2 → 0.1.3 via the existing `scripts/bump-version.sh`

**Branching:** all work on a fresh Mercurial bookmark `visual-pass` off rev 122.

---

## Task 1: ui.reef skeleton + isatty C helper + `--ui-demo` flag

**Files:**
- Create: `src/ui.reef`
- Modify: `src/helpers.c` (append `zyginit_isatty`)
- Modify: `src/main.reef` (argv parsing for `--ui-demo`)
- Test: `tests/integration/ui_tests.sh` (new)

- [ ] **Step 1: Write the failing snapshot test for `--ui-demo skeleton`**

Create `tests/integration/ui_tests.sh`:

```sh
#!/bin/sh
# Snapshot tests for the ui.reef renderer.
# Each scenario invokes `zyginit --ui-demo <name>` and diffs against a golden file.
set -e

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
ZYGINIT="$PROJECT_DIR/build/zyginit"
SNAP_DIR="$SCRIPT_DIR/snapshots"

mkdir -p "$SNAP_DIR"
PASS=0
FAIL=0

check_scenario() {
    name=$1
    env_prefix=$2
    expected="$SNAP_DIR/$name.txt"
    got=$(mktemp)
    eval "$env_prefix $ZYGINIT --ui-demo $name" > "$got" 2>&1
    if [ ! -f "$expected" ]; then
        cp "$got" "$expected"
        echo "  [created]  $name"
        PASS=$((PASS+1))
        rm -f "$got"
        return
    fi
    if diff -u "$expected" "$got" > /dev/null; then
        echo "  [pass]     $name"
        PASS=$((PASS+1))
    else
        echo "  [FAIL]     $name"
        diff -u "$expected" "$got" || true
        FAIL=$((FAIL+1))
    fi
    rm -f "$got"
}

echo "ui snapshot tests"
check_scenario skeleton "ZYGINIT_NO_UI=1"

echo
echo "passed: $PASS  failed: $FAIL"
[ "$FAIL" -eq 0 ]
```

Then make it executable: `chmod +x tests/integration/ui_tests.sh`

- [ ] **Step 2: Run the test to confirm it fails**

Run: `./tests/integration/ui_tests.sh`
Expected: FAIL — `build/zyginit` either doesn't exist or doesn't recognize `--ui-demo`. Either is the failing condition we want.

- [ ] **Step 3: Add the `zyginit_isatty` C helper**

In `src/helpers.c`, append after the existing helpers (before the final closing line):

```c
/* zyginit_isatty: thin wrapper around isatty(3) so Reef FFI sees an int
 * return. Returns 1 if fd is a terminal, 0 otherwise. */
#include <unistd.h>
int zyginit_isatty(int fd) {
    return isatty(fd) ? 1 : 0;
}
```

- [ ] **Step 4: Create `src/ui.reef` skeleton**

Create `src/ui.reef`:

```reef
/* SRCHEADER goes here — copy from project root SRCHEADER.txt, fill in:
   Project: zyginit, Filename: ui.reef,
   Description: Unified visual rendering: palette, glyphs, sigil,
                rolling tape, progress bar, spinner, final cards. */

module ui

import core.str as str
import sys.env as env

// Render modes. Decided once at startup by ui_init().
fn MODE_PLAIN(): int        return 0   end MODE_PLAIN
fn MODE_RICH_ASCII(): int   return 1   end MODE_RICH_ASCII
fn MODE_RICH_16(): int      return 2   end MODE_RICH_16
fn MODE_RICH_TRUE(): int    return 3   end MODE_RICH_TRUE

mut g_mode: int = 0
mut g_failed: bool = false   // set if write() to /dev/console errors

extern "C" fn zyginit_isatty(fd: int): int

proc ui_init(rich_mode: int)
    g_mode = rich_mode
    g_failed = false
end ui_init

fn ui_mode(): int
    return g_mode
end ui_mode

// Scenario dispatcher for --ui-demo. Called from main.reef.
// Returns 0 on success, 1 if the scenario name is unknown.
fn ui_demo(scenario: string): int
    if scenario == "skeleton"
        println("ui.reef skeleton ok, mode=" + int_to_str(g_mode))
        return 0
    end if
    println("ui.reef: unknown demo scenario: " + scenario)
    return 1
end ui_demo

end module
```

- [ ] **Step 5: Wire `--ui-demo` into `src/main.reef`**

Find the argv parsing block near the top of `main()` in `src/main.reef`. Before the existing `--version` check, add:

```reef
// --ui-demo <scenario>: run a canned UI scenario for snapshot testing.
if args.has_flag(argv, "--ui-demo")
    let scenario = args.get_flag_value(argv, "--ui-demo")
    ui.ui_init(ui.MODE_PLAIN())
    process.exit(ui.ui_demo(scenario))
end if
```

And add `import ui` to the imports near the top of `src/main.reef`.

- [ ] **Step 6: Build and re-run the test**

```sh
clang -c src/helpers.c -o build/helpers.o
clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
./tests/integration/ui_tests.sh
```

Expected: snapshot is auto-created on first run. Output line: `ui.reef skeleton ok, mode=0`.

- [ ] **Step 7: Commit**

```sh
hg add src/ui.reef tests/integration/ui_tests.sh tests/integration/snapshots/skeleton.txt
hg commit -m "ui: skeleton + --ui-demo flag + isatty C helper"
```

---

## Task 2: `detect_rich_mode` and plain-mode line renderer

**Files:**
- Modify: `src/ui.reef`
- Modify: `tests/integration/ui_tests.sh`

- [ ] **Step 1: Add failing snapshot tests for plain-mode scenarios**

In `tests/integration/ui_tests.sh`, add after the `skeleton` check:

```sh
check_scenario plain_boot "ZYGINIT_NO_UI=1 TERM=xterm-256color"
check_scenario detect_no_color "NO_COLOR=1 TERM=xterm-256color"
check_scenario detect_dumb "TERM=dumb"
check_scenario detect_sun "TERM=sun"
check_scenario detect_sun_color "TERM=sun-color"
```

- [ ] **Step 2: Run tests to confirm failure**

Run: `./tests/integration/ui_tests.sh`
Expected: FAILs — scenarios are unknown.

- [ ] **Step 3: Implement `detect_rich_mode` and plain-mode emitter in `src/ui.reef`**

Add to `src/ui.reef` (after the mode constants, before `ui_init`):

```reef
fn detect_rich_mode(): int
    if env.has_env("ZYGINIT_NO_UI")     return MODE_PLAIN()     end if
    if env.has_env("NO_COLOR")          return MODE_PLAIN()     end if
    // PID check: non-PID-1 zyginit instances always emit plain.
    // sys.process.getpid is imported at top of file.
    if process.getpid() != 1
        // Allow override for unit tests via --ui-demo: caller calls ui_init() directly.
        // detect_rich_mode is only invoked from boot path in main.reef.
        return MODE_PLAIN()
    end if
    if zyginit_isatty(1) == 0           return MODE_PLAIN()     end if
    let term = env.get_env_or("TERM", "")
    if str.length(term) == 0            return MODE_PLAIN()     end if
    if term == "dumb"                   return MODE_PLAIN()     end if
    if env.has_env("ZYGINIT_ASCII")     return MODE_RICH_ASCII() end if
    if term == "sun-color"              return MODE_RICH_TRUE() end if
    if str.index_of(term, "256color") >= 0
        return MODE_RICH_TRUE()
    end if
    return MODE_RICH_16()
end detect_rich_mode
```

Add `import sys.process as process` at the top of `src/ui.reef`.

Then add a plain-mode emitter:

```reef
// Plain-mode line: "  <elapsed-s>  <level>  <event>     <name>  [k=v ...]"
// Used for boot/shutdown tape entries in MODE_PLAIN and as fallback.
fn fmt_plain_event(elapsed_ms: int, level: string, event: string,
                   name: string, kv: string): string
    let secs = int_to_str(elapsed_ms / 1000)
    let frac = int_to_str((elapsed_ms mod 1000) / 10)   // 2-digit decimal
    let pad = ""
    if str.length(frac) < 2
        pad = "0"
    end if
    mut line = str.concat("  ", secs)
    line = str.concat(line, ".")
    line = str.concat(line, pad)
    line = str.concat(line, frac)
    line = str.concat(line, "  ")
    line = str.concat(line, level)
    line = str.concat(line, "  ")
    line = str.concat(line, event)
    line = str.concat(line, "  ")
    line = str.concat(line, name)
    if str.length(kv) > 0
        line = str.concat(line, "  ")
        line = str.concat(line, kv)
    end if
    return line
end fmt_plain_event
```

- [ ] **Step 4: Add demo scenarios to `ui_demo`**

Replace the `ui_demo` body in `src/ui.reef`:

```reef
fn ui_demo(scenario: string): int
    if scenario == "skeleton"
        println("ui.reef skeleton ok, mode=" + int_to_str(g_mode))
        return 0
    end if
    if scenario == "plain_boot"
        println(fmt_plain_event(40, "info", "started", "root-fs", ""))
        println(fmt_plain_event(120, "info", "started", "crypto", "dur_ms=80"))
        println(fmt_plain_event(310, "info", "started", "devfs", "dur_ms=190"))
        println(fmt_plain_event(5020, "err",  "failed",  "network", "exit=1 dur_ms=4023"))
        return 0
    end if
    if str.index_of(scenario, "detect_") == 0
        // Override g_mode by calling detect_rich_mode under the test env.
        g_mode = detect_rich_mode()
        println("mode=" + int_to_str(g_mode))
        return 0
    end if
    println("ui.reef: unknown demo scenario: " + scenario)
    return 1
end ui_demo
```

- [ ] **Step 5: Build and re-run tests**

```sh
clang -c src/helpers.c -o build/helpers.o
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
./tests/integration/ui_tests.sh
```

Expected snapshots get auto-created. Verify their content by hand:

```sh
cat tests/integration/snapshots/plain_boot.txt
```

Should show 4 lines, last one being the failed network event. Check the detect snapshots match expectations:
- `detect_no_color.txt`: `mode=0`
- `detect_dumb.txt`: `mode=0`
- `detect_sun.txt`: `mode=2` (RICH_16)
- `detect_sun_color.txt`: `mode=3` (RICH_TRUE)

If any are wrong, fix `detect_rich_mode` and re-run.

- [ ] **Step 6: Commit**

```sh
hg add tests/integration/snapshots/*.txt
hg commit -m "ui: detect_rich_mode + plain-mode line formatter + snapshot tests"
```

---

## Task 3: Rich primitives — palette, glyphs, sigil

**Files:**
- Modify: `src/ui.reef`
- Modify: `tests/integration/ui_tests.sh`

- [ ] **Step 1: Add failing snapshots for color and glyph helpers**

In `tests/integration/ui_tests.sh`:

```sh
check_scenario palette_true "TERM=xterm-256color"
check_scenario palette_16   "TERM=sun"
check_scenario glyphs_unicode "TERM=xterm-256color"
check_scenario glyphs_ascii   "TERM=xterm-256color ZYGINIT_ASCII=1"
check_scenario sigil_rich   "TERM=sun-color"
```

- [ ] **Step 2: Run tests — confirm failure**

Run: `./tests/integration/ui_tests.sh`
Expected: 5 new FAILs.

- [ ] **Step 3: Add palette + glyph + sigil functions to `src/ui.reef`**

```reef
// SGR escape sequences. CSI is "\e[", reset is "\e[0m".
fn esc_reset(): string return "\x1b[0m" end esc_reset

// 16-color foreground codes.
fn sgr_steel(): string  return "\x1b[37m"   end sgr_steel    // white
fn sgr_teal(): string   return "\x1b[36m"   end sgr_teal     // cyan
fn sgr_ok(): string     return "\x1b[32m"   end sgr_ok       // green
fn sgr_warn(): string   return "\x1b[33m"   end sgr_warn     // yellow
fn sgr_fail(): string   return "\x1b[31m"   end sgr_fail     // red
fn sgr_mute(): string   return "\x1b[2m"    end sgr_mute     // dim

// Truecolor foreground codes.
fn sgr_steel_true(): string return "\x1b[38;2;110;123;139m"  end sgr_steel_true
fn sgr_teal_true(): string  return "\x1b[38;2;0;106;111m"    end sgr_teal_true
fn sgr_ok_true(): string    return "\x1b[38;2;46;139;87m"    end sgr_ok_true
fn sgr_warn_true(): string  return "\x1b[38;2;255;191;0m"    end sgr_warn_true
fn sgr_fail_true(): string  return "\x1b[38;2;200;32;31m"    end sgr_fail_true

// Wrap text in the given palette token, honoring g_mode.
fn paint(token: string, text: string): string
    if g_mode == MODE_PLAIN()  return text  end if
    mut on = ""
    if g_mode == MODE_RICH_TRUE()
        if token == "frame"  on = sgr_steel_true()
        else if token == "accent" on = sgr_teal_true()
        else if token == "ok" on = sgr_ok_true()
        else if token == "warn" on = sgr_warn_true()
        else if token == "fail" on = sgr_fail_true()
        else if token == "mute" on = sgr_mute()
        end if
    else
        if token == "frame"  on = sgr_steel()
        else if token == "accent" on = sgr_teal()
        else if token == "ok" on = sgr_ok()
        else if token == "warn" on = sgr_warn()
        else if token == "fail" on = sgr_fail()
        else if token == "mute" on = sgr_mute()
        end if
    end if
    return str.concat(str.concat(on, text), esc_reset())
end paint

// Glyphs. Selected by g_mode.
fn glyph_ok(): string
    if g_mode == MODE_RICH_ASCII() return "#" end if
    if g_mode == MODE_PLAIN()      return "ok" end if
    return "●"
end glyph_ok

fn glyph_fail(): string
    if g_mode == MODE_RICH_ASCII() return "X" end if
    if g_mode == MODE_PLAIN()      return "fail" end if
    return "✕"
end glyph_fail

fn glyph_pending(): string
    if g_mode == MODE_RICH_ASCII() return "." end if
    if g_mode == MODE_PLAIN()      return "pending" end if
    return "·"
end glyph_pending

// Static starting glyph used when not animating (rare, e.g. snapshots).
fn glyph_starting(): string
    if g_mode == MODE_RICH_ASCII() return "o" end if
    if g_mode == MODE_PLAIN()      return "starting" end if
    return "◉"
end glyph_starting

// Sigil. Always "[Z]", colored c.accent in rich modes.
fn sigil(): string
    return paint("accent", "[Z]")
end sigil
```

- [ ] **Step 4: Add demo scenarios for the primitives**

In `ui_demo`, add before the final `unknown` line:

```reef
    if scenario == "palette_true" or scenario == "palette_16"
        g_mode = detect_rich_mode()
        println(paint("frame",  "frame text"))
        println(paint("accent", "accent text"))
        println(paint("ok",     "ok text"))
        println(paint("warn",   "warn text"))
        println(paint("fail",   "fail text"))
        println(paint("mute",   "mute text"))
        return 0
    end if
    if scenario == "glyphs_unicode" or scenario == "glyphs_ascii"
        g_mode = detect_rich_mode()
        println(glyph_ok() + " " + glyph_starting() + " " +
                glyph_fail() + " " + glyph_pending())
        return 0
    end if
    if scenario == "sigil_rich"
        g_mode = detect_rich_mode()
        println(sigil() + " zyginit 0.1.3")
        return 0
    end if
```

- [ ] **Step 5: Build and verify snapshots**

```sh
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
./tests/integration/ui_tests.sh
```

By-hand sanity:
- `palette_true.txt` contains `\x1b[38;2;` truecolor escapes.
- `palette_16.txt` contains `\x1b[37m` etc — no truecolor.
- `glyphs_unicode.txt`: `● ◉ ✕ ·`
- `glyphs_ascii.txt`: `# o X .`
- `sigil_rich.txt` starts with `\x1b[38;2;0;106;111m[Z]\x1b[0m`.

- [ ] **Step 6: Commit**

```sh
hg add tests/integration/snapshots/*.txt
hg commit -m "ui: palette + glyph + sigil primitives with mode-aware fallback"
```

---

## Task 4: Boot header + rolling tape

**Files:**
- Modify: `src/ui.reef`
- Modify: `tests/integration/ui_tests.sh`

- [ ] **Step 1: Failing snapshot for boot tape scenario**

In `tests/integration/ui_tests.sh`:

```sh
check_scenario boot_tape_rich  "TERM=xterm-256color"
check_scenario boot_tape_ascii "TERM=xterm ZYGINIT_ASCII=1"
check_scenario boot_tape_plain "ZYGINIT_NO_UI=1"
```

Run: `./tests/integration/ui_tests.sh` → FAIL.

- [ ] **Step 2: Implement tape state + header + emit_started/failed**

Add to `src/ui.reef`:

```reef
// Layout constants.
fn TAPE_HEIGHT(): int return 12 end TAPE_HEIGHT
fn LINE_WIDTH(): int  return 78 end LINE_WIDTH

// Boot/shutdown phase tracker for the header.
mut g_phase: string = ""        // "boot" | "shutdown"
mut g_runlevel: string = ""
mut g_num_svc: int = 0
mut g_num_tiers: int = 0
mut g_cur_tier: int = 0
mut g_done: int = 0
mut g_failed_count: int = 0
mut g_failed_first: string = ""
mut g_failed_first_exit: int = 0
mut g_boot_start_ms: int = 0    // monotonic ms at boot start

// Rolling tape: ring buffer of TAPE_HEIGHT lines.
type TapeLine = struct
    elapsed_ms: int
    glyph: string
    name: string
    note: string
end TapeLine

mut g_tape: [TapeLine] = new [TapeLine](12)
mut g_tape_head: int = 0
mut g_tape_count: int = 0

// time_now_ms: monotonic milliseconds. Wraps sys.time.time_now_ms when
// available; for the demo path we read a static counter.
extern "C" fn zyginit_monotonic_ms(): int

proc tape_push(elapsed_ms: int, glyph: string, name: string, note: string)
    let slot = g_tape_head mod TAPE_HEIGHT()
    g_tape[slot] = TapeLine{ elapsed_ms: elapsed_ms, glyph: glyph,
                             name: name, note: note }
    g_tape_head = g_tape_head + 1
    if g_tape_count < TAPE_HEIGHT()
        g_tape_count = g_tape_count + 1
    end if
end tape_push

// Format a tape row (mode-aware coloring on the glyph).
fn fmt_tape_row(line: TapeLine): string
    let secs = line.elapsed_ms / 1000
    let frac = (line.elapsed_ms mod 1000) / 10
    mut sf = int_to_str(frac)
    if str.length(sf) < 2  sf = str.concat("0", sf)  end if
    let elapsed = int_to_str(secs) + "." + sf
    // Pad elapsed to 6 cols right-aligned.
    mut pad = ""
    let pad_n = 6 - str.length(elapsed)
    mut i = 0
    while i < pad_n
        pad = str.concat(pad, " ")
        i = i + 1
    end while
    mut row = pad + elapsed + "  " + line.glyph + "  " + line.name
    if str.length(line.note) > 0
        row = str.concat(row, "  ")
        row = str.concat(row, paint("mute", "(" + line.note + ")"))
    end if
    return row
end fmt_tape_row

// Header banner + summary + failed-line + divider. 4 rows.
fn fmt_header(elapsed_ms: int): string
    let secs = elapsed_ms / 1000
    let frac = (elapsed_ms mod 1000) / 100
    let elapsed = int_to_str(secs) + "." + int_to_str(frac) + "s"
    let phase_label = g_phase + " · " + g_runlevel
    mut out = "  " + sigil() + " zyginit 0.1.3 · " + paint("frame", phase_label)
    out = str.concat(out, "  ")
    // pad to ~LINE_WIDTH then append elapsed
    out = str.concat(out, paint("accent", "elapsed " + elapsed))
    out = str.concat(out, "\n")
    out = str.concat(out, "  ")
    out = str.concat(out, paint("frame",
        int_to_str(g_num_svc) + " services · tier " + int_to_str(g_cur_tier)
        + " · " + int_to_str(g_done) + " done · "
        + int_to_str(g_failed_count) + " failed"))
    out = str.concat(out, "\n  ")
    if g_failed_count == 0
        out = str.concat(out, paint("mute", "failed: none"))
    else if g_failed_count == 1
        out = str.concat(out, paint("fail",
            "failed: " + g_failed_first + "  exit=" + int_to_str(g_failed_first_exit)))
    else
        out = str.concat(out, paint("fail",
            "failed: " + int_to_str(g_failed_count) + " services"))
    end if
    out = str.concat(out, "\n  ")
    mut div = ""
    mut k = 0
    while k < 62
        div = str.concat(div, "─")
        k = k + 1
    end while
    out = str.concat(out, paint("accent", div))
    return out
end fmt_header

// Render the entire screen for the current state.
fn render_boot_screen(elapsed_ms: int): string
    if g_mode == MODE_PLAIN()
        // Plain mode: no fixed regions; tape rows printed live as events fire.
        return ""
    end if
    mut out = fmt_header(elapsed_ms) + "\n"
    let start = g_tape_head - g_tape_count
    mut i = 0
    while i < TAPE_HEIGHT()
        if i < g_tape_count
            let slot = (start + i) mod TAPE_HEIGHT()
            out = str.concat(out, fmt_tape_row(g_tape[slot]))
        end if
        out = str.concat(out, "\n")
        i = i + 1
    end while
    return out
end render_boot_screen

// Lifecycle entry points.
proc ui_boot_start(num_svc: int, num_tiers: int, runlevel: string)
    g_phase = "boot"
    g_runlevel = runlevel
    g_num_svc = num_svc
    g_num_tiers = num_tiers
    g_cur_tier = 0
    g_done = 0
    g_failed_count = 0
    g_failed_first = ""
    g_failed_first_exit = 0
    g_tape_head = 0
    g_tape_count = 0
    g_boot_start_ms = zyginit_monotonic_ms()
end ui_boot_start

proc ui_tier_start(tier: int)
    g_cur_tier = tier
end ui_tier_start

proc ui_tier_done(tier: int)
    // Header repaint happens on next emit/tick.
end ui_tier_done

proc ui_event_started(name: string, dur_ms: int)
    let elapsed = zyginit_monotonic_ms() - g_boot_start_ms
    g_done = g_done + 1
    tape_push(elapsed, paint("ok", glyph_ok()), name, "")
    emit_or_redraw(elapsed)
end ui_event_started

proc ui_event_failed(name: string, exit_code: int, dur_ms: int)
    let elapsed = zyginit_monotonic_ms() - g_boot_start_ms
    g_done = g_done + 1
    g_failed_count = g_failed_count + 1
    if g_failed_count == 1
        g_failed_first = name
        g_failed_first_exit = exit_code
    end if
    tape_push(elapsed, paint("fail", glyph_fail()), name,
              "exit=" + int_to_str(exit_code))
    emit_or_redraw(elapsed)
end ui_event_failed

// In plain mode: print one line. In rich mode: clear screen and redraw.
proc emit_or_redraw(elapsed_ms: int)
    if g_mode == MODE_PLAIN()
        // Find the most recent tape row and emit it as a plain line.
        if g_tape_count == 0  return  end if
        let last = (g_tape_head - 1) mod TAPE_HEIGHT()
        let line = g_tape[last]
        let level = "info"
        let event = "started"
        // Crude: failure glyph means we render as failed.
        if str.index_of(line.glyph, glyph_fail()) >= 0
            println(fmt_plain_event(elapsed_ms, "err", "failed",
                line.name, line.note))
        else
            println(fmt_plain_event(elapsed_ms, "info", "started",
                line.name, line.note))
        end if
        return
    end if
    // Rich mode: CSI 2J (clear) + CSI H (home).
    print("\x1b[2J\x1b[H")
    print(render_boot_screen(elapsed_ms))
end emit_or_redraw
```

Add `zyginit_monotonic_ms` to `src/helpers.c`:

```c
#include <time.h>
int zyginit_monotonic_ms(void) {
    struct timespec ts;
    if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return 0;
    return (int)((ts.tv_sec * 1000) + (ts.tv_nsec / 1000000));
}
```

- [ ] **Step 3: Add boot_tape demo scenario**

In `ui_demo`:

```reef
    if scenario == "boot_tape_rich" or scenario == "boot_tape_ascii" or
       scenario == "boot_tape_plain"
        g_mode = detect_rich_mode()
        ui_boot_start(17, 8, "multi-user")
        // Hand-rolled event sequence so snapshots are deterministic.
        // Override monotonic_ms by setting g_boot_start_ms to 0 and emitting
        // explicit elapsed values via direct tape_push.
        g_boot_start_ms = 0
        let elapsed_seq = [40, 160, 310, 490, 1040, 1080, 1360, 1550]
        let name_seq = ["root-fs", "crypto", "devfs", "swap",
                        "filesystem", "identity", "sysconfig", "dlmgmtd"]
        mut i = 0
        while i < 8
            g_done = g_done + 1
            tape_push(elapsed_seq[i], paint("ok", glyph_ok()), name_seq[i], "")
            i = i + 1
        end while
        // Inject one failure.
        g_failed_count = 1
        g_failed_first = "network"
        g_failed_first_exit = 1
        g_cur_tier = 5
        g_done = g_done + 1
        tape_push(5020, paint("fail", glyph_fail()), "network", "exit=1")
        // Render once.
        if g_mode == MODE_PLAIN()
            // Plain: re-emit the 9 events.
            let levels = ["info","info","info","info","info","info","info","info","err"]
            let evs    = ["started","started","started","started",
                          "started","started","started","started","failed"]
            let allnames = ["root-fs","crypto","devfs","swap",
                            "filesystem","identity","sysconfig","dlmgmtd","network"]
            let allelap  = [40,160,310,490,1040,1080,1360,1550,5020]
            mut j = 0
            while j < 9
                let kv = ""
                let kv2 = if evs[j] == "failed" then "exit=1" else "" end
                println(fmt_plain_event(allelap[j], levels[j], evs[j],
                                        allnames[j], kv2))
                j = j + 1
            end while
        else
            print(render_boot_screen(5020))
        end if
        return 0
    end if
```

(Note: the `if ... then ... else ... end` ternary form may differ in Reef
syntax; if it doesn't compile, fall back to a small helper that returns the
right string.)

- [ ] **Step 4: Build, run, sanity-check snapshots**

```sh
clang -c src/helpers.c -o build/helpers.o
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
./tests/integration/ui_tests.sh
```

Inspect:
- `boot_tape_rich.txt` — should show the 4-row header with truecolor escapes around `[Z]`, the `failed: network exit=1` line in red, and a tape with 9 rows. The `network` row has the `✕` glyph in red.
- `boot_tape_ascii.txt` — same shape but `#` and `X` glyphs, no truecolor escapes (16-color only).
- `boot_tape_plain.txt` — 9 plain machine-parseable lines; no escapes.

If the rendered tape is misaligned, fix `fmt_tape_row` padding before moving on.

- [ ] **Step 5: Commit**

```sh
hg add tests/integration/snapshots/boot_tape_*.txt
hg commit -m "ui: boot header + rolling tape + emit_or_redraw"
```

---

## Task 5: Progress bar

**Files:**
- Modify: `src/ui.reef`
- Modify: `tests/integration/ui_tests.sh`

- [ ] **Step 1: Failing snapshot for progress bar**

In `tests/integration/ui_tests.sh`:

```sh
check_scenario progress_rich  "TERM=xterm-256color"
check_scenario progress_ascii "TERM=xterm ZYGINIT_ASCII=1"
check_scenario progress_full  "TERM=xterm-256color"   # 17/17 done
```

Run → FAIL.

- [ ] **Step 2: Implement `fmt_progress_bar` and append to `render_boot_screen`**

Add to `src/ui.reef`:

```reef
fn PROGRESS_WIDTH(): int return 36 end PROGRESS_WIDTH

fn fmt_progress_bar(done: int, total: int): string
    if g_mode == MODE_PLAIN()  return ""  end if
    let width = PROGRESS_WIDTH()
    mut filled = 0
    if total > 0
        filled = (done * width) / total
    end if
    if filled > width  filled = width  end if
    let pct = if total > 0 then (done * 100) / total else 0 end
    mut bar = ""
    if g_mode == MODE_RICH_ASCII()
        // [#########          ] form
        bar = str.concat(bar, "[")
        mut i = 0
        while i < (width - 2)
            if i < ((filled * (width - 2)) / width)
                bar = str.concat(bar, "#")
            else
                bar = str.concat(bar, " ")
            end if
            i = i + 1
        end while
        bar = str.concat(bar, "]")
    else
        mut i = 0
        while i < width
            if i < filled
                bar = str.concat(bar, paint("accent", "█"))
            else
                bar = str.concat(bar, paint("mute", "░"))
            end if
            i = i + 1
        end while
    end if
    return "  " + bar + "  " + int_to_str(done) + "/" + int_to_str(total)
                + "  " + int_to_str(pct) + "%"
end fmt_progress_bar
```

Modify `render_boot_screen` to append:

```reef
    // Inside render_boot_screen, after the tape loop, before return:
    mut div = ""
    mut k = 0
    while k < 62
        div = str.concat(div, "─")
        k = k + 1
    end while
    out = str.concat(out, "  ")
    out = str.concat(out, paint("accent", div))
    out = str.concat(out, "\n")
    out = str.concat(out, fmt_progress_bar(g_done, g_num_svc))
    out = str.concat(out, "\n")
    return out
```

- [ ] **Step 3: Add `progress_*` demo scenarios**

```reef
    if scenario == "progress_rich" or scenario == "progress_ascii"
        g_mode = detect_rich_mode()
        ui_boot_start(17, 8, "multi-user")
        g_done = 12
        g_failed_count = 1
        print(fmt_progress_bar(g_done, g_num_svc))
        return 0
    end if
    if scenario == "progress_full"
        g_mode = detect_rich_mode()
        ui_boot_start(17, 8, "multi-user")
        g_done = 17
        print(fmt_progress_bar(g_done, g_num_svc))
        return 0
    end if
```

- [ ] **Step 4: Build and inspect**

```sh
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
./tests/integration/ui_tests.sh
```

Check `progress_rich.txt` shows `█████████████░░░░...  12/17  70%`.
Check `progress_ascii.txt` shows `[####################          ]  12/17  70%`.
Check `progress_full.txt` shows `█` x 36 + `  17/17  100%`.

- [ ] **Step 5: Commit**

```sh
hg add tests/integration/snapshots/progress_*.txt
hg commit -m "ui: progress bar with block + ASCII fallback forms"
```

---

## Task 6: Spinner + ui_tick

**Files:**
- Modify: `src/ui.reef`
- Modify: `tests/integration/ui_tests.sh`

- [ ] **Step 1: Failing snapshot for spinner frames**

```sh
check_scenario spinner_forward  "TERM=xterm-256color"
check_scenario spinner_reverse  "TERM=xterm-256color"
check_scenario spinner_in_tape  "TERM=xterm-256color"
```

Run → FAIL.

- [ ] **Step 2: Implement spinner state**

Add to `src/ui.reef`:

```reef
mut g_tick: int = 0
mut g_starting_name: string = ""    // service in STARTING state, if any
mut g_stopping_name: string = ""    // service in STOPPING state, if any

fn N_FRAMES(): int return 4 end N_FRAMES

fn ui_spinner_frame(reverse: bool): string
    let frames = ["/", "-", "\\", "|"]
    if reverse
        return frames[(N_FRAMES() - 1) - (g_tick mod N_FRAMES())]
    end if
    return frames[g_tick mod N_FRAMES()]
end ui_spinner_frame

proc ui_tick()
    g_tick = g_tick + 1
    if g_mode == MODE_PLAIN()  return  end if
    if str.length(g_starting_name) == 0 and str.length(g_stopping_name) == 0
        return
    end if
    // Just redraw the screen on each tick — the tape's last row holds the
    // currently-starting/stopping service, so its glyph updates.
    let elapsed = zyginit_monotonic_ms() - g_boot_start_ms
    print("\x1b[2J\x1b[H")
    print(render_boot_screen(elapsed))
end ui_tick

proc ui_event_starting(name: string)
    g_starting_name = name
    let elapsed = zyginit_monotonic_ms() - g_boot_start_ms
    tape_push(elapsed, paint("warn", ui_spinner_frame(false)), name, "starting")
    emit_or_redraw(elapsed)
end ui_event_starting

proc ui_event_stopping(name: string)
    g_stopping_name = name
    let elapsed = zyginit_monotonic_ms() - g_boot_start_ms
    tape_push(elapsed, paint("warn", ui_spinner_frame(true)), name, "stopping")
    emit_or_redraw(elapsed)
end ui_event_stopping
```

When `ui_event_started` or `ui_event_failed` fires, clear `g_starting_name`:

```reef
// At the top of ui_event_started and ui_event_failed:
g_starting_name = ""
```

And update `render_boot_screen` so the *active* spinner row's glyph reflects the current frame. The cleanest approach: when rendering the tape, if a row's `name` matches `g_starting_name`, replace its glyph with `paint("warn", ui_spinner_frame(false))`; if it matches `g_stopping_name`, use reverse.

Modify `fmt_tape_row` to accept and re-paint the spinner row, or — simpler — keep a separate `g_active_glyph` that `ui_tick` updates and that the renderer reads when emitting the matching row:

```reef
// In fmt_tape_row, before assembling `row`:
mut glyph = line.glyph
if str.length(g_starting_name) > 0 and line.name == g_starting_name
    glyph = paint("warn", ui_spinner_frame(false))
end if
if str.length(g_stopping_name) > 0 and line.name == g_stopping_name
    glyph = paint("warn", ui_spinner_frame(true))
end if
let row = pad + elapsed + "  " + glyph + "  " + line.name
```

(Update the assembled `row` accordingly.)

- [ ] **Step 3: Add spinner demo scenarios**

```reef
    if scenario == "spinner_forward"
        g_mode = detect_rich_mode()
        mut i = 0
        while i < 8
            g_tick = i
            print(ui_spinner_frame(false))
            i = i + 1
        end while
        println("")
        return 0
    end if
    if scenario == "spinner_reverse"
        g_mode = detect_rich_mode()
        mut i = 0
        while i < 8
            g_tick = i
            print(ui_spinner_frame(true))
            i = i + 1
        end while
        println("")
        return 0
    end if
    if scenario == "spinner_in_tape"
        g_mode = detect_rich_mode()
        ui_boot_start(17, 8, "multi-user")
        g_boot_start_ms = 0
        // 3 done, 1 starting (deterministic frame 2 = "\\")
        tape_push(40, paint("ok", glyph_ok()), "root-fs", "")
        tape_push(160, paint("ok", glyph_ok()), "crypto", "")
        tape_push(310, paint("ok", glyph_ok()), "devfs", "")
        g_done = 3
        ui_event_starting("network")
        g_tick = 2  // force frame "\\"
        print("\x1b[2J\x1b[H")
        print(render_boot_screen(2100))
        return 0
    end if
```

- [ ] **Step 4: Build and verify**

```sh
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
./tests/integration/ui_tests.sh
```

Sanity:
- `spinner_forward.txt`: `/-\|/-\|`
- `spinner_reverse.txt`: `|\-/|\-/`
- `spinner_in_tape.txt`: tape rows show `root-fs ●`, `crypto ●`, `devfs ●`, then `network \` (frame index 2) with `(starting)` note.

- [ ] **Step 5: Commit**

```sh
hg add tests/integration/snapshots/spinner_*.txt
hg commit -m "ui: spinner with reverse rotation for stopping state"
```

---

## Task 7: Boot final card

**Files:**
- Modify: `src/ui.reef`
- Modify: `tests/integration/ui_tests.sh`

- [ ] **Step 1: Failing snapshots**

```sh
check_scenario final_card_ok      "TERM=xterm-256color"
check_scenario final_card_failed  "TERM=xterm-256color"
```

- [ ] **Step 2: Implement `ui_boot_complete`**

Add to `src/ui.reef`:

```reef
type BootStats = struct
    elapsed_ms: int
    online: int
    failed: int
    slowest: [string]      // up to 3 entries: "name N.NNs"
end BootStats

proc ui_boot_complete(stats: BootStats)
    if g_mode == MODE_PLAIN()
        println(fmt_plain_event(stats.elapsed_ms, "info", "boot_complete",
                "summary", "online=" + int_to_str(stats.online)
                + " failed=" + int_to_str(stats.failed)))
        return
    end if
    // Clear screen, paint final card.
    print("\x1b[2J\x1b[H")
    let secs = stats.elapsed_ms / 1000
    let frac = (stats.elapsed_ms mod 1000) / 100
    let elapsed = int_to_str(secs) + "." + int_to_str(frac) + "s"
    let summary_count = int_to_str(stats.online + stats.failed)
    mut out = "  " + sigil() + " zyginit 0.1.3 · " +
              paint("frame", g_runlevel) + "  " +
              paint("accent", "boot " + elapsed) + "\n"
    out = str.concat(out, "  " + paint("frame",
        summary_count + " services — " + int_to_str(stats.online) + " online, "
        + int_to_str(stats.failed) + " failed") + "\n")
    out = str.concat(out, "\n")
    if stats.failed == 0
        out = str.concat(out, "  " + paint("ok", "all services online") + "\n")
    else if stats.failed == 1
        out = str.concat(out, "  " + paint("fail", "failed: " + g_failed_first +
            "  exit=" + int_to_str(g_failed_first_exit)) + "\n")
        out = str.concat(out, "      " +
            paint("accent", "→ zygctl log " + g_failed_first) +
            paint("mute", "    to see why") + "\n")
    else
        out = str.concat(out, "  " + paint("fail",
            "failed: " + int_to_str(stats.failed) + " services") + "\n")
        out = str.concat(out, "      " +
            paint("accent", "→ zygctl status") +
            paint("mute", "    to see them") + "\n")
    end if
    mut div = ""
    mut k = 0
    while k < 62
        div = str.concat(div, "─")
        k = k + 1
    end while
    out = str.concat(out, "  " + paint("accent", div) + "\n")
    if str.length_array(stats.slowest) > 0
        mut slow = "slowest:"
        mut i = 0
        while i < str.length_array(stats.slowest) and i < 3
            slow = str.concat(slow, "  ")
            slow = str.concat(slow, stats.slowest[i])
            i = i + 1
        end while
        out = str.concat(out, "  " + paint("frame", slow) + "\n")
    end if
    print(out)
end ui_boot_complete
```

(Note: `str.length_array` may be spelled `len()` in Reef — verify against `~/repos/reef-lang/docs/`. If different, swap to the correct name.)

- [ ] **Step 3: Add demo scenarios**

```reef
    if scenario == "final_card_ok"
        g_mode = detect_rich_mode()
        ui_boot_start(17, 8, "multi-user")
        let stats = BootStats{
            elapsed_ms: 8300, online: 17, failed: 0,
            slowest: ["dlmgmtd 1.2s", "filesystem 0.55s", "sshd 0.37s"]
        }
        ui_boot_complete(stats)
        return 0
    end if
    if scenario == "final_card_failed"
        g_mode = detect_rich_mode()
        ui_boot_start(17, 8, "multi-user")
        g_failed_first = "network"
        g_failed_first_exit = 1
        let stats = BootStats{
            elapsed_ms: 8300, online: 16, failed: 1,
            slowest: ["network 5.02s", "filesystem 0.55s", "sshd 0.37s"]
        }
        ui_boot_complete(stats)
        return 0
    end if
```

- [ ] **Step 4: Build and verify**

```sh
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
./tests/integration/ui_tests.sh
```

Inspect both snapshots by hand for layout and color escapes.

- [ ] **Step 5: Commit**

```sh
hg add tests/integration/snapshots/final_card_*.txt
hg commit -m "ui: boot final card (success and failure variants)"
```

---

## Task 8: Shutdown mirror + final card

**Files:**
- Modify: `src/ui.reef`
- Modify: `tests/integration/ui_tests.sh`

- [ ] **Step 1: Failing snapshots**

```sh
check_scenario shutdown_mirror   "TERM=xterm-256color"
check_scenario shutdown_final    "TERM=xterm-256color"
```

- [ ] **Step 2: Implement shutdown procs**

Add to `src/ui.reef`:

```reef
mut g_shutdown_reason: string = ""

proc ui_shutdown_start(reason: string)
    g_phase = "shutdown"
    g_shutdown_reason = reason
    g_runlevel = "stopping for " + reason
    g_done = 0       // counts services that have reached STOPPED
    g_failed_count = 0
    g_failed_first = ""
    g_failed_first_exit = 0
    g_tape_head = 0
    g_tape_count = 0
    g_boot_start_ms = zyginit_monotonic_ms()
end ui_shutdown_start

proc ui_event_stopped(name: string, dur_ms: int)
    g_stopping_name = ""
    let elapsed = zyginit_monotonic_ms() - g_boot_start_ms
    g_done = g_done + 1
    tape_push(elapsed, paint("mute", glyph_pending()), name, "")
    emit_or_redraw(elapsed)
end ui_event_stopped

proc ui_shutdown_complete(reason: string, elapsed_ms: int)
    if g_mode == MODE_PLAIN()
        println(fmt_plain_event(elapsed_ms, "info", "shutdown_complete",
                reason, "down=" + int_to_str(g_done)))
        return
    end if
    print("\x1b[2J\x1b[H")
    let secs = elapsed_ms / 1000
    let frac = (elapsed_ms mod 1000) / 100
    let elapsed = int_to_str(secs) + "." + int_to_str(frac) + "s"
    mut out = "  " + sigil() + " zyginit 0.1.3 · " +
              paint("frame", reason + " complete") + "  " +
              paint("accent", reason + " " + elapsed) + "\n"
    let stat = if g_failed_count == 0 then "stopped cleanly"
               else "stopped (" + int_to_str(g_failed_count) +
                    " force-killed)" end
    out = str.concat(out, "  " + paint("frame",
        int_to_str(g_num_svc) + " services " + stat) + "\n")
    mut div = ""
    mut k = 0
    while k < 62
        div = str.concat(div, "─")
        k = k + 1
    end while
    out = str.concat(out, "  " + paint("accent", div) + "\n")
    out = str.concat(out, "  " + paint("mute",
        "invoking uadmin(A_SHUTDOWN, AD_BOOT)") + "\n")
    print(out)
end ui_shutdown_complete
```

The progress bar already works in shutdown direction because it computes
`done / total`. Update `fmt_progress_bar` to show "down" when `g_phase == "shutdown"`:

```reef
    let label = if g_phase == "shutdown" then " down" else "" end
    return "  " + bar + "  " + int_to_str(done) + "/" + int_to_str(total)
                + label + "  " + int_to_str(pct) + "%"
```

- [ ] **Step 3: Add demo scenarios**

```reef
    if scenario == "shutdown_mirror"
        g_mode = detect_rich_mode()
        ui_boot_start(17, 8, "multi-user")
        ui_shutdown_start("reboot")
        g_boot_start_ms = 0
        tape_push(40,  paint("mute", glyph_pending()), "sshd", "")
        tape_push(160, paint("mute", glyph_pending()), "console-login", "")
        tape_push(310, paint("mute", glyph_pending()), "cron", "")
        ui_event_stopping("syslogd")
        g_tick = 3   // force last frame "/"
        g_done = 4
        g_cur_tier = 6
        print("\x1b[2J\x1b[H")
        print(render_boot_screen(480))
        return 0
    end if
    if scenario == "shutdown_final"
        g_mode = detect_rich_mode()
        ui_boot_start(17, 8, "multi-user")
        ui_shutdown_start("reboot")
        g_num_svc = 17
        g_done = 17
        ui_shutdown_complete("reboot", 2100)
        return 0
    end if
```

- [ ] **Step 4: Build and verify**

```sh
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
./tests/integration/ui_tests.sh
```

Inspect:
- `shutdown_mirror.txt` — reverse spinner on `syslogd` row (frame `/`), header says "stopping for reboot".
- `shutdown_final.txt` — final card with "reboot complete" and "stopped cleanly".

- [ ] **Step 5: Commit**

```sh
hg add tests/integration/snapshots/shutdown_*.txt
hg commit -m "ui: shutdown mirror + shutdown final card"
```

---

## Task 9: `zygctl status` server-side rendering + PLAIN modifier

**Files:**
- Modify: `src/ui.reef` (add `ui_render_status`)
- Modify: `src/socket.reef` (delegate `cmd_status`)
- Modify: `tests/integration/run_tests.sh` (add tests, set ZYGINIT_NO_UI=1)

- [ ] **Step 1: Failing test in the integration harness**

In `tests/integration/run_tests.sh`, add toward the end (after existing tests):

```sh
echo "TEST: zygctl status renders banner+table when not piped"
$ZYGINIT &
sleep 0.5
out=$(script -qfc "$ZYGCTL status" /dev/null 2>&1)
echo "$out" | grep -q "\[Z\] zyginit" || fail "expected [Z] sigil in TTY status output"
echo "$out" | grep -q "SERVICE" || fail "expected SERVICE column header"
pass

echo "TEST: zygctl status returns plain when piped"
out=$($ZYGCTL status | cat)
echo "$out" | grep -q "\[Z\]" && fail "did not expect sigil in piped output"
echo "$out" | grep -q "SERVICE" || fail "expected SERVICE column header even in plain"
pass
```

And ensure `ZYGINIT_NO_UI` is unset for these specific tests (the boot UI of the daemon is irrelevant — only the rendered status response).

- [ ] **Step 2: Run integration suite — confirm failure**

```sh
./tests/integration/run_tests.sh
```

Expected: the two new tests fail (sigil absent because the renderer hasn't been wired).

- [ ] **Step 3: Add `ui_render_status` to `src/ui.reef`**

```reef
fn ui_render_status(table: supervisor.ServiceTable,
                    want_banner: int, plain: int): string
    let count = supervisor.service_count(table)
    let prev_mode = g_mode
    if plain != 0
        g_mode = MODE_PLAIN()
    end if
    mut out = ""
    // Compute summary counts.
    mut online = 0
    mut failed = 0
    mut i = 0
    while i < count
        let rt = supervisor.get_runtime(table, i)
        let st = supervisor.rt_state(rt)
        if st == supervisor.STATE_RUNNING()
            online = online + 1
        else if st == supervisor.STATE_FAILED() or
                st == supervisor.STATE_MAINTENANCE()
            failed = failed + 1
        end if
        i = i + 1
    end while
    if want_banner != 0 and g_mode != MODE_PLAIN()
        out = str.concat(out, "  " + sigil() + " zyginit 0.1.3 · ")
        out = str.concat(out, paint("frame", "multi-user · " +
            int_to_str(count) + " services · " +
            int_to_str(online) + " online, " +
            int_to_str(failed) + " failed") + "\n")
        mut div = ""
        mut k = 0
        while k < 62
            div = str.concat(div, "─")
            k = k + 1
        end while
        out = str.concat(out, "  " + paint("accent", div) + "\n")
    end if
    // Column header.
    out = str.concat(out, "  ")
    out = str.concat(out, paint("frame",
        pad_right("SERVICE", 14) +
        pad_right("STATE", 11) +
        pad_right("PID", 7) +
        pad_right("CTID", 7) +
        pad_right("UPTIME", 10) +
        "NOTES"))
    out = str.concat(out, "\n")
    // Service rows.
    mut j = 0
    while j < count
        let rt = supervisor.get_runtime(table, j)
        out = str.concat(out, fmt_status_row(rt))
        out = str.concat(out, "\n")
        j = j + 1
    end while
    g_mode = prev_mode
    return out
end ui_render_status

fn fmt_status_row(rt): string
    // Implementation detail: read fields from the runtime struct (consult
    // src/supervisor.reef for accessor names). Output one row:
    //    name        glyph state    pid    ctid    uptime    notes
    // For plain mode: no glyph, no escapes.
    // Re-use the existing supervisor.format_duration() helper for uptime.
    // …pseudo-code body…
    return "  …"
end fmt_status_row

fn pad_right(s: string, n: int): string
    let extra = n - str.length(s)
    if extra <= 0  return s  end if
    mut out = s
    mut i = 0
    while i < extra
        out = str.concat(out, " ")
        i = i + 1
    end while
    return out
end pad_right
```

Fill in `fmt_status_row` body using the existing runtime accessors in
`src/supervisor.reef:835` (`get_status_line`). Reuse its logic for
uptime / exit_code / restart_count, but format as columns with the glyph in
the STATE column. State → glyph mapping:

- `STATE_RUNNING` → `glyph_ok()` + " running"
- `STATE_EXITED` (oneshot success) → `glyph_ok()` + " online"
- `STATE_STARTING` → `glyph_starting()` + " starting"
- `STATE_STOPPING` → `glyph_starting()` + " stopping"
- `STATE_STOPPED` → `glyph_pending()` + " stopped"
- `STATE_FAILED` → `glyph_fail()` + " failed"
- `STATE_MAINTENANCE` → `glyph_fail()` + " maint."

- [ ] **Step 4: Wire `cmd_status` to delegate**

Add a single-row renderer to `src/ui.reef` (avoids needing a "subtable"
abstraction):

```reef
fn ui_render_status_one(table: supervisor.ServiceTable, idx: int,
                        plain: int): string
    let prev_mode = g_mode
    if plain != 0  g_mode = MODE_PLAIN()  end if
    let rt = supervisor.get_runtime(table, idx)
    let row = fmt_status_row(rt) + "\n"
    g_mode = prev_mode
    return row
end ui_render_status_one
```

Then in `src/socket.reef`, replace the body of `cmd_status` (around line 261):

```reef
fn cmd_status(table: supervisor.ServiceTable, arg: string): string
    let count = supervisor.service_count(table)
    if str.length(arg) > 0
        let idx = supervisor.find_service(table, arg)
        if idx < 0
            return "error: unknown service: " + arg + "\n"
        end if
        return ui.ui_render_status_one(table, idx, 1)
    end if
    if count == 0
        return "no services loaded\n"
    end if
    return ui.ui_render_status(table, 1, 0)
end cmd_status
```

- [ ] **Step 5: Add the `STATUS PLAIN` wire modifier**

In `src/socket.reef`, find the request parser (the dispatch around
`cmd_status` / `cmd_list`). Adjust to accept a trailing `PLAIN` token:

```reef
// Existing dispatch (rough shape):
//   "STATUS" → cmd_status(table, "")
//   "STATUS sshd" → cmd_status(table, "sshd")
// New:
//   "STATUS PLAIN" → cmd_status_plain(table, "")
//   "STATUS PLAIN sshd" → cmd_status_plain(table, "sshd")
fn cmd_status_plain(table: supervisor.ServiceTable, arg: string): string
    if str.length(arg) > 0
        let idx = supervisor.find_service(table, arg)
        if idx < 0  return "error: unknown service: " + arg + "\n"  end if
        let one_table = supervisor.subtable(table, idx)
        return ui.ui_render_status(one_table, 0, 1)
    end if
    return ui.ui_render_status(table, 0, 1)
end cmd_status_plain
```

Update the request tokenizer to recognize `STATUS PLAIN` before falling
through to `STATUS`.

- [ ] **Step 6: Build + run the integration suite**

```sh
clang -c src/helpers.c -o build/helpers.o
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
(cd tools/zygctl && clang -c src/symlink_wrapper.c -o build/symlink_wrapper.o && reefc build --obj build/symlink_wrapper.o)
./tests/integration/run_tests.sh
```

Both new tests should pass; existing 44 should remain green.

- [ ] **Step 7: Commit**

```sh
hg commit -m "ui+socket: zygctl status banner+table + STATUS PLAIN modifier"
```

- [ ] **Step 8: Add `ui_render_list` and wire `cmd_list`**

`zygctl list` keeps its minimal one-line-per-service shape but adds a
small enhancement: enabled state, and color on the type chip when not
plain. Add to `src/ui.reef`:

```reef
fn ui_render_list(table: supervisor.ServiceTable, plain: int): string
    let count = supervisor.service_count(table)
    if count == 0  return "no services loaded\n"  end if
    let prev_mode = g_mode
    if plain != 0  g_mode = MODE_PLAIN()  end if
    mut out = ""
    mut i = 0
    while i < count
        let rt = supervisor.get_runtime(table, i)
        let def = supervisor.rt_def(rt)
        let name = config.svc_name(def)
        let stype = config.service_type_name(config.svc_type(def))
        let enabled = supervisor.rt_enabled(rt)
        let chip = paint("accent", stype)
        let enabled_str = if enabled then "enabled" else "disabled" end
        out = str.concat(out, name)
        out = str.concat(out, " (")
        out = str.concat(out, chip)
        out = str.concat(out, ", ")
        out = str.concat(out, enabled_str)
        out = str.concat(out, ")\n")
        i = i + 1
    end while
    g_mode = prev_mode
    return out
end ui_render_list
```

If `supervisor.rt_enabled` doesn't exist, fall back to `true` (every
service in the table is enabled, since that's how it got loaded).

In `src/socket.reef`, replace `cmd_list` body:

```reef
fn cmd_list(table: supervisor.ServiceTable): string
    return ui.ui_render_list(table, 0)
end cmd_list

fn cmd_list_plain(table: supervisor.ServiceTable): string
    return ui.ui_render_list(table, 1)
end cmd_list_plain
```

Add `LIST PLAIN` to the request tokenizer alongside `STATUS PLAIN`.

- [ ] **Step 9: Build + verify list rendering**

```sh
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
./tests/integration/run_tests.sh
```

The existing `zygctl list` tests should still pass (the response now
contains `(type, enabled)` instead of `(type)` — if any test does an exact
string match, update it to match the new shape).

- [ ] **Step 10: Commit**

```sh
hg commit -m "ui+socket: zygctl list enabled chip + LIST PLAIN modifier"
```

---

## Task 10: zygctl client pre-flight + STATUS PLAIN / LIST PLAIN dispatch

**Files:**
- Modify: `tools/zygctl/src/main.reef`

- [ ] **Step 1: Failing assertion is already in place from Task 9**

(Both tests added in Task 9 still need the client side to dispatch correctly. The server side renders correctly only if asked.)

- [ ] **Step 2: Add `detect_rich_mode` equivalent in zygctl client**

In `tools/zygctl/src/main.reef`, add near the top (above `cmd_status` /
client dispatch):

```reef
extern "C" fn zyginit_isatty(fd: int): int

fn client_should_be_plain(): bool
    if env.has_env("NO_COLOR")  return true  end if
    if zyginit_isatty(1) == 0   return true  end if
    let term = env.get_env_or("TERM", "")
    if str.length(term) == 0    return true  end if
    if term == "dumb"           return true  end if
    return false
end client_should_be_plain
```

Note: `zygctl` already links `symlink_wrapper.c`. Add the `zyginit_isatty`
function to that same file (it's a thin POSIX call, no special wiring):

```c
#include <unistd.h>
int zyginit_isatty(int fd) {
    return isatty(fd) ? 1 : 0;
}
```

- [ ] **Step 3: Update the `status` dispatch in `tools/zygctl/src/main.reef`**

Find where `status` builds its socket request (~line 60 area, exact line
depends on current rev). Replace with:

```reef
    if cmd == "status"
        mut req = "STATUS"
        if client_should_be_plain()
            req = "STATUS PLAIN"
        end if
        if str.length(arg) > 0
            req = req + " " + arg
        end if
        send_to_socket(req)
        return
    end if
    if cmd == "list"
        mut req = "LIST"
        if client_should_be_plain()
            req = "LIST PLAIN"
        end if
        send_to_socket(req)
        return
    end if
```

(`send_to_socket` is whatever helper the existing code uses for the same
purpose — keep the same call site.)

- [ ] **Step 4: Build + run**

```sh
(cd tools/zygctl && clang -c src/symlink_wrapper.c -o build/symlink_wrapper.o && reefc build --obj build/symlink_wrapper.o)
./tests/integration/run_tests.sh
```

The two zygctl status tests from Task 9 should now both pass.

- [ ] **Step 5: Commit**

```sh
hg commit -m "zygctl: TTY-aware dispatch (STATUS vs STATUS PLAIN)"
```

---

## Task 11: Wire ui_* calls into main.reef and supervisor.reef

**Files:**
- Modify: `src/main.reef`
- Modify: `src/supervisor.reef`

- [ ] **Step 1: Run the existing integration suite — capture baseline**

```sh
./tests/integration/run_tests.sh 2>&1 | tail -5
```

Should currently report 46 passing (44 original + 2 from Task 9).

- [ ] **Step 2: In `src/main.reef`, replace the boot-banner prints with ui calls**

Find the section in `src/main.reef:main()` where the daemon prints
`zyginit v0.1.3 starting`, `running as PID 1`, `booting into multi-user`,
`loaded N services`, etc. Replace with:

```reef
    let rich_mode = ui.detect_rich_mode()
    ui.ui_init(rich_mode)

    // Existing dep graph loading happens here…
    let svc_count = supervisor.service_count(g_table)
    let num_tiers = depgraph.tier_count(g_boot_order)
    let runlevel = if g_single_user then "single-user" else "multi-user" end
    ui.ui_boot_start(svc_count, num_tiers, runlevel)
```

Keep the existing `zyginit: …` warning/error prints — those route to log
files in rich mode (or stay on-console in plain mode, which is fine).

- [ ] **Step 3: Wrap each tier in `ui_tier_start` / `ui_tier_done`**

Find the tier-iteration loop (`for each tier in g_boot_order …`). Insert
calls:

```reef
    mut tier = 0
    while tier < num_tiers
        ui.ui_tier_start(tier)
        // …existing per-tier service start logic…
        ui.ui_tier_done(tier)
        tier = tier + 1
    end while
```

- [ ] **Step 4: Add `ui_tick` to the main poll loop**

In the steady-state poll loop, after `poll()` returns:

```reef
    ui.ui_tick()
```

This is cheap (no-op once boot completes and there are no STARTING/STOPPING
services).

- [ ] **Step 5: Call `ui_boot_complete` once all tiers finish**

After the tier loop and before "entering event loop":

```reef
    let stats = build_boot_stats(g_table, ...)  // collect online/failed/slowest
    ui.ui_boot_complete(stats)
```

`build_boot_stats` is a small helper that scans the service table and
computes online count, failed count, and the 3 slowest services by start
time. Add it inline in `main.reef`.

- [ ] **Step 6: Wire shutdown**

Find the existing `shutdown_services` / shutdown sequence. Wrap:

```reef
    ui.ui_shutdown_start(g_shutdown_reason)
    // …existing reverse-tier stop loop…
    ui.ui_shutdown_complete(g_shutdown_reason, total_elapsed_ms)
```

For each stop, in `supervisor.reef` (next step), `ui_event_stopping` and
`ui_event_stopped` will fire.

- [ ] **Step 7: In `src/supervisor.reef`, route lifecycle prints**

Find these lines (approximate refs from rev 122):

- `supervisor: started ${name}` near line 456 → replace with `ui.ui_event_started(name, dur_ms)`
- `supervisor: stopping ${name} (signal)` near line 490 → replace with `ui.ui_event_stopping(name)`
- `supervisor: ${name} stopped` near line 622 → replace with `ui.ui_event_stopped(name, dur_ms)`
- `supervisor: ${name} stopped (exit ${code})` near line 653 → replace with `ui.ui_event_failed(name, exit_code, dur_ms)` when exit_code != 0
- `supervisor: ${name} completed (oneshot)` near line 610 → `ui.ui_event_started(name, dur_ms)` (oneshot completion is "online")
- `supervisor: ${name} failed (oneshot, exit N)` near line 613 → `ui.ui_event_failed(...)`

Add `import ui` at the top of `src/supervisor.reef`.

For warning prints (`supervisor: ${name} stop timed out, killing`,
`entered maintenance`, `scheduled for restart`, etc.) — keep them as
`println` for now. They land in the log file under rich mode and on the
console under plain mode.

- [ ] **Step 8: Build and run integration suite**

```sh
clang -c src/helpers.c -o build/helpers.o
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
(cd tools/zygctl && reefc build --obj build/symlink_wrapper.o)
./tests/integration/run_tests.sh
```

Expected: 46 pass. The integration harness sets `ZYGINIT_NO_UI=1` (added in
Task 12 below — set it manually for this run if needed), forcing plain
mode, so the assertions on string outputs don't shift.

- [ ] **Step 9: Run `./tests/integration/ui_tests.sh`**

All previous snapshots should still match.

- [ ] **Step 10: Commit**

```sh
hg commit -m "main+supervisor: route lifecycle prints through ui.reef"
```

---

## Task 12: Version bump, integration test hygiene, hh-prototest smoke

**Files:**
- Modify: `tests/integration/run_tests.sh` (set `ZYGINIT_NO_UI=1`)
- Run: `scripts/bump-version.sh 0.1.3`
- Modify: spec/plan dates / status if needed

- [ ] **Step 1: Force plain mode in the integration harness**

In `tests/integration/run_tests.sh`, near the existing `export ZYGINIT_*`
block:

```sh
export ZYGINIT_NO_UI=1
```

This keeps the boot UI from polluting integration-test output. The two
`zygctl status` tests added in Task 9 explicitly *unset* this var before
testing the rich vs plain dispatch.

- [ ] **Step 2: Verify the integration harness still passes end-to-end**

```sh
./tests/integration/run_tests.sh
./tests/integration/ui_tests.sh
```

Both should pass.

- [ ] **Step 3: Bump version 0.1.2 → 0.1.3**

```sh
./scripts/bump-version.sh 0.1.3
hg diff --stat
```

Expected: changes in `reef.toml`, `tools/zygctl/reef.toml`,
`tools/sysv-wrapper/Makefile`, `src/version.reef`,
`tools/zygctl/src/version.reef`. Verify with:

```sh
grep -nE 'version|VERSION' reef.toml tools/zygctl/reef.toml \
    src/version.reef tools/zygctl/src/version.reef
```

All should show `0.1.3`.

- [ ] **Step 4: Update spec doc-string references in `src/ui.reef`**

Any hardcoded `0.1.3` strings in the demo scenarios are intentional —
verify they still match by running the snapshot suite. If the snapshots
were captured at 0.1.2, the version-string change will produce diffs.
Re-run with snapshot regeneration:

```sh
rm tests/integration/snapshots/*.txt
./tests/integration/ui_tests.sh   # regenerates
hg diff tests/integration/snapshots/ | head -50
```

Confirm the only changes are `0.1.2 → 0.1.3` in the version strings.
Commit the regenerated snapshots.

- [ ] **Step 5: Hammerhead deploy + smoke test on hh-prototest**

```sh
# From dev host:
scp src/ui.reef src/helpers.c src/main.reef src/supervisor.reef \
    src/socket.reef tools/zygctl/src/main.reef tools/zygctl/src/symlink_wrapper.c \
    root@192.168.122.50:/root/zyginit/src/  # adjust paths per file

# Or rsync the whole tree:
rsync -av --exclude build/ . root@192.168.122.50:/root/zyginit/

# On hh-prototest:
ssh root@192.168.122.50 <<'EOF'
    cd /root/zyginit
    gcc -c src/helpers.c -o build/helpers.o
    reefc build -l contract --obj build/helpers.o
    cp build/zyginit /sbin/init.new
    mv /sbin/init.new /sbin/init
    cd tools/zygctl
    gcc -c src/symlink_wrapper.c -o build/symlink_wrapper.o
    reefc build --obj build/symlink_wrapper.o
    cp build/zygctl /sbin/zygctl.new
    mv /sbin/zygctl.new /sbin/zygctl
EOF

# Reboot the VM (init 6 from PID-1 zyginit is a real reboot now):
virsh --connect=qemu:///system reset hh-prototest

# Watch the boot via virsh console (or video out if you can capture):
virsh --connect=qemu:///system console hh-prototest
```

Verify visually:
- The boot screen shows the `[Z]` sigil, rolling tape, and progress bar
  in 256-color (Hammerhead framebuffer = `TERM=sun-color`).
- After all tiers complete, the final card appears with `boot N.Ns` and a
  summary line.
- `console-login` overwrites the card with the `Login:` prompt.
- Once logged in: `zygctl status` shows the banner+table in 256-color.

If anything looks wrong, capture screenshots and iterate. Note that polled
I/O is slow — pauses of ~100ms per redraw are expected and not a regression.

- [ ] **Step 6: Test shutdown**

From the SSH session on hh-prototest:

```sh
zygctl reboot
```

Expected screen: "stopping for reboot" header, services tick from `●` → `·`
in reverse tier order, final card "reboot complete · stopped cleanly", then
the kernel reset takes over.

- [ ] **Step 7: Commit and tag**

```sh
hg commit -m "release: bump to 0.1.3 (visual pass)"
hg tag v0.1.3
```

- [ ] **Step 8: Update auto-memory**

After deploy succeeds, the user will likely want a memory note. The plan
ends here; the user updates `MEMORY.md` and adds a `visual_pass.md` memory
file if they want to record the milestone.

---

## Self-review notes

- **Spec coverage:** every section of the design spec has at least one task implementing it. §4.3 spinner → Task 6. §5.1 boot tape → Task 4. §5.2 final card → Task 7. §5.3 shutdown mirror → Task 8. §5.5 `zygctl status` → Task 9 + 10. §6 render policy → Task 2 (`detect_rich_mode`). §7 error handling → covered inline in `emit_or_redraw` and the `g_failed` flag. §8 data flow → Task 11.
- **No placeholders:** every step has a concrete file path, code block, or shell command.
- **Type consistency:** `BootStats` defined in Task 7; `TapeLine` in Task 4; both used by name in later tasks.
- **Tests-first:** every task starts with a failing test (snapshot or integration).
- **Frequent commits:** every task ends with a focused `hg commit`.
- **YAGNI:** no speculative features. Spinner is event-driven, not timer-driven. No `tier_name` field. No graphical splash.
- **DRY:** `paint`, `glyph_*`, `fmt_plain_event`, `pad_right` factored once; reused everywhere.
