# zyginit 0.2.0 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:** Ship 0.2.0 per `docs/superpowers/specs/2026-05-14-zyginit-0.2.0-design.md` — per-service directory layout, functional `task` type, `[condition]` block with SKIPPED state, plus migration tool. Hard-cut release.

**Architecture:** Three integrated changes that share the same `service.toml` schema. Layout reshape comes first (everything downstream consumes it); `task` semantics and `[condition]` block are independent additions to the supervisor and config parser. New UI counter (`g_skipped`) and event proc (`ui_event_skipped`). All work on Mercurial bookmark `visual-pass` on top of rev 166 (current tip after the spec commit).

**Tech Stack:** Reef 0.4+ (compiles to C via reefc), POSIX shell for the migration tool, Mercurial for VCS.

---

## File Structure

**Create:**
- `scripts/migrate-layout.sh` — migration tool (POSIX shell)
- `tests/integration/migrate_layout_tests.sh` — migration tool tests (POSIX shell)
- `tests/integration/fixtures/old-layout/` — fixture tree for migration tests
- `src/condition.reef` — new module: condition evaluation (`evaluate_conditions`, `ConditionResult`)

**Modify:**
- `src/config.reef` — rename `SERVICE_TYPE_TRANSIENT` → `SERVICE_TYPE_TASK`, add `[condition]` parser, change `scan_services` to walk `services/*/service.toml`, change `scan_enabled` to dereference directory symlinks with escape check
- `src/supervisor.reef` — add `task` branch in `handle_contract_event`; add `STATE_SKIPPED` constant + `skip_reason` field on `ServiceRuntime`; wire `evaluate_conditions` into `start_service`
- `src/ui.reef` — add `g_skipped` counter + `ui_event_skipped` proc; update `fmt_header` and `ui_boot_complete` to show skipped count; update `render_boot_screen` progress-bar denominator
- `src/ui_render.reef` — add `STATE_SKIPPED` mapping to `state_color` / `state_glyph` / `state_label`; show `skip_reason` in NOTES column
- `src/main.reef` — fatal error when `services/` directory missing; reset `g_skipped` alongside other counters
- `tools/zygctl/src/main.reef` — `enable`/`disable` create/remove symlinks targeting `../services/<name>/` (directory), not `<name>.toml` (file)
- `services/hammerhead/` — migrated to new layout in one commit
- `examples/` — migrated to new layout (or relocated under services/) in same commit
- `tests/integration/ui_tests.sh` — add 5 new scenarios (`task_ok`, `task_failed`, `boot_with_skipped`, `final_card_with_skipped`, `status_skipped`)
- `tests/integration/run_tests.sh` — adapt fixtures to new layout where needed
- `reef.toml`, `tools/zygctl/reef.toml`, `tools/sysv-wrapper/version.h`, `src/version.reef`, `tools/zygctl/src/version.reef` — version bump 0.1.8 → 0.2.0 via `scripts/bump-version.sh`

---

## Task 1: Migration tool

**Files:**
- Create: `scripts/migrate-layout.sh`
- Create: `tests/integration/migrate_layout_tests.sh`
- Create: `tests/integration/fixtures/old-layout/` (test fixture tree)

- [ ] **Step 1: Create the test fixture tree**

```sh
mkdir -p tests/integration/fixtures/old-layout/enabled.d
cat > tests/integration/fixtures/old-layout/cron.toml <<'EOF'
[service]
name = "cron"
type = "daemon"

[exec]
start = "/usr/sbin/cron"
EOF

cat > tests/integration/fixtures/old-layout/filesystem.toml <<'EOF'
[service]
name = "filesystem"
type = "oneshot"

[exec]
start = "./start.sh"
EOF

mkdir -p tests/integration/fixtures/old-layout/filesystem
cat > tests/integration/fixtures/old-layout/filesystem/start.sh <<'EOF'
#!/bin/sh
mount -a
EOF
chmod +x tests/integration/fixtures/old-layout/filesystem/start.sh

# Symlink in enabled.d targeting the old <name>.toml form
ln -s ../cron.toml tests/integration/fixtures/old-layout/enabled.d/cron
ln -s ../filesystem.toml tests/integration/fixtures/old-layout/enabled.d/filesystem
```

- [ ] **Step 2: Write the failing test harness**

Create `tests/integration/migrate_layout_tests.sh`:

```sh
#!/bin/sh
# Tests for scripts/migrate-layout.sh
set -e

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
TOOL="$PROJECT_DIR/scripts/migrate-layout.sh"
FIXTURE_SRC="$SCRIPT_DIR/fixtures/old-layout"

PASS=0
FAIL=0

pass() { echo "  [pass]   $1";  PASS=$((PASS+1)); }
fail() { echo "  [FAIL]   $1"; echo "    $2"; FAIL=$((FAIL+1)); }

setup_tmp() {
    TMP=$(mktemp -d /tmp/migrate-test.XXXXXX)
    cp -a "$FIXTURE_SRC"/. "$TMP"/
    echo "$TMP"
}

# --- Test 1: --dry-run does not modify anything ---
TMP=$(setup_tmp)
BEFORE=$(find "$TMP" | sort)
"$TOOL" --dry-run --config-dir "$TMP" > /dev/null
AFTER=$(find "$TMP" | sort)
if [ "$BEFORE" = "$AFTER" ]; then
    pass "dry-run leaves tree unchanged"
else
    fail "dry-run leaves tree unchanged" "tree changed"
fi
rm -rf "$TMP"

# --- Test 2: --apply migrates flat layout to per-service directories ---
TMP=$(setup_tmp)
"$TOOL" --apply --config-dir "$TMP" > /dev/null
if [ -f "$TMP/services/cron/service.toml" ] && \
   [ -f "$TMP/services/filesystem/service.toml" ] && \
   [ -f "$TMP/services/filesystem/start.sh" ]; then
    pass "apply creates services/<name>/service.toml"
else
    fail "apply creates services/<name>/service.toml" "missing expected files"
fi
rm -rf "$TMP"

# --- Test 3: --apply retargets enabled.d symlinks to directories ---
TMP=$(setup_tmp)
"$TOOL" --apply --config-dir "$TMP" > /dev/null
target=$(readlink "$TMP/enabled.d/cron")
case "$target" in
    *services/cron) pass "enabled.d symlink targets directory" ;;
    *)              fail "enabled.d symlink targets directory" "got '$target'" ;;
esac
rm -rf "$TMP"

# --- Test 4: --apply is idempotent ---
TMP=$(setup_tmp)
"$TOOL" --apply --config-dir "$TMP" > /dev/null
SECOND=$("$TOOL" --apply --config-dir "$TMP" 2>&1)
if echo "$SECOND" | grep -q "already migrated\|nothing to migrate\|no changes"; then
    pass "apply is idempotent"
else
    fail "apply is idempotent" "second run did not report no-op"
fi
rm -rf "$TMP"

# --- Test 5: refuses when both old and new layout coexist ---
TMP=$(setup_tmp)
mkdir -p "$TMP/services/cron"
cp "$TMP/cron.toml" "$TMP/services/cron/service.toml"
# Now both $TMP/cron.toml and $TMP/services/cron/service.toml exist
set +e
"$TOOL" --apply --config-dir "$TMP" > /dev/null 2>&1
rc=$?
set -e
if [ "$rc" -ne 0 ]; then
    pass "refuses on ambiguous old+new coexistence"
else
    fail "refuses on ambiguous old+new coexistence" "exit was 0"
fi
rm -rf "$TMP"

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

Make it executable: `chmod +x tests/integration/migrate_layout_tests.sh`.

- [ ] **Step 3: Run tests to confirm they fail**

Run: `./tests/integration/migrate_layout_tests.sh`
Expected: FAILs — `scripts/migrate-layout.sh` doesn't exist yet.

- [ ] **Step 4: Implement the migration tool**

Create `scripts/migrate-layout.sh`:

```sh
#!/bin/sh
#
# Migrate a 0.1.x /etc/zyginit/ tree to the 0.2.0 per-service directory layout.
# Idempotent. Dry-run by default. Run with --apply to actually move files.
#
set -e

usage() {
    cat <<EOF
Usage: $0 [--dry-run|--apply] [--config-dir <path>]

  --dry-run         Print planned moves and exit (default)
  --apply           Perform the moves
  --config-dir DIR  Target directory (default /etc/zyginit/)
  -h, --help        Show this help
EOF
}

MODE=dry-run
CONFIG_DIR=/etc/zyginit

while [ $# -gt 0 ]; do
    case "$1" in
        --dry-run)    MODE=dry-run; shift ;;
        --apply)      MODE=apply; shift ;;
        --config-dir) CONFIG_DIR="$2"; shift 2 ;;
        -h|--help)    usage; exit 0 ;;
        *)            echo "error: unknown arg: $1" >&2; usage >&2; exit 2 ;;
    esac
done

if [ ! -d "$CONFIG_DIR" ]; then
    echo "error: $CONFIG_DIR is not a directory" >&2
    exit 1
fi

cd "$CONFIG_DIR"

# Ambiguity check: refuse if any service has BOTH <name>.toml at top level
# AND services/<name>/service.toml.
AMBIGUOUS=""
if [ -d services ]; then
    for f in *.toml; do
        [ -f "$f" ] || continue
        name="${f%.toml}"
        if [ -f "services/$name/service.toml" ]; then
            AMBIGUOUS="$AMBIGUOUS $name"
        fi
    done
fi
if [ -n "$AMBIGUOUS" ]; then
    echo "error: ambiguous layout — both old <name>.toml and services/<name>/service.toml exist for:$AMBIGUOUS" >&2
    echo "       Resolve manually before re-running." >&2
    exit 3
fi

# Plan moves
PLAN_FILE=$(mktemp /tmp/migrate-plan.XXXXXX)
trap 'rm -f "$PLAN_FILE"' EXIT

COUNT=0
for f in *.toml; do
    [ -f "$f" ] || continue
    name="${f%.toml}"
    echo "mkdir -p services/$name" >> "$PLAN_FILE"
    echo "mv $f services/$name/service.toml" >> "$PLAN_FILE"
    if [ -d "$name" ] && [ ! -L "$name" ]; then
        # Move helper scripts (start.sh, stop.sh, etc.) into the new dir
        for sf in "$name"/*; do
            [ -e "$sf" ] || continue
            base=$(basename "$sf")
            echo "mv $name/$base services/$name/$base" >> "$PLAN_FILE"
        done
        echo "rmdir $name" >> "$PLAN_FILE"
    fi
    COUNT=$((COUNT + 1))
done

# Plan enabled.d symlink retarget
if [ -d enabled.d ]; then
    for link in enabled.d/*; do
        [ -L "$link" ] || continue
        target=$(readlink "$link")
        case "$target" in
            *.toml)
                name=$(basename "$link")
                echo "ln -sfn ../services/$name $link" >> "$PLAN_FILE"
                ;;
        esac
    done
fi

# Plan examples/ migration (same convention as services/)
if [ -d examples ]; then
    for f in examples/*.toml; do
        [ -f "$f" ] || continue
        name=$(basename "${f%.toml}")
        echo "mkdir -p examples/$name" >> "$PLAN_FILE"
        echo "mv $f examples/$name/service.toml" >> "$PLAN_FILE"
    done
fi

if [ ! -s "$PLAN_FILE" ]; then
    echo "no changes — tree already in 0.2.0 layout"
    exit 0
fi

if [ "$MODE" = "dry-run" ]; then
    echo "PLANNED MOVES ($COUNT services):"
    cat "$PLAN_FILE"
    echo
    echo "Re-run with --apply to perform these moves."
    exit 0
fi

# Apply
echo "Applying $COUNT services..."
while IFS= read -r line; do
    eval "$line"
done < "$PLAN_FILE"
echo "Migration complete."
```

Make it executable: `chmod +x scripts/migrate-layout.sh`.

- [ ] **Step 5: Run tests to confirm they pass**

Run: `./tests/integration/migrate_layout_tests.sh`
Expected: 5/5 pass.

- [ ] **Step 6: Commit**

```sh
hg add scripts/migrate-layout.sh tests/integration/migrate_layout_tests.sh tests/integration/fixtures/
hg commit -m "scripts: migrate-layout.sh + tests — converts 0.1.x flat layout to 0.2.0 per-service dirs"
```

---

## Task 2: Layout parser in config.reef

**Files:**
- Modify: `src/config.reef`

The current parser scans `<config_dir>/*.toml` and uses the basename as the service name. New behavior: scan `<config_dir>/services/<name>/service.toml`; service name comes from the directory name.

- [ ] **Step 1: Identify current scan functions**

Read `src/config.reef` and find:
- The function that scans the config directory for service TOML files (likely `scan_services`, `load_services_from_dir`, or similar)
- The function that scans `enabled.d/` and dereferences symlinks

Note their current signatures and the call sites in `src/main.reef`.

- [ ] **Step 2: Rewrite the services scanner to walk `services/`**

Replace the implementation that walks `*.toml` at top level with one that walks `services/*/service.toml`. Reef-flavored:

```reef
fn scan_services_dir(config_dir: string, table: ServiceTable): int
    let services_root = str.concat(config_dir, "/services")
    if !io.dir.exists(services_root)
        // Fatal: empty boot
        println("zyginit: FATAL: services/ directory not found at " + services_root)
        println("zyginit: run scripts/migrate-layout.sh to convert a 0.1.x tree")
        return 0
    end if
    let dirs = io.dir.list(services_root)
    let n = array_length(dirs)   // or whatever Reef's array-length idiom is
    mut count = 0
    mut i = 0
    while i < n
        let name = dirs[i]
        let svc_dir = str.concat(str.concat(services_root, "/"), name)
        let toml_path = str.concat(svc_dir, "/service.toml")
        if !io.file.exists(toml_path)
            println("zyginit: services/" + name + "/ missing service.toml — skipping")
            i = i + 1
            continue
        end if
        let result = parse_service_toml(toml_path, name, svc_dir)
        if result.ok
            add_to_table(table, result.svc)
            count = count + 1
        else
            println("zyginit: services/" + name + "/service.toml parse error: " + result.error)
        end if
        i = i + 1
    end while
    return count
end scan_services_dir
```

`parse_service_toml(toml_path, name, svc_dir)` is the existing TOML parser plumbing renamed/restructured to:
- Take the **directory** path (`svc_dir`) so it can resolve `./start.sh`-style paths to absolute (`<svc_dir>/start.sh`).
- Use **name** as the service identifier (not a field in the TOML; if the TOML has `[service].name`, ignore or warn that it's unused now).

Match the project's existing parser style; this is a refactor rather than a from-scratch rewrite.

- [ ] **Step 3: Update path resolution for `./*.sh`**

In whatever section of `parse_service_toml` builds the executable path from `exec.start`, add: if the path starts with `./`, prepend `<svc_dir>/`. Absolute paths (starting with `/`) are unchanged.

```reef
fn resolve_exec_path(raw: string, svc_dir: string): string
    if str.starts_with(raw, "./")
        let rel = str.substring(raw, 2, str.length(raw) - 2)
        return str.concat(str.concat(svc_dir, "/"), rel)
    end if
    return raw
end resolve_exec_path
```

Apply to both `exec.start` and (if present) `exec.stop`.

- [ ] **Step 4: Rewrite enabled.d scanner with symlink dereference + escape check**

Update `scan_enabled_dir` (or equivalent) to:
- List `<config_dir>/enabled.d/`
- For each entry, `readlink` it (use `os.fs.readlink` or whatever Reef stdlib provides; add a C helper `zyginit_readlink` if needed)
- Resolve to an absolute path
- Verify it points inside `<config_dir>/services/`
- Reject (with warning log) if it escapes or is dangling

```reef
fn scan_enabled_dir(config_dir: string): [string]
    let enabled_root = str.concat(config_dir, "/enabled.d")
    let services_prefix = str.concat(config_dir, "/services/")
    let result = new [string](MAX_SERVICES)
    mut count = 0
    if !io.dir.exists(enabled_root)
        return result   // no enabled.d means nothing enabled
    end if
    let entries = io.dir.list(enabled_root)
    mut i = 0
    while i < array_length(entries)
        let entry = entries[i]
        let link_path = str.concat(str.concat(enabled_root, "/"), entry)
        let target = zyginit_readlink_resolved(link_path)
        if str.length(target) == 0
            println("zyginit: enabled.d/" + entry + " is dangling — ignoring")
            i = i + 1
            continue
        end if
        if !str.starts_with(target, services_prefix)
            println("zyginit: enabled.d/" + entry + " escapes services/ — ignoring")
            i = i + 1
            continue
        end if
        result[count] = entry
        count = count + 1
        i = i + 1
    end while
    return result
end scan_enabled_dir
```

If Reef stdlib doesn't have a "resolve a symlink to an absolute canonical path" function, add a C helper:

```c
/* in src/helpers.c */
#include <stdlib.h>
#include <limits.h>
static char zyginit_readlink_buf[PATH_MAX];
char *zyginit_readlink_resolved(char *path) {
    char *resolved = realpath(path, zyginit_readlink_buf);
    return resolved ? resolved : (char*)"";
}
```

And extern declare in config.reef:

```reef
extern "C" fn zyginit_readlink_resolved(path: string): string
```

- [ ] **Step 5: Build + verify**

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

Existing tests will FAIL at this point because the in-repo `services/hammerhead/` tree is still in the old layout. That's expected — Task 3 migrates it.

- [ ] **Step 6: Commit**

```sh
hg commit -m "config: scan services/<name>/service.toml; enabled.d symlinks target directories"
```

---

## Task 3: Migrate in-repo `services/hammerhead/` and `examples/`

**Files:**
- Modify: `services/hammerhead/*` (extensive)
- Modify: `examples/*` (if present)
- Modify: any test fixtures referencing the old layout

- [ ] **Step 1: Dry-run the migration on the in-repo tree**

```sh
./scripts/migrate-layout.sh --dry-run --config-dir services/hammerhead/
```

Verify the planned moves match the spec's per-service-directory layout. If the output looks right, proceed.

- [ ] **Step 2: Apply the migration**

```sh
./scripts/migrate-layout.sh --apply --config-dir services/hammerhead/
```

Verify each service is now `services/hammerhead/services/<name>/service.toml` plus any helper scripts in the same directory. The `services/hammerhead/services/<name>` nesting is intentional — `services/hammerhead/` is the deploy-root, and the per-service layout starts inside it.

- [ ] **Step 3: Migrate the examples directory if present**

```sh
ls examples/ 2>/dev/null && ./scripts/migrate-layout.sh --apply --config-dir examples/ || echo "no examples/ at top level"
```

If examples live under `services/hammerhead/examples/` instead, migrate that location.

- [ ] **Step 4: Update integration test fixtures**

In `tests/integration/run_tests.sh`, the harness creates temporary services at runtime. Find the section that writes `<TMP>/<name>.toml` and update it to write `<TMP>/services/<name>/service.toml`. Use a small helper at the top of the test script:

```sh
create_service() {
    name=$1
    toml_content=$2
    mkdir -p "$CONFIG_DIR/services/$name"
    printf '%s\n' "$toml_content" > "$CONFIG_DIR/services/$name/service.toml"
}

enable_service() {
    name=$1
    ln -sfn "../services/$name" "$ENABLED_DIR/$name"
}
```

Then update every existing test that calls `cat > "$CONFIG_DIR/<name>.toml"` to use `create_service` and every `ln -s` in `enabled.d/` to use `enable_service`.

- [ ] **Step 5: Build + run all tests**

```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
./tests/integration/ui_tests.sh
./tests/integration/migrate_layout_tests.sh
```

All should pass. If integration tests fail, the most likely cause is a missed test-fixture update — grep for `\.toml` in `tests/integration/run_tests.sh` to find any stragglers.

- [ ] **Step 6: Commit**

```sh
hg addremove   # picks up the moves
hg commit -m "services+tests: migrate in-repo trees to 0.2.0 per-service layout"
```

---

## Task 4: Update `zygctl enable`/`disable` symlink targets

**Files:**
- Modify: `tools/zygctl/src/main.reef` (and possibly `src/socket.reef` for the server-side equivalent if zygctl delegates)

- [ ] **Step 1: Find current enable/disable logic**

In `tools/zygctl/src/main.reef`, find the function that handles `zygctl enable <name>`. Currently it creates a symlink `<config_dir>/enabled.d/<name>` → `../<name>.toml`.

- [ ] **Step 2: Add a failing integration test**

In `tests/integration/run_tests.sh`, add (in the appropriate test suite around enable/disable):

```sh
echo "TEST: zygctl enable creates directory-target symlink"
create_service test-svc 'foo'
$ZYGCTL enable test-svc
target=$(readlink "$ENABLED_DIR/test-svc")
case "$target" in
    *services/test-svc) pass "zygctl enable targets directory" ;;
    *)                  fail "zygctl enable targets directory" "got: $target" ;;
esac
$ZYGCTL disable test-svc
```

(You'll need to wire `pass`/`fail` into the existing harness shape, or use whatever pattern the surrounding tests use.)

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

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

Expected: the new test fails (current zygctl creates a `<name>.toml` symlink, not a directory).

- [ ] **Step 4: Update the symlink target**

In `tools/zygctl/src/main.reef`, change the symlink-create call:

```reef
// Old:
let target = str.concat("../", str.concat(name, ".toml"))

// New:
let target = str.concat("../services/", name)
```

The link path stays the same (`enabled.d/<name>`).

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

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

Test should now pass.

- [ ] **Step 6: Commit**

```sh
hg commit -m "zygctl: enable/disable target services/<name>/ directory instead of <name>.toml"
```

---

## Task 5: Rename `TRANSIENT` → `TASK` + supervisor branch

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

- [ ] **Step 1: Add a failing test for task semantics**

Add to `tests/integration/run_tests.sh` (in a new test suite or appropriate existing one):

```sh
echo "=== Task type semantics ==="

create_service task-success "$(cat <<'EOF'
[service]
type = "task"

[exec]
start = "/bin/true"
EOF
)"
enable_service task-success

create_service task-failure "$(cat <<'EOF'
[service]
type = "task"

[exec]
start = "/bin/false"
EOF
)"
enable_service task-failure

start_daemon
sleep 2

out=$($ZYGCTL status)
echo "$out" | grep -q "task-success.*stopped" && \
    pass "task exit 0 = stopped" || \
    fail "task exit 0 = stopped" "got: $(echo "$out" | grep task-success)"

echo "$out" | grep -q "task-failure.*failed" && \
    pass "task exit !=0 = failed" || \
    fail "task exit !=0 = failed" "got: $(echo "$out" | grep task-failure)"

# Verify no restart attempt — restart_count should be 0
echo "$out" | grep -E "task-failure.*restarts=" && \
    fail "task does not restart" "restarts field present" || \
    pass "task does not restart"

stop_daemon
```

(Use whatever start_daemon / stop_daemon helpers the existing tests use.)

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

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

Expected: the task-related assertions fail (parser rejects `type = "task"` or the supervisor doesn't handle it).

- [ ] **Step 3: Rename in `src/config.reef`**

```sh
hg cat -r tip src/config.reef | grep -n TRANSIENT
```

Find every occurrence of `SERVICE_TYPE_TRANSIENT` and `"transient"` in `src/config.reef`. Replace:
- `SERVICE_TYPE_TRANSIENT` → `SERVICE_TYPE_TASK` (function name + every call site)
- `"transient"` string literal in `service_type_name` and the parser → `"task"`

In the parser:

```reef
// Old:
if type_str == "transient"
    return SERVICE_TYPE_TRANSIENT()
end if

// New:
if type_str == "task"
    return SERVICE_TYPE_TASK()
end if
```

For `type = "transient"` in TOML: reject with a clear error message:

```reef
if type_str == "transient"
    println("error: type='transient' is no longer supported — rename to 'task' in your TOML")
    return SERVICE_TYPE_DAEMON()   // safe fallback to keep parser going
end if
```

- [ ] **Step 4: Add the supervisor task branch**

In `src/supervisor.reef`, find `handle_contract_event` and the point right after the exit-code resolution block (around line 598 in 0.1.8). Insert:

```reef
let svc_type = config.svc_type(def)

// Task: may run for any duration. Exit 0 = STOPPED (counts as online).
// Exit ≠ 0 = FAILED. Never restarts. Skips daemonize handshake.
if svc_type == config.SERVICE_TYPE_TASK()
    let dur_ms = (time.time_now() - rt.last_start_time) * 1000
    let ec = if exit_code >= 0 then exit_code else 0 end if
    rt.pid = 0 - 1
    rt.contract_id = 0 - 1
    rt.last_exit_code = ec
    table.contract_map.remove(int_to_str(contract_id))
    if ec == 0
        rt.state = STATE_STOPPED()
        table.runtimes[idx] = rt
        ui.ui_event_stopped(name, dur_ms)
    else
        rt.state = STATE_FAILED()
        table.runtimes[idx] = rt
        ui.ui_event_failed(name, ec, dur_ms)
    end if
    return
end if
```

This **must** be placed BEFORE the existing daemonize-handshake check (the `if svc_type == config.SERVICE_TYPE_DAEMON() and hint_exit_code >= 0 and exit_code == 0 ...` block).

- [ ] **Step 5: Build + run all tests**

```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
./tests/integration/ui_tests.sh
```

All should pass including the new task tests.

- [ ] **Step 6: Commit**

```sh
hg commit -m "config+supervisor: rename TRANSIENT to TASK with real semantics (exit 0 = stopped, exit != 0 = failed, no restart)"
```

---

## Task 6: `[condition]` schema and `evaluate_conditions`

**Files:**
- Create: `src/condition.reef`
- Modify: `src/config.reef` (parser additions)

- [ ] **Step 1: Define the ConditionResult type and entry signature**

Create `src/condition.reef`. Copy the SRCHEADER from existing modules; fill in:
- Project: `zyginit`
- Filename: `condition.reef`
- Description: `Evaluate [condition] blocks: exists_file, exists_file_any, command. Returns pass/fail + reason.`

Skeleton:

```reef
module condition

import core.str as str
import io.file as file
import sys.process as process

export
    type ConditionResult
    fn  evaluate_conditions(def: config.ServiceDef): ConditionResult
    fn  cond_passed(r: ConditionResult): bool
    fn  cond_reason(r: ConditionResult): string
end export

type ConditionResult = struct
    passed: bool
    reason: string
end ConditionResult

fn cond_passed(r: ConditionResult): bool
    return r.passed
end cond_passed

fn cond_reason(r: ConditionResult): string
    return r.reason
end cond_reason
```

- [ ] **Step 2: Add the parser for `[condition]` to config.reef**

In `src/config.reef`, extend `ServiceDef` with optional fields:

```reef
type ServiceDef = struct
    // ... existing fields ...
    cond_exists_file: string         // empty if not set
    cond_exists_file_any: [string]
    cond_exists_file_any_count: int  // companion length field
    cond_command: string             // empty if not set
end ServiceDef
```

In the TOML parser, find the section that walks parsed keys after `[exec]` and `[stop]` etc. Add a handler for `[condition]`:

```reef
// Inside the parse loop, when section == "condition":
if key == "exists_file"
    def.cond_exists_file = value_str
elif key == "exists_file_any"
    let arr = parse_string_array(value)
    def.cond_exists_file_any = arr
    def.cond_exists_file_any_count = array_length(arr)
elif key == "command"
    def.cond_command = value_str
else
    println("warning: unknown [condition] key: " + key)
end if
```

Match the existing TOML-parser shape; the project already has similar handlers for `[exec]`, `[restart]`, etc.

- [ ] **Step 3: Add accessor fns to ServiceDef**

In `src/config.reef`:

```reef
fn svc_cond_exists_file(svc: ServiceDef): string
    return svc.cond_exists_file
end svc_cond_exists_file

fn svc_cond_exists_file_any(svc: ServiceDef): [string]
    return svc.cond_exists_file_any
end svc_cond_exists_file_any

fn svc_cond_exists_file_any_count(svc: ServiceDef): int
    return svc.cond_exists_file_any_count
end svc_cond_exists_file_any_count

fn svc_cond_command(svc: ServiceDef): string
    return svc.cond_command
end svc_cond_command
```

Export each.

- [ ] **Step 4: Implement `evaluate_conditions` in condition.reef**

```reef
fn evaluate_conditions(def: config.ServiceDef): ConditionResult
    // exists_file
    let f = config.svc_cond_exists_file(def)
    if str.length(f) > 0
        if !file.exists(f)
            return ConditionResult{
                passed: false,
                reason: str.concat("missing ", f)
            }
        end if
    end if

    // exists_file_any
    let n = config.svc_cond_exists_file_any_count(def)
    if n > 0
        let paths = config.svc_cond_exists_file_any(def)
        mut found = false
        mut i = 0
        while i < n
            if file.exists(paths[i])
                found = true
            end if
            i = i + 1
        end while
        if !found
            return ConditionResult{
                passed: false,
                reason: "none of exists_file_any paths exist"
            }
        end if
    end if

    // command (with 5-second timeout)
    let cmd = config.svc_cond_command(def)
    if str.length(cmd) > 0
        let rc = run_command_with_timeout(cmd, 5)
        if rc != 0
            if rc == TIMEOUT_RC()
                return ConditionResult{ passed: false, reason: "command timeout" }
            elif rc == NOT_FOUND_RC()
                return ConditionResult{ passed: false, reason: str.concat("command not found: ", cmd) }
            elif rc == EXEC_FAILED_RC()
                return ConditionResult{ passed: false, reason: "command exec failed" }
            else
                return ConditionResult{
                    passed: false,
                    reason: str.concat("command exit=", int_to_str(rc))
                }
            end if
        end if
    end if

    return ConditionResult{ passed: true, reason: "" }
end evaluate_conditions

fn TIMEOUT_RC(): int     return 0 - 2 end TIMEOUT_RC
fn NOT_FOUND_RC(): int   return 0 - 3 end NOT_FOUND_RC
fn EXEC_FAILED_RC(): int return 0 - 4 end EXEC_FAILED_RC

// Runs command via a new C helper that handles fork/exec/timeout/wait.
// Returns: 0 on success, command's exit code, TIMEOUT_RC if exceeded,
// NOT_FOUND_RC if no such binary, EXEC_FAILED_RC on other exec error.
extern "C" fn run_command_with_timeout(cmd: string, timeout_sec: int): int
```

- [ ] **Step 5: Add the C helper `run_command_with_timeout`**

In `src/helpers.c`, append:

```c
#include <sys/wait.h>
#include <signal.h>
#include <errno.h>
/* Run a command with a timeout in seconds. Returns:
 *   command's exit code on normal completion (0 = success),
 *   -2 if timeout exceeded (child killed),
 *   -3 if command binary not found,
 *   -4 on other exec failure.
 * The cmd string is run via /bin/sh -c so $PATH lookup works. */
int run_command_with_timeout(char *cmd, int timeout_sec) {
    pid_t pid = fork();
    if (pid < 0) return -4;
    if (pid == 0) {
        /* child */
        execl("/bin/sh", "sh", "-c", cmd, (char *)0);
        _exit(127);   /* exec failed */
    }
    /* parent */
    time_t deadline = time(0) + timeout_sec;
    int status;
    for (;;) {
        pid_t r = waitpid(pid, &status, WNOHANG);
        if (r == pid) {
            if (WIFEXITED(status)) {
                int ec = WEXITSTATUS(status);
                if (ec == 127) return -3;
                return ec;
            }
            return -4;   /* signaled */
        }
        if (r < 0) return -4;
        if (time(0) >= deadline) {
            kill(pid, SIGKILL);
            waitpid(pid, &status, 0);
            return -2;
        }
        struct timespec ts = { 0, 50 * 1000 * 1000 };   /* 50ms */
        nanosleep(&ts, 0);
    }
}
```

- [ ] **Step 6: Build + unit-test via --ui-demo or direct config-parse test**

Build:

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

Since `evaluate_conditions` is a pure function, the easiest test is to wire a quick demo scenario in `ui_demo` (or add a small assertion in the integration harness). For now, just confirm the build succeeds. Real coverage comes in Task 8.

- [ ] **Step 7: Commit**

```sh
hg add src/condition.reef
hg commit -m "condition: [condition] block schema + evaluate_conditions (exists_file, exists_file_any, command with timeout)"
```

---

## Task 7: STATE_SKIPPED + `ui_event_skipped` + wire into start_service

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

- [ ] **Step 1: Add STATE_SKIPPED constant + skip_reason field**

In `src/supervisor.reef`:

```reef
fn STATE_SKIPPED(): int return 7 end STATE_SKIPPED   // pick an unused state value
```

Add it to the state-name mapping function (the one that returns strings for states).

Extend `ServiceRuntime` struct with:

```reef
skip_reason: string
```

Initialize to `""` in the existing constructor / table init logic.

- [ ] **Step 2: Add `ui_event_skipped` to ui.reef**

In `src/ui.reef`, alongside other lifecycle procs:

```reef
mut g_skipped: int = 0   // declared next to g_done/g_failed_count

proc ui_event_skipped(name: string, reason: string)
    let elapsed = zyginit_monotonic_ms() - g_boot_start_ms
    g_skipped = g_skipped + 1
    let note = str.concat("skipped: ", reason)
    tape_push(elapsed, paint("mute", glyph_pending()), name, note, 0)
    if g_mode == MODE_PLAIN()
        let kv = str.concat("reason=", reason)
        println(fmt_plain_event(elapsed, "info", "skipped", name, kv))
    else
        emit_redraw(elapsed)
    end if
end ui_event_skipped
```

Export `ui_event_skipped` and `g_skipped` access if needed.

Also reset `g_skipped = 0` in `ui_boot_start` and `ui_shutdown_start` alongside the other resets.

- [ ] **Step 3: Wire condition evaluation into start_service**

In `src/supervisor.reef`, find the top of `start_service`. After the initial state read but BEFORE the fork, insert:

```reef
let cond_result = condition.evaluate_conditions(def)
if condition.cond_passed(cond_result) == false
    rt.state = STATE_SKIPPED()
    rt.skip_reason = condition.cond_reason(cond_result)
    table.runtimes[idx] = rt
    ui.ui_event_skipped(name, rt.skip_reason)
    return true   // not an error from the caller's perspective
end if
```

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

- [ ] **Step 4: Build**

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

- [ ] **Step 5: Add integration tests for SKIPPED**

In `tests/integration/run_tests.sh`:

```sh
echo "=== [condition] block — SKIPPED state ==="

create_service skip-missing-file "$(cat <<'EOF'
[service]
type = "daemon"

[exec]
start = "/bin/sleep 1000"

[condition]
exists_file = "/this/path/definitely/does/not/exist"
EOF
)"
enable_service skip-missing-file

create_service skip-cmd-fail "$(cat <<'EOF'
[service]
type = "daemon"

[exec]
start = "/bin/sleep 1000"

[condition]
command = "/bin/false"
EOF
)"
enable_service skip-cmd-fail

start_daemon
sleep 2

out=$($ZYGCTL status)
echo "$out" | grep -q "skip-missing-file.*skipped" && \
    pass "missing file → skipped" || \
    fail "missing file → skipped" "got: $(echo "$out" | grep skip-missing-file)"

echo "$out" | grep -q "skip-cmd-fail.*skipped" && \
    pass "command exit non-zero → skipped" || \
    fail "command exit non-zero → skipped" "got: $(echo "$out" | grep skip-cmd-fail)"

# Reason text appears in NOTES
echo "$out" | grep -q "skip-missing-file.*missing" && \
    pass "reason text in status" || \
    fail "reason text in status" "got: $(echo "$out" | grep skip-missing-file)"

stop_daemon
```

- [ ] **Step 6: Run + verify**

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

Some may still fail (because the UI render for SKIPPED isn't in ui_render.reef yet — that's Task 8). The supervisor-level tests should pass.

- [ ] **Step 7: Commit**

```sh
hg commit -m "supervisor+ui: STATE_SKIPPED + skip_reason + ui_event_skipped — conditions evaluated before fork"
```

---

## Task 8: UI integration — banner, progress bar, final card, zygctl status

**Files:**
- Modify: `src/ui.reef`
- Modify: `src/ui_render.reef`

- [ ] **Step 1: Update banner to show skipped count**

In `src/ui.reef`, find `fmt_header`. The summary line currently reads:

```
23 services · tier 5 · 12 done · 1 failed
```

Modify to include skipped:

```reef
out = str.concat(out, paint("frame",
    str.concat(str.concat(int_to_str(g_num_svc), " services · tier "),
    str.concat(str.concat(int_to_str(g_cur_tier), " · "),
    str.concat(str.concat(int_to_str(g_done), " done · "),
    str.concat(str.concat(int_to_str(g_failed_count), " failed · "),
    str.concat(int_to_str(g_skipped), " skipped")))))))
```

- [ ] **Step 2: Update progress bar denominator**

In `src/ui.reef`, find `render_boot_screen`. The progress bar call is:

```reef
out = str.concat(out, fmt_progress_bar(g_done, g_num_svc))
```

`g_done` already counts STARTED + FAILED. Add `g_skipped`:

```reef
out = str.concat(out, fmt_progress_bar(g_done + g_skipped, g_num_svc))
```

- [ ] **Step 3: Update final card summary line**

In `src/ui.reef`, find `ui_boot_complete`. The summary line currently:

```reef
"17 services — 16 online, 1 failed"
```

Modify the BootStats struct (in ui.reef) to include `skipped: int`. Update the line to:

```reef
out = str.concat(out, "  ")
out = str.concat(out, paint("frame",
    str.concat(str.concat(summary_count, " services — "),
    str.concat(str.concat(int_to_str(stats.online), " online, "),
    str.concat(str.concat(int_to_str(stats.failed), " failed, "),
    str.concat(int_to_str(stats.skipped), " skipped"))))))
```

- [ ] **Step 4: Update build_boot_stats in main.reef**

In `src/main.reef`, find `build_boot_stats`. Add a SKIPPED counter:

```reef
fn build_boot_stats(table: supervisor.ServiceTable, boot_elapsed_ms: int): ui.BootStats
    let count = supervisor.service_count(table)
    mut online = 0
    mut failed = 0
    mut skipped = 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  end if
        if st == supervisor.STATE_STOPPED()
            if supervisor.rt_last_exit_code(rt) == 0  online = online + 1  end if
        end if
        if st == supervisor.STATE_FAILED() or st == supervisor.STATE_MAINTENANCE()
            failed = failed + 1
        end if
        if st == supervisor.STATE_SKIPPED()  skipped = skipped + 1  end if
        i = i + 1
    end while
    let slow = new [string](3)
    return ui.BootStats{
        elapsed_ms: boot_elapsed_ms,
        online: online,
        failed: failed,
        skipped: skipped,
        slowest: slow,
        slowest_count: 0
    }
end build_boot_stats
```

- [ ] **Step 5: Update ui_render.reef state mapping**

In `src/ui_render.reef`, find `state_color`, `state_glyph`, `state_label`. Add SKIPPED:

```reef
// In state_color:
if state == supervisor.STATE_SKIPPED() return "mute" end if

// In state_glyph:
if state == supervisor.STATE_SKIPPED() return glyph_pending() end if

// In state_label:
if state == supervisor.STATE_SKIPPED() return " skipped" end if
```

In `fmt_status_row`, ensure the NOTES column picks up `rt.skip_reason` when state is SKIPPED. Add right above the `last_exit > 0` block:

```reef
if state == supervisor.STATE_SKIPPED()
    let reason = supervisor.rt_skip_reason(rt)
    if str.length(reason) > 0
        notes = reason
    end if
end if
```

(`supervisor.rt_skip_reason` is a new accessor — add it next to the other `rt_*` accessors and export it.)

Update the banner in `ui_render_status` to show `skipped` count alongside online/failed. Same shape as boot banner.

- [ ] **Step 6: Build + run all tests**

```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
./tests/integration/ui_tests.sh
./tests/integration/migrate_layout_tests.sh
```

All should pass. Existing snapshot tests may regenerate because the banner now has an extra ` · 0 skipped` segment. Regenerate them deliberately:

```sh
rm tests/integration/snapshots/{boot_tape_*,shutdown_*,final_card_*}.txt
./tests/integration/ui_tests.sh
hg diff tests/integration/snapshots/ | head -40   # confirm only banner diffs
```

If diffs look right (only the new ` · N skipped` text and the progress bar denominator), commit them.

- [ ] **Step 7: Commit**

```sh
hg commit -m "ui+ui_render: g_skipped counter, banner, progress denominator, final card, status table"
```

---

## Task 9: New UI snapshot scenarios

**Files:**
- Modify: `src/ui.reef` (demo scenarios)
- Modify: `tests/integration/ui_tests.sh`

- [ ] **Step 1: Add the 5 new check_scenario lines**

In `tests/integration/ui_tests.sh`:

```sh
check_scenario task_ok                "ZYGINIT_FORCE_80x25=1 TERM=xterm-256color"
check_scenario task_failed            "ZYGINIT_FORCE_80x25=1 TERM=xterm-256color"
check_scenario boot_with_skipped      "ZYGINIT_FORCE_80x25=1 TERM=xterm-256color"
check_scenario final_card_with_skipped "ZYGINIT_FORCE_80x25=1 TERM=xterm-256color"
check_scenario status_skipped         "ZYGINIT_FORCE_80x25=1 TERM=xterm-256color"
```

- [ ] **Step 2: Run tests — confirm 5 FAILs**

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

Expected: 5 unknown scenarios.

- [ ] **Step 3: Implement the demo scenarios in ui.reef's ui_demo**

```reef
    if scenario == "task_ok"
        g_mode = detect_rich_mode()
        ui_boot_start(5, 2, "multi-user")
        g_boot_start_ms = 0
        tape_push(40,  paint("ok", glyph_ok()), "root-fs",  "", 0)
        tape_push(160, paint("ok", glyph_ok()), "task-svc", "", 0)
        g_done = 2
        let stats = BootStats{
            elapsed_ms: 200, online: 5, failed: 0, skipped: 0,
            slowest: new [string](3), slowest_count: 0
        }
        ui_boot_complete(stats)
        return 0
    end if

    if scenario == "task_failed"
        g_mode = detect_rich_mode()
        ui_boot_start(5, 2, "multi-user")
        g_boot_start_ms = 0
        tape_push(40,  paint("ok",   glyph_ok()),   "root-fs",  "", 0)
        tape_push(160, paint("fail", glyph_fail()), "task-svc", "exit=1", 0)
        g_done = 2
        g_failed_count = 1
        g_failed_first = "task-svc"
        g_failed_first_exit = 1
        let stats = BootStats{
            elapsed_ms: 200, online: 4, failed: 1, skipped: 0,
            slowest: new [string](3), slowest_count: 0
        }
        ui_boot_complete(stats)
        return 0
    end if

    if scenario == "boot_with_skipped"
        g_mode = detect_rich_mode()
        ui_boot_start(5, 2, "multi-user")
        g_boot_start_ms = 0
        tape_push(40,  paint("ok",   glyph_ok()),      "root-fs", "", 0)
        tape_push(160, paint("mute", glyph_pending()), "acpihpd", "skipped: missing /dev/acpihp", 0)
        tape_push(310, paint("ok",   glyph_ok()),      "cron",    "", 0)
        g_done = 2
        g_skipped = 1
        // Render the screen mid-boot (not the final card)
        print(str.concat(str.concat(zyginit_esc_str(), "[2J"),
              str.concat(zyginit_esc_str(), "[H")))
        print(render_boot_screen(400))
        return 0
    end if

    if scenario == "final_card_with_skipped"
        g_mode = detect_rich_mode()
        ui_boot_start(23, 9, "multi-user")
        let stats = BootStats{
            elapsed_ms: 8300, online: 19, failed: 0, skipped: 4,
            slowest: new [string](3), slowest_count: 0
        }
        ui_boot_complete(stats)
        return 0
    end if

    if scenario == "status_skipped"
        // Renders just the zygctl status banner + table with one skipped row.
        // Use the same fixture pattern as the existing status scenarios.
        // (Implementation depends on how existing status demos are structured —
        // mirror that shape and inject one row with state=SKIPPED.)
        g_mode = detect_rich_mode()
        // ... see existing status snapshot demos for the pattern ...
        return 0
    end if
```

For the `status_skipped` scenario specifically: look at how the existing status snapshots (if any) are generated. If there isn't a `--ui-demo` mode for the status table yet, this scenario may need to build a small `ServiceTable` mock — defer this specific scenario to a later iteration if it requires significant scaffolding. Document it as a known gap in the task report.

- [ ] **Step 4: Build, run, inspect snapshots**

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

Inspect each new snapshot:

```sh
cat tests/integration/snapshots/task_ok.txt
cat tests/integration/snapshots/boot_with_skipped.txt
# etc.
```

Verify the visual shape matches the spec mockups in §7.

- [ ] **Step 5: Commit**

```sh
hg add tests/integration/snapshots/task_*.txt tests/integration/snapshots/boot_with_skipped.txt tests/integration/snapshots/final_card_with_skipped.txt tests/integration/snapshots/status_skipped.txt
hg commit -m "ui-tests: 5 new snapshot scenarios — task ok/failed, boot/final with skipped, status skipped"
```

---

## Task 10: Version bump + release

**Files:**
- Modify: `reef.toml`, `src/version.reef`, `tools/zygctl/reef.toml`, `tools/zygctl/src/version.reef`, `tools/sysv-wrapper/version.h` (via bump-version.sh)

- [ ] **Step 1: Bump version 0.1.8 → 0.2.0**

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

Confirm 5 files changed.

- [ ] **Step 2: Regenerate version-stamped snapshots**

```sh
grep -l "0\.1\.8" tests/integration/snapshots/*.txt | xargs rm 2>/dev/null
```

- [ ] **Step 3: Rebuild + full test pass**

```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/ui_tests.sh         # regenerates and validates
./tests/integration/run_tests.sh
./tests/integration/migrate_layout_tests.sh
```

All must pass.

- [ ] **Step 4: Commit + tag + release tarball**

```sh
hg commit -m "release: bump to 0.2.0 (per-service layout + task type + [condition] block)"
hg tag v0.2.0
./scripts/make-release.sh
cp releases/zyginit-0.2.0-source.tar.xz releases/zyginit-0.2.0-source.tar.xz.sha256 ~/reef-releases/
cat ~/reef-releases/zyginit-0.2.0-source.tar.xz.sha256
hg log -l 5 --template '{rev}: {desc|firstline}\n'
```

- [ ] **Step 5: Update release notes**

Append to (or create) `docs/RELEASE_NOTES.md`:

```markdown
## 0.2.0 — 2026-05-14

**Breaking change.** Filesystem layout for `/etc/zyginit/` is restructured.
Operators upgrading from 0.1.x MUST run the migration tool before installing
0.2.0 binaries:

```
/path/to/scripts/migrate-layout.sh --dry-run    # preview
/path/to/scripts/migrate-layout.sh --apply      # apply
```

The tool is idempotent and refuses to proceed on ambiguous starting state.

### New: per-service directory layout

Each service is now self-contained:

```
/etc/zyginit/services/<name>/
├── service.toml
├── start.sh
└── stop.sh
```

`enabled.d/<name>` symlinks target `../services/<name>/` (the directory).

### New: `type = "task"` service type

For services that may run for a while but are expected to eventually exit
cleanly (e.g., platform probes, one-time-but-slow setup tasks). Exit 0 =
STOPPED (counts as online), exit ≠ 0 = FAILED, never restarts. Replaces
the previously-unused `type = "transient"`.

### New: `[condition]` block

Declarative pre-flight gating. Supports `exists_file`, `exists_file_any`,
`command` (with 5-second timeout). Services with failing conditions enter
the new SKIPPED state — no failure, no restart loop.

```toml
[condition]
exists_file = "/dev/acpihp"
```

### Hammerhead vendoring

The Hammerhead team runs the migration tool against their override tree
in `base/usr/src/zyginit/overrides/` as part of consuming this drop.
```

- [ ] **Step 6: Commit release notes**

```sh
hg add docs/RELEASE_NOTES.md   # if newly created
hg commit -m "docs: release notes for 0.2.0"
```

---

## Self-review notes

- **Spec coverage:** every spec section has at least one task. §4 layout → Tasks 2+3+4. §5 task type → Task 5. §6 condition block → Tasks 6+7. §7 UI → Task 8. §8 migration tool → Task 1. §9 data flow → covered across Tasks 2/5/7. §10 error handling → covered inline in each task. §11 testing → Tasks 1/3/5/7/8/9. §12 phased delivery → Tasks 1–10 maps roughly to the spec's Phase 1–7.
- **No placeholders:** every step has concrete file paths, code blocks, and shell commands. Task 9 step 3 acknowledges that `status_skipped` may need scaffolding that doesn't yet exist; the implementer should document it as a deferral if needed rather than guessing.
- **Type consistency:** `ConditionResult` defined in Task 6 step 1, used in Task 7. `BootStats.skipped: int` added in Task 8 step 3, used in build_boot_stats step 4. `STATE_SKIPPED()` defined Task 7 step 1, mapped in Task 8 step 5. `rt_skip_reason` accessor introduced Task 8 step 5; field added to ServiceRuntime Task 7 step 1.
- **Tests-first:** every task begins with a failing test (snapshot or integration).
- **Frequent commits:** every task ends with one `hg commit`.
- **YAGNI:** no extra condition kinds, no transitional layout reader, no `[assert]` block, no auto-migrate at boot.
- **DRY:** `evaluate_conditions` factored once; banner skipped counter added once and consumed by `fmt_header`, `ui_render_status`, and `ui_boot_complete`.
