|
root / docs / superpowers / plans / 2026-05-14-zyginit-0.2.0.md
2026-05-14-zyginit-0.2.0.md markdown 1535 lines 47.8 KB

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_TRANSIENTSERVICE_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.reefenable/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

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:

#!/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:

#!/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
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:

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.

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
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:

/* 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:

extern "C" fn zyginit_readlink_resolved(path: string): string
  • Step 5: Build + verify
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
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

./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
./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
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:

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
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
hg addremove   # picks up the moves
hg commit -m "services+tests: migrate in-repo trees to 0.2.0 per-service layout"

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):

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
./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:

// 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
(cd tools/zygctl && reefc build --obj build/symlink_wrapper.o)
./tests/integration/run_tests.sh

Test should now pass.

  • Step 6: Commit
hg commit -m "zygctl: enable/disable target services/<name>/ directory instead of <name>.toml"

Task 5: Rename TRANSIENTTASK + 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):

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
./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
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_TRANSIENTSERVICE_TYPE_TASK (function name + every call site)
  • "transient" string literal in service_type_name and the parser → "task"

In the parser:

// 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:

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:

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
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
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:

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:

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]:

// 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:

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
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:

#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:

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
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:

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:

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:

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:

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
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:

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
./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
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:

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:

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

g_done already counts STARTED + FAILED. Add g_skipped:

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:

"17 services — 16 online, 1 failed"

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

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:

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:

// 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:

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
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:

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
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:

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

Expected: 5 unknown scenarios.

  • Step 3: Implement the demo scenarios in ui.reef's ui_demo
    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
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
./tests/integration/ui_tests.sh

Inspect each new snapshot:

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
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

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

Confirm 5 files changed.

  • Step 2: Regenerate version-stamped snapshots
grep -l "0\.1\.8" tests/integration/snapshots/*.txt | xargs rm 2>/dev/null
  • Step 3: Rebuild + full test pass
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
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:

## 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//
├── 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.