diff --git a/.gitignore b/.gitignore index 6b74f38..b4bb73d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ data/*.sqlite-shm data/*.sqlite-wal data/raw/ *.log +macos/BraiinsRatchet/.build/ diff --git a/README.md b/README.md index d3084cc..4f28bcf 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,21 @@ If the cockpit is in cooldown and you want the app to wait until the earliest ne It prints the exact monitor-only plan and asks `yes/no` before doing anything. +For the durable forever lifecycle supervisor: + +```bash +./scripts/ratchet supervise +``` + +It persists lifecycle state in `data/ratchet.sqlite`. If the process crashes or the Mac reboots, start the same command again and it resumes from SQLite. + +For the native macOS SwiftUI shell: + +```bash +cd macos/BraiinsRatchet +swift run BraiinsRatchetMac +``` + For a 6-hour monitoring session: ```bash diff --git a/START_HERE.md b/START_HERE.md index b83fdbf..d1633b7 100644 --- a/START_HERE.md +++ b/START_HERE.md @@ -73,6 +73,48 @@ It only runs after you type `yes`. It is still monitor-only. It never places, changes, or cancels Braiins orders. +## Forever Supervisor + +For the full autoresearch lifecycle, run: + +```bash +./scripts/ratchet supervise +``` + +The supervisor is the long-running engine. It: + +1. Loads persisted state from `data/ratchet.sqlite`. +2. Waits through cooldown if cooldown is active. +3. Runs the next passive watch when due. +4. Writes reports and lifecycle events. +5. Re-enters cooldown. +6. Repeats until you stop it. + +If it crashes or the Mac reboots, start the same command again. It resumes from SQLite. + +Use this to inspect persisted state without starting the loop: + +```bash +./scripts/ratchet supervise --status +``` + +## Native Mac App + +The native SwiftUI shell is in: + +```text +macos/BraiinsRatchet +``` + +Run it from source: + +```bash +cd macos/BraiinsRatchet +swift run BraiinsRatchetMac +``` + +The app is a native cockpit over the same durable Python lifecycle engine. + ## Research Pathway The cockpit has two different time horizons: diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 96fa625..997fe38 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -46,6 +46,22 @@ If approved, it waits until the earliest next action time, runs one fresh monito PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli pipeline ``` +## `supervise` + +Runs the durable forever lifecycle supervisor. + +```bash +PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli supervise +``` + +Status only: + +```bash +PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli supervise --status +``` + +Crash/reboot contract: start `./scripts/ratchet supervise` again and it resumes from persisted SQLite lifecycle state. + ## `init-db` Creates `data/ratchet.sqlite` if it does not exist. diff --git a/docs/RATCHET_OPERATIONS.md b/docs/RATCHET_OPERATIONS.md index 4ddb8fb..1034883 100644 --- a/docs/RATCHET_OPERATIONS.md +++ b/docs/RATCHET_OPERATIONS.md @@ -58,6 +58,24 @@ During cooldown, the cockpit prints a progress bar, remaining minutes, and the e The pipeline prints its plan and asks for `yes` or `no` before doing anything. It never places Braiins orders. +## Forever Lifecycle + +For unattended monitor-only autoresearch: + +```bash +./scripts/ratchet supervise +``` + +The supervisor is stateful. It writes lifecycle state and events into `data/ratchet.sqlite`, so a restart can continue from the current phase instead of starting over. + +Check the persisted lifecycle state: + +```bash +./scripts/ratchet supervise --status +``` + +This is still monitor-only. Manual Braiins bids remain outside the app unless separately recorded. + If a run already happened before automatic bookkeeping was available, reconstruct it from the stored SQLite snapshots: ```bash diff --git a/macos/BraiinsRatchet/Package.swift b/macos/BraiinsRatchet/Package.swift new file mode 100644 index 0000000..378022e --- /dev/null +++ b/macos/BraiinsRatchet/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "BraiinsRatchetMac", + platforms: [ + .macOS(.v15) + ], + products: [ + .executable(name: "BraiinsRatchetMac", targets: ["BraiinsRatchetMac"]) + ], + targets: [ + .executableTarget( + name: "BraiinsRatchetMac" + ) + ] +) diff --git a/macos/BraiinsRatchet/README.md b/macos/BraiinsRatchet/README.md new file mode 100644 index 0000000..6c954e0 --- /dev/null +++ b/macos/BraiinsRatchet/README.md @@ -0,0 +1,23 @@ +# Braiins Ratchet Mac + +Native SwiftUI shell for the durable Braiins Ratchet lifecycle engine. + +The Python supervisor remains the source of truth. This app reads the same repository-local SQLite state through `./scripts/ratchet`. + +## Run From Source + +```bash +cd macos/BraiinsRatchet +swift run BraiinsRatchetMac +``` + +## Current Scope + +- Native macOS SwiftUI cockpit. +- Liquid-glass-inspired material panels. +- Buttons for cockpit, lifecycle status, automation proposal, and full report. +- Monitor-only. It never places Braiins orders. + +## Product Direction + +The next production step is packaging this SwiftUI shell as a signed `.app` and wiring LaunchAgent controls for the durable supervisor. diff --git a/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift b/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift new file mode 100644 index 0000000..fc1d30b --- /dev/null +++ b/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift @@ -0,0 +1,160 @@ +import SwiftUI + +@main +struct BraiinsRatchetApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .frame(minWidth: 980, minHeight: 680) + } + .windowStyle(.hiddenTitleBar) + } +} + +struct ContentView: View { + @State private var output = "Press Refresh Cockpit." + @State private var isRunning = false + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.05, green: 0.07, blue: 0.08), + Color(red: 0.08, green: 0.14, blue: 0.15), + Color(red: 0.17, green: 0.20, blue: 0.17) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(alignment: .leading, spacing: 22) { + header + controls + outputPanel + } + .padding(30) + } + .task { + await runRatchet(["next"]) + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Braiins Ratchet") + .font(.system(size: 44, weight: .black, design: .rounded)) + .foregroundStyle(.white) + Text("Persistent monitor-only autoresearch cockpit") + .font(.system(size: 18, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.72)) + } + } + + private var controls: some View { + HStack(spacing: 12) { + glassButton("Refresh Cockpit") { + Task { await runRatchet(["next"]) } + } + glassButton("Lifecycle Status") { + Task { await runRatchet(["supervise", "--status"]) } + } + glassButton("Automation Plan") { + Task { await runRatchet(["pipeline"], input: "no\n") } + } + glassButton("Full Report") { + Task { await runRatchet(["report"]) } + } + if isRunning { + ProgressView() + .controlSize(.small) + .padding(.leading, 8) + } + } + } + + private var outputPanel: some View { + ScrollView { + Text(output) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.white.opacity(0.92)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(22) + } + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(.white.opacity(0.16), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.35), radius: 28, x: 0, y: 18) + } + + private func glassButton(_ title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + .background(.thinMaterial, in: Capsule()) + .overlay(Capsule().stroke(.white.opacity(0.18), lineWidth: 1)) + .disabled(isRunning) + } + + @MainActor + private func runRatchet(_ arguments: [String], input: String? = nil) async { + isRunning = true + output = "Running ./scripts/ratchet \(arguments.joined(separator: " ")) ..." + let result = await RatchetProcess.run(arguments: arguments, input: input) + output = result + isRunning = false + } +} + +enum RatchetProcess { + static func run(arguments: [String], input: String? = nil) async -> String { + await Task.detached { + let packageRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let repoRoot = packageRoot + .deletingLastPathComponent() + .deletingLastPathComponent() + let script = repoRoot.appendingPathComponent("scripts/ratchet").path + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-lc", ([script] + arguments).map(shellQuote).joined(separator: " ")] + process.currentDirectoryURL = repoRoot + + let outputPipe = Pipe() + let inputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = outputPipe + process.standardInput = inputPipe + + do { + try process.run() + if let input { + inputPipe.fileHandleForWriting.write(Data(input.utf8)) + } + inputPipe.fileHandleForWriting.closeFile() + process.waitUntilExit() + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + let text = String(data: data, encoding: .utf8) ?? "" + return text.isEmpty ? "Command finished with no output." : text + } catch { + return "Failed to run ratchet command: \(error.localizedDescription)" + } + }.value + } + + private static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/scripts/ratchet b/scripts/ratchet index 4649f2d..60aae6c 100755 --- a/scripts/ratchet +++ b/scripts/ratchet @@ -14,6 +14,7 @@ Commands: once Fetch one fresh sample, then print the cockpit. watch [hours] Run repeated monitor cycles for N hours. Default: 6. pipeline Propose timed automation, then ask yes/no. + supervise Run the durable forever lifecycle supervisor. report Print the latest stored report without fetching new data. experiments Print the Karpathy-style experiment ledger. retro SINCE [UNTIL] Write a retroactive report from stored snapshots. @@ -28,6 +29,7 @@ Examples: ./scripts/ratchet once ./scripts/ratchet watch 6 ./scripts/ratchet pipeline + ./scripts/ratchet supervise ./scripts/ratchet report ./scripts/ratchet experiments ./scripts/ratchet retro 2026-04-25T19:08:00+00:00 2026-04-25T21:05:00+00:00 @@ -111,6 +113,10 @@ cmd_pipeline() { run_python -m braiins_ratchet.cli pipeline "$@" } +cmd_supervise() { + run_python -m braiins_ratchet.cli supervise "$@" +} + cmd_experiments() { run_python -m braiins_ratchet.cli experiments } @@ -152,6 +158,7 @@ main() { once) cmd_once "$@" ;; watch) cmd_watch "$@" ;; pipeline|auto) cmd_pipeline "$@" ;; + supervise|daemon) cmd_supervise "$@" ;; report) cmd_report "$@" ;; experiments) cmd_experiments "$@" ;; retro) cmd_retro "$@" ;; diff --git a/src/braiins_ratchet/cli.py b/src/braiins_ratchet/cli.py index 19c1c9e..bac8079 100644 --- a/src/braiins_ratchet/cli.py +++ b/src/braiins_ratchet/cli.py @@ -17,6 +17,7 @@ from .experiments import ( write_retro_report, ) from .guidance import build_operator_cockpit +from .lifecycle import render_lifecycle_status, render_supervisor_plan, run_supervisor from .monitor import run_cycle from .ocean import fetch_snapshot from .report import build_text_report @@ -192,6 +193,22 @@ def cmd_pipeline(args: argparse.Namespace) -> int: return 0 +def cmd_supervise(args: argparse.Namespace) -> int: + config = load_config(Path(args.config) if args.config else None) + if args.status: + with connect() as conn: + init_db(conn) + print(render_lifecycle_status(conn)) + return 0 + print(render_supervisor_plan()) + if not args.yes: + answer = input("> ").strip().lower() + if answer not in {"y", "yes"}: + print("Supervisor cancelled. No action was taken.") + return 0 + return run_supervisor(config, once=args.once) + + def cmd_experiments(_: argparse.Namespace) -> int: if not EXPERIMENT_LOG.exists(): print("No experiment log yet. Run ./scripts/ratchet watch 2.") @@ -328,6 +345,13 @@ def build_parser() -> argparse.ArgumentParser: pipeline.add_argument("--yes", action="store_true", help="accept the printed plan without prompting") pipeline.set_defaults(func=cmd_pipeline) + supervise = sub.add_parser("supervise", help="run the durable forever lifecycle supervisor") + supervise.add_argument("--config") + supervise.add_argument("--yes", action="store_true", help="start without prompting") + supervise.add_argument("--once", action="store_true", help="run one supervisor decision then stop") + supervise.add_argument("--status", action="store_true", help="print persisted lifecycle status") + supervise.set_defaults(func=cmd_supervise) + experiments = sub.add_parser("experiments", help="print the Karpathy-style experiment log") experiments.set_defaults(func=cmd_experiments) diff --git a/src/braiins_ratchet/lifecycle.py b/src/braiins_ratchet/lifecycle.py new file mode 100644 index 0000000..7d86675 --- /dev/null +++ b/src/braiins_ratchet/lifecycle.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +import json +import time + +from .config import AppConfig +from .experiments import finish_experiment, start_experiment +from .guidance import POST_WATCH_COOLDOWN_MINUTES, build_operator_cockpit +from .monitor import run_cycle +from .storage import connect, init_db + + +DEFAULT_WATCH_CYCLES = 24 +DEFAULT_INTERVAL_SECONDS = 300 + + +@dataclass(frozen=True) +class LifecycleStatus: + phase: str + next_action_utc: str | None + last_run_id: str | None + message: str + + +def init_lifecycle_db(conn) -> None: + init_db(conn) + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS lifecycle_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS lifecycle_events ( + id INTEGER PRIMARY KEY, + timestamp_utc TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS manual_positions ( + id INTEGER PRIMARY KEY, + opened_utc TEXT NOT NULL, + closed_utc TEXT, + status TEXT NOT NULL, + venue TEXT NOT NULL, + description TEXT NOT NULL, + expected_maturity_utc TEXT, + payload_json TEXT NOT NULL + ); + """ + ) + conn.commit() + + +def get_lifecycle_status(conn) -> LifecycleStatus: + init_lifecycle_db(conn) + state = _read_state(conn) + return LifecycleStatus( + phase=state.get("phase", "idle"), + next_action_utc=state.get("next_action_utc"), + last_run_id=state.get("last_run_id"), + message=state.get("message", "no lifecycle state recorded yet"), + ) + + +def render_lifecycle_status(conn) -> str: + status = get_lifecycle_status(conn) + lines = [ + "Braiins Ratchet Lifecycle", + "", + f"Phase: {status.phase}", + f"Next action UTC: {status.next_action_utc or 'now'}", + f"Last run id: {status.last_run_id or 'none'}", + f"Message: {status.message}", + ] + if status.next_action_utc: + remaining = _seconds_until(status.next_action_utc) + lines.append(f"Countdown: {_format_duration(remaining)}") + return "\n".join(lines) + + +def render_supervisor_plan() -> str: + return "\n".join( + [ + "Forever Supervisor Proposal", + "", + "I am going to take over the monitor-only autoresearch lifecycle.", + "", + "Loop:", + " 1. Resume persisted lifecycle state from data/ratchet.sqlite.", + " 2. If cooldown is active, wait until the persisted next-action time.", + " 3. Run one 2-hour passive watch when the lifecycle is ready.", + " 4. Write the experiment ledger and run report.", + " 5. Enter post-watch cooldown.", + " 6. Repeat forever until you stop the process.", + "", + "Crash/reboot behavior:", + " Restart ./scripts/ratchet supervise and it resumes from SQLite.", + "", + "Safety:", + " This supervisor is monitor-only.", + " It never places, changes, or cancels Braiins orders.", + " If you manually start a Braiins order, record it separately; this daemon will not infer owner-token state.", + "", + "Are you OK with this? Type yes or no.", + ] + ) + + +def run_supervisor(config: AppConfig, *, once: bool = False) -> int: + with connect() as conn: + init_lifecycle_db(conn) + _record_event(conn, "supervisor_started", {"once": once}) + + while True: + with connect() as conn: + init_lifecycle_db(conn) + state = _read_state(conn) + phase = state.get("phase", "idle") + next_action_utc = state.get("next_action_utc") + + if phase == "cooldown" and next_action_utc: + remaining = _seconds_until(next_action_utc) + if remaining > 0: + _print_timer("Lifecycle cooldown", remaining) + if once: + return 0 + _sleep_with_progress(remaining) + + run_id = _run_watch_stage(config) + next_action = datetime.now(UTC) + timedelta(minutes=POST_WATCH_COOLDOWN_MINUTES) + with connect() as conn: + init_lifecycle_db(conn) + _write_state( + conn, + { + "phase": "cooldown", + "next_action_utc": next_action.isoformat(timespec="seconds"), + "last_run_id": run_id, + "message": "watch complete; cooldown active before next research stage", + }, + ) + _record_event( + conn, + "watch_completed", + {"run_id": run_id, "next_action_utc": next_action.isoformat(timespec="seconds")}, + ) + print(build_operator_cockpit(conn)) + if once: + return 0 + + +def _run_watch_stage(config: AppConfig) -> str: + experiment = start_experiment( + DEFAULT_WATCH_CYCLES, + DEFAULT_INTERVAL_SECONDS, + "forever supervisor: bounded passive watch stage", + ) + with connect() as conn: + init_lifecycle_db(conn) + _write_state( + conn, + { + "phase": "watching", + "next_action_utc": "", + "last_run_id": experiment.run_id, + "message": "2-hour passive watch is running", + }, + ) + _record_event(conn, "watch_started", {"run_id": experiment.run_id}) + + status = "completed" + try: + for index in range(DEFAULT_WATCH_CYCLES): + result = run_cycle(conn, config) + print( + f"cycle {index + 1}/{DEFAULT_WATCH_CYCLES}: " + f"{result.proposal.action} - {result.proposal.reason}", + flush=True, + ) + if index + 1 < DEFAULT_WATCH_CYCLES: + time.sleep(DEFAULT_INTERVAL_SECONDS) + except KeyboardInterrupt: + status = "interrupted" + print("interrupted: writing partial experiment report before exit", flush=True) + report_path = finish_experiment( + conn, + experiment.run_id, + experiment.started_utc, + DEFAULT_WATCH_CYCLES, + DEFAULT_INTERVAL_SECONDS, + "forever supervisor: bounded passive watch stage", + status=status, + ) + _record_event(conn, "watch_report_written", {"run_id": experiment.run_id, "report": report_path}) + return experiment.run_id + + +def _read_state(conn) -> dict[str, str]: + rows = conn.execute("SELECT key, value FROM lifecycle_state").fetchall() + return {row[0]: row[1] for row in rows} + + +def _write_state(conn, values: dict[str, str]) -> None: + conn.execute("DELETE FROM lifecycle_state") + for key, value in values.items(): + conn.execute( + "INSERT INTO lifecycle_state (key, value) VALUES (?, ?)", + (key, value), + ) + conn.commit() + + +def _record_event(conn, event_type: str, payload: dict[str, object]) -> None: + conn.execute( + """ + INSERT INTO lifecycle_events (timestamp_utc, event_type, payload_json) + VALUES (?, ?, ?) + """, + (datetime.now(UTC).isoformat(timespec="seconds"), event_type, json.dumps(payload, sort_keys=True)), + ) + conn.commit() + + +def _seconds_until(timestamp_utc: str) -> int: + try: + target = datetime.fromisoformat(timestamp_utc.replace("Z", "+00:00")) + except ValueError: + return 0 + if target.tzinfo is None: + target = target.replace(tzinfo=UTC) + return max(0, int((target.astimezone(UTC) - datetime.now(UTC)).total_seconds())) + + +def _sleep_with_progress(seconds: int) -> None: + remaining = max(0, seconds) + while remaining > 0: + sleep_for = min(60, remaining) + time.sleep(sleep_for) + remaining -= sleep_for + _print_timer("Lifecycle cooldown", remaining) + + +def _print_timer(label: str, seconds: int) -> None: + print(f"{label}: {_format_duration(seconds)} remaining", flush=True) + + +def _format_duration(seconds: int) -> str: + seconds = max(0, seconds) + hours, remainder = divmod(seconds, 3600) + minutes, _ = divmod(remainder, 60) + if hours: + return f"{hours}h {minutes}m" + return f"{minutes}m" diff --git a/src/braiins_ratchet/storage.py b/src/braiins_ratchet/storage.py index 30f06eb..3ec3fe0 100644 --- a/src/braiins_ratchet/storage.py +++ b/src/braiins_ratchet/storage.py @@ -63,6 +63,29 @@ def init_db(conn: sqlite3.Connection) -> None: score_btc TEXT NOT NULL, maturity_note TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS lifecycle_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS lifecycle_events ( + id INTEGER PRIMARY KEY, + timestamp_utc TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS manual_positions ( + id INTEGER PRIMARY KEY, + opened_utc TEXT NOT NULL, + closed_utc TEXT, + status TEXT NOT NULL, + venue TEXT NOT NULL, + description TEXT NOT NULL, + expected_maturity_utc TEXT, + payload_json TEXT NOT NULL + ); """ ) _ensure_market_columns(conn) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..1beabb9 --- /dev/null +++ b/tests/test_lifecycle.py @@ -0,0 +1,49 @@ +from datetime import UTC, datetime, timedelta +import sqlite3 +import unittest + +from braiins_ratchet.lifecycle import ( + get_lifecycle_status, + init_lifecycle_db, + render_lifecycle_status, + render_supervisor_plan, +) + + +class LifecycleTests(unittest.TestCase): + def test_lifecycle_tables_initialize_and_status_defaults(self) -> None: + conn = sqlite3.connect(":memory:") + + init_lifecycle_db(conn) + status = get_lifecycle_status(conn) + + self.assertEqual(status.phase, "idle") + self.assertIsNone(status.next_action_utc) + self.assertIn("no lifecycle state", status.message) + + def test_lifecycle_status_renders_countdown(self) -> None: + conn = sqlite3.connect(":memory:") + init_lifecycle_db(conn) + next_action = datetime.now(UTC) + timedelta(minutes=5) + conn.execute("INSERT INTO lifecycle_state (key, value) VALUES (?, ?)", ("phase", "cooldown")) + conn.execute( + "INSERT INTO lifecycle_state (key, value) VALUES (?, ?)", + ("next_action_utc", next_action.isoformat(timespec="seconds")), + ) + conn.commit() + + text = render_lifecycle_status(conn) + + self.assertIn("Phase: cooldown", text) + self.assertIn("Countdown:", text) + + def test_supervisor_plan_states_monitor_only_resume_contract(self) -> None: + text = render_supervisor_plan() + + self.assertIn("Resume persisted lifecycle state", text) + self.assertIn("Restart ./scripts/ratchet supervise", text) + self.assertIn("never places", text) + + +if __name__ == "__main__": + unittest.main()