#!/usr/bin/env python """launch.py -- the only way a PA3 session starts. detect_state -> preflight -> write_seed -> build_cmd -> launch. Stdlib only; no imports from `pa/` (it may import plan_edit.py, its neighbour). """ import argparse import datetime import json import os import re import subprocess import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import plan_edit as PE # noqa: E402 try: # UTF-8 output even when not started with -X utf8 sys.stdout.reconfigure(encoding="utf-8", errors="replace") except Exception: pass MIN_CC = (2, 1, 270) FABLE = "claude-fable-5-1[1m]" SESSION = "pa-session" # the one main-thread agent; router/planner/review are its modes SONNET = "claude-sonnet-5-5[1m]" # the pa-session model (mechanical router); planners and experts run Opus 5.5 / Fable per agent file PLANNER_LABEL = "claude-opus-5-5/medium" # was claude-fable-5-1/medium (T18, 2026-09-24), before that /xhigh; planner-gen / planner-phase run as medium subagents of pa-session MODES = { "planner-gen": {"agent": SESSION, "model": SONNET, "effort": "medium", "perm": None}, "planner-phase": {"agent": SESSION, "model": SONNET, "effort": "medium", "perm": None}, "review": {"agent": SESSION, "model": SONNET, "effort": "medium", "perm": None}, "router": {"agent": SESSION, "model": SONNET, "effort": "medium", "perm": "auto"}, "plain": {"agent": "plain", "model": FABLE, "effort": "high", "perm": None}, } def die(msg): sys.stdout.write("refused: %s\n" % msg) raise SystemExit(1) def rel(root, path): return PE.rel(root, path) def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") # --------------------------------------------------------------------------- # def gen_phases(root): """[(id, name, status, raw)] from GENERATION_PLAN.md '## Phases'.""" path = os.path.join(root, "GENERATION_PLAN.md") if not os.path.isfile(path): return None out = [] inside = False for raw in PE.read_text(path).split("\n"): body = PE.split_eol(raw)[0] if body.startswith("## "): inside = body[3:].strip().lower().startswith("phases") continue if not inside: continue m = re.match(r"^\s*-\s+([0-9]+(?:\.[0-9]+)*)\s+([^|]*)", body) if m: st = re.search(r"status:\s*(\S+)", body) out.append((m.group(1), m.group(2).strip(), (st.group(1) if st else "open"), body)) return out def detect_state(root, conf, mode=None): """-> (mode, reason, consumed_next_mode).""" cur = os.path.join(PE.phase_dir(root, conf), "current") if mode: return mode, "--mode", False nxt = os.path.join(root, ".run", "next_mode") if os.path.isfile(nxt): want = PE.read_text(nxt).strip() os.remove(nxt) if want in MODES: return want, ".run/next_mode (consumed)", True phases = gen_phases(root) if phases is None or not phases or all(p[2] == "closed" for p in phases): return "planner-gen", "no open generation phase", False if os.path.isfile(os.path.join(cur, "REPLAN.md")): return "planner-phase", "REPLAN.md present", False if os.path.isfile(os.path.join(cur, "REVIEW.md")): return "review", "REVIEW.md present", False plan = os.path.join(cur, "PHASE_PLAN.md") if not os.path.isfile(plan): return "planner-phase", "no PHASE_PLAN.md", False if not PE.is_approved(PE.read_text(plan)): return "planner-phase", "PHASE_PLAN.md not approved", False return "router", "approved plan", False def phase_id(root, conf): plan = PE.plan_path(root, conf) if os.path.isfile(plan): first = PE.read_text(plan).split("\n")[0] m = re.match(r"^#\s*Phase\s+(\S+)", first.strip()) if m: return m.group(1) phases = gen_phases(root) or [] for pid, _n, st, _r in phases: if st != "closed": return pid return "0" def generation_id(root, conf): plan = PE.plan_path(root, conf) if os.path.isfile(plan): m = re.search(r"phase\s+([0-9]+(?:\.[0-9]+)*)", PE.read_text(plan).split("\n")[0]) if m: return m.group(1).split(".")[0] phases = gen_phases(root) or [] if phases: return phases[0][0].split(".")[0] return "0" # --------------------------------------------------------------------------- # def _claude_version(): try: out = subprocess.run(["claude", "--version"], capture_output=True, text=True, timeout=20) except Exception: return None m = re.search(r"(\d+)\.(\d+)\.(\d+)", (out.stdout or "") + (out.stderr or "")) return tuple(int(x) for x in m.groups()) if m else None def _pa_sessions(root): try: out = subprocess.run(["claude", "agents", "--json"], capture_output=True, text=True, timeout=20, cwd=root) if out.returncode != 0 or not out.stdout.strip(): return [] data = json.loads(out.stdout) except Exception: return [] found = [] def walk(node): if isinstance(node, dict): name = node.get("name") if isinstance(name, str) and name.startswith("pa:"): found.append(name) for v in node.values(): walk(v) elif isinstance(node, list): for v in node: walk(v) walk(data) return found def check_fable(notes): path = os.path.join(os.path.expanduser("~"), ".claude", "usage-ledger", "summary.json") if not os.path.isfile(path): notes.append("warn: no usage-ledger summary.json; Fable-window check skipped") return try: with open(path, encoding="utf-8") as fh: data = json.load(fh) except Exception as exc: notes.append("warn: summary.json unreadable (%s)" % type(exc).__name__) return worst = 0.0 txt = json.dumps(data) for m in re.finditer(r'"(?:pct|used_percentage|utilization)"\s*:\s*([0-9.]+)', txt): try: worst = max(worst, float(m.group(1))) except ValueError: pass if "fable" in txt.lower() and worst >= 95: notes.append("warn: Fable window at %.0f%% -- consider --model claude-opus-5" % worst) else: notes.append("ok: Fable window %.0f%%" % worst) def preflight(root, conf, mode, args, notes): if not os.path.isfile(os.path.join(root, ".claude", "pa.json")): die("no .claude/pa.json above %s (is this a PA3 project?)" % os.getcwd()) if not os.path.isdir(os.path.join(root, ".git")): notes.append("warn: %s is not a git worktree" % rel(root, root)) if os.environ.get("PA_SKIP_PREFLIGHT") == "1": notes.append("warn: preflight probes skipped (PA_SKIP_PREFLIGHT=1)") if args.check_fable: check_fable(notes) return ver = _claude_version() if ver is None: notes.append("warn: `claude --version` unavailable; version gate skipped") elif ver < MIN_CC: if not args.force: die("claude %s < %s (--force to override)" % (".".join(map(str, ver)), ".".join(map(str, MIN_CC)))) notes.append("warn: claude %s < required %s" % (ver, MIN_CC)) else: notes.append("ok: claude %s" % ".".join(map(str, ver))) if not args.resume and not args.seed_only: live = _pa_sessions(root) if live and not args.force: die("a PA session is already open in this worktree: %s (--force)" % live[0]) if mode.startswith("planner"): try: out = subprocess.run(["git", "status", "--porcelain"], cwd=root, capture_output=True, text=True, timeout=20) if out.returncode == 0 and out.stdout.strip(): n = len([x for x in out.stdout.split("\n") if x.strip()]) notes.append("warn: %d uncommitted change(s) at planner start" % n) except Exception: pass if args.check_fable: check_fable(notes) # --------------------------------------------------------------------------- # SEED_HEAD = """# PA3 session seed -- {mode} -- Phase {phase} (Gen {gen}) You are the {mode} session. Read this file, then run your loop; do not read it twice. Rules live in the project-architect skill (already in your context). Pointers only below. Files: {py} tools/card.py slice router | PROJECT_CONTEXT.md | GENERATION_PLAN.md {plan} Index: rules/INDEX.md | cookbook/INDEX.md | {pe}/TASK_INDEX.md | {pe}/RESEARCH_INDEX.md Tools: python tools/plan_edit.py | tools/status.py | tools/run.sh | tools/commit_task.sh """ def own_session_id(root): """This session's id or None: the ``~/.claude/sessions/.json`` registry ``sessionId``, else the newest ``.run/warmer/.pid`` naming a live pid.""" sid = None try: reg = os.path.join(os.path.expanduser("~"), ".claude", "sessions", "%s.json" % os.environ["CLAUDE_PID"]) with open(reg, encoding="utf-8") as fh: sid = json.load(fh).get("sessionId") except Exception: sid = None if not sid: try: sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from pa.warmer import pid_alive except Exception: pid_alive = None wdir = os.path.join(root, ".run", "warmer") try: pids = sorted((f for f in os.listdir(wdir) if f.endswith(".pid")), key=lambda f: (os.path.getmtime(os.path.join(wdir, f)), f), reverse=True) except OSError: pids = [] for f in pids: try: with open(os.path.join(wdir, f), encoding="utf-8") as fh: pid = int(fh.read().strip() or 0) except (OSError, ValueError): continue if pid_alive is None or pid_alive(pid): sid = f[:-4] break return sid def _arm_line(root): """The seed's ``Arm now:`` Monitor line with the session id (3.9.5 T6: a seed without it left the trial's pa-session unarmed). Sid: ``own_session_id``, else ````.""" sid = own_session_id(root) line ="Arm now: Monitor on `tail -n0 -F .run/warmer/%s.wake` (timeout 30 min), then continue." % ( sid or "") if not sid: line += " (sid: .run/status.json router_session or the newest .run/warmer/*.pid)" return line + "\n" def _status_doc(root): """``.run/status.json`` as a dict ({} when absent).""" try: with open(os.path.join(root, ".run", "status.json"), encoding="utf-8") as fh: doc = json.load(fh) return doc if isinstance(doc, dict) else {} except (OSError, ValueError): return {} def _curate_seed_line(root, conf, mode): """Return a curate: seed line: the migration line in any mode while LEGACY_INDEX.md has entries, pa.json lacks `memory_routed` (3.14 T2) and `.claude-state/memory` holds a memory file (any *.md but MEMORY.md and gen*.md archives; 3.14 T4); else, planner-gen only, the closing-gen line when a GenerationEnd exists.""" pe_dir = PE.phase_dir(root, conf) # check LEGACY_INDEX.md for entries (non-comment, non-blank lines); the migration wins legacy = os.path.join(pe_dir, "LEGACY_INDEX.md") if os.path.isfile(legacy) and not conf.get("memory_routed"): text = PE.read_text(legacy) entries = [l for l in text.split("\n") if l.strip() and not l.strip().startswith("#")] memdir = os.path.join(root, ".claude-state", "memory") memories = [n for n in (os.listdir(memdir) if os.path.isdir(memdir) else []) if n.endswith(".md") and n != "MEMORY.md" and not n.startswith("gen")] if entries and memories: return "curate: migration -> gen legacy" if mode != "planner-gen": return None # find the highest GenerationEnd highest_g = None if os.path.isdir(pe_dir): for name in sorted(os.listdir(pe_dir)): m = re.match(r"GenerationEnd_(\d+)\.md$", name) if m: g = int(m.group(1)) if highest_g is None or g > highest_g: highest_g = g if highest_g is not None: return "curate: closing gen %d -> opening gen %d" % (highest_g, highest_g + 1) return None def _audit_flag_line(root): """``Audit flag: flags in PhaseEnd_Phase.md`` when the newest PhaseEnd has flags.""" pe_dir = os.path.join(root, "phase-ends") if not os.path.isdir(pe_dir): return None # find PhaseEnd files (natural sort by phase number) pe_files = [] for name in os.listdir(pe_dir): m = re.match(r"PhaseEnd_Phase(\S+)\.md$", name) if m: pe_files.append((name, m.group(1))) if not pe_files: # also look inside phase-/ subdirs for sub in os.listdir(pe_dir): sub_path = os.path.join(pe_dir, sub) if not os.path.isdir(sub_path): continue for name in os.listdir(sub_path): m = re.match(r"PhaseEnd_Phase(\S+)\.md$", name) if m: pe_files.append((os.path.join(sub, name), m.group(1))) if not pe_files: return None # natural sort by phase id (type-safe: ids like 3_5 mix digits and words) from phaseend_index import natural_key pe_files.sort(key=lambda item: natural_key(item[1])) newest_rel, newest_phase = pe_files[-1] newest_path = os.path.join(pe_dir, newest_rel) # count '- flag:' lines under '## Audit' n_flags = 0 in_audit = False try: with open(newest_path, "r", encoding="utf-8") as fh: for line in fh: if line.startswith("## Audit"): in_audit = True continue if in_audit and line.startswith("## "): break if in_audit and line.strip().startswith("- flag:"): n_flags += 1 except OSError: return None if n_flags: return "Audit flag: %d flags in PhaseEnd_Phase%s.md" % (n_flags, newest_phase) return None def _run_age_note(run_id, root, conf): """Return ' (last record ago)' or ' (last record ago, stale)' for the seed line.""" # find the transcript file for this run transcript = None ledger_dir = os.environ.get("PA_LEDGER_DIR") or os.path.join( os.path.expanduser("~"), ".claude", "usage-ledger") db_path = os.path.join(ledger_dir, "usage-ledger.db") if os.path.isfile(db_path): try: import sqlite3 conn = sqlite3.connect(db_path, timeout=2) conn.row_factory = sqlite3.Row row = conn.execute( "SELECT transcript_path FROM agent_runs WHERE run_id=?", (run_id,)).fetchone() conn.close() if row and row["transcript_path"]: transcript = row["transcript_path"] except Exception: pass if not transcript: rpath = os.path.join(ledger_dir, "running.json") if os.path.isfile(rpath): try: with open(rpath, encoding="utf-8") as fh: rdoc = json.load(fh) for _sid, sess in (rdoc.get("sessions") or {}).items(): for aid, ag in (sess.get("agents") or {}).items(): if aid == run_id and ag.get("transcript_path"): transcript = ag["transcript_path"] break if transcript: break except Exception: pass if not transcript or not os.path.isfile(transcript): return "" # compute age try: mtime = os.path.getmtime(transcript) age_s = max(0, datetime.datetime.now(datetime.timezone.utc).timestamp() - mtime) except OSError: return "" age_min = age_s / 60.0 if age_min < 60: age_str = "%.0fm" % age_min else: age_str = "%.1fh" % (age_min / 60.0) # read liveness_min from config liveness_min = 5 cfg_path = os.path.join(ledger_dir, "config.json") if os.path.isfile(cfg_path): try: with open(cfg_path, encoding="utf-8") as fh: cdoc = json.load(fh) lm = (cdoc.get("resume") or {}).get("liveness_min") if isinstance(lm, (int, float)) and lm > 0: liveness_min = lm except Exception: pass if age_min > liveness_min: return " (last record %s ago, stale)" % age_str return " (last record %s ago)" % age_str def _card_slice(root, role): """Run ``card.py slice `` and return its output, or '' on failure.""" card_py = os.path.join(os.path.dirname(os.path.abspath(__file__)), "card.py") if not os.path.isfile(card_py): return "" try: res = subprocess.run([sys.executable, card_py, "slice", role], cwd=root, capture_output=True, text=True, timeout=10) if res.returncode == 0 and res.stdout.strip(): return res.stdout.rstrip() except Exception: pass return "" def _deferred_items(root, conf, phase, every=False): """-> [(id, text)] deferred items due for `phase` (scan: _genplan.deferred_items); every=True: every open deferral (planner-gen triage).""" gp = os.path.join(root, "GENERATION_PLAN.md") gtext = PE.read_text(gp) if os.path.isfile(gp) else "" return [(d["id"], d["text"]) for d in PE._genplan.deferred_items(PE.phase_dir(root, conf), gtext, phase, every=every)] def _upgrade_line(root): """`Upgrade:` seed line when .claude/pa3-upgrade/UPGRADE.md lists unresolved conflicts (`- ` lines without ` | resolve:`, T20).""" path = os.path.join(root, ".claude", "pa3-upgrade", "UPGRADE.md") try: with open(path, encoding="utf-8") as f: n = sum(1 for ln in f if ln.startswith("- ") and " | resolve:" not in ln) except OSError: return None if not n: return None return ("Upgrade: %d file(s) in .claude/pa3-upgrade/UPGRADE.md await a resolution " "(keep | upstream | merged) by an upgrade expert; the router spawns it before the " "first task; nothing is copied by hand" % n) def write_seed(root, conf, mode, phase, gen, args): pe = rel(root, PE.phase_dir(root, conf)) plan = PE.plan_path(root, conf) py = conf.get("python") or "python" body = [SEED_HEAD.format(mode=mode, phase=phase, gen=gen, pe=pe, plan=rel(root, plan), py=py)] preset = conf.get("preset") or "max20" # max5/pro have no hard rung (pa/install/ladder.PRESETS) body.append("Preset: %s (hard rung: %s)\n" % (preset, "none" if preset in ("max5", "pro") else "expert-fable")) cur = os.path.join(PE.phase_dir(root, conf), "current") if mode == "router": body.append(_arm_line(root)) # first, before Run in flight / Running (3.9.5 T6) st = _status_doc(root) if st.get("task") and st.get("run_id") and st.get("killed_at"): progress = os.path.join(cur, "TASK_PROGRESS.md") if os.path.isfile(progress): progress_note = "PROGRESS: phase-ends/current/TASK_PROGRESS.md (written at the resume from the killed run's transcript)" else: progress_note = "(no progress file: the transcript was not found; respawn from the plan)" age_note = _run_age_note(st["run_id"], root, conf) unstop_note = "" if st.get("unstop_cleared"): unstop_note = " (stoppedByUser cleared by the resume hook)" body.append( "Run in flight: %s (task %s, %s) was killed with the previous process at %s%s%s; " "continue it first: SendMessage(to=\"%s\", message=\"continue: %s -- the session was " "resumed; your files and the plan are unchanged\") and end your turn to wait for its " "notification; when the send fails (the harness refuses an agent killed with its " "process) respawn %s as attempt %d with %s\n" % (st["run_id"], st["task"], st.get("expert_agent_type") or "?", st["killed_at"], age_note, unstop_note, st["run_id"], st["task"], st["task"], (st.get("attempt") or 1) + 1, progress_note)) elif st.get("task") and st.get("run_id") and st.get("kind") in ("task", "handoff", "relaunch"): body.append("Running: %s run %s (attempt %s, %s) — a resumed session: SendMessage that run first; " "respawn only if it is unknown\n" % (st["task"], st["run_id"], st.get("attempt") or 1, st.get("kind"))) body.append("## Tasks") body.append(_capture(["show", "--tasks", "--titles"], root)) body.append("\n## Next") body.append(_capture(["next"], root)) # inline the router card slice card = _card_slice(root, "router") if card: body.append("\n## Card") body.append(card) elif mode.startswith("planner"): body.append("## Generation phases") for pid, name, st, _raw in (gen_phases(root) or []): body.append("- %s %s | status: %s" % (pid, name, st)) # curate line: the closing-gen line is planner-gen only (while a generation is open, # its predecessor's GenerationEnd still exists; the 3.3 trial ran the curator at a # phase-planning launch because of that); the migration line is any mode (3.14 T2) curate_line = _curate_seed_line(root, conf, mode) if curate_line: body.append(curate_line) if os.path.isfile(os.path.join(cur, "REPLAN.md")): body.append("\nREPLAN.md is present: read it first, then rewrite the plan.") # deferred items due for the planned phase (first open); each gets a ## Triage line. # planner-gen: every open deferral, triaged by `discuss` first (Triage: line) gp = os.path.join(root, "GENERATION_PLAN.md") planned = PE._genplan.first_open(PE.read_text(gp)) if os.path.isfile(gp) else None items = _deferred_items(root, conf, planned, every=(mode == "planner-gen")) if mode == "planner-gen" and items: body.append("Triage: %d deferred items" % len(items)) for did, dtext in items: body.append("Deferred: %s %s" % (did, dtext)) body.append("\nSpawn %s to draft, ask the summary question, stop at approval. " "After approval: python tools/plan_edit.py approve --planner \"%s\"" % (mode, PLANNER_LABEL)) elif mode == "review": body.append("Read %s/current/REVIEW.md, answer outcome-first, " "record decisions via plan_edit.py or INBOX.md." % pe) for name in ("INBOX.md", "REPLAN.md", "REVIEW.md", "TASK_PROGRESS.md"): if os.path.isfile(os.path.join(cur, name)): body.append("present: %s/current/%s" % (pe, name)) upg = _upgrade_line(root) # staged install conflicts (3.9.7 T8) if upg: body.append(upg) if not mode.startswith("planner"): # planner modes carry it above (3.14 T2) curate_line = _curate_seed_line(root, conf, mode) if curate_line: body.append(curate_line) if mode == "router": audit_flag = _audit_flag_line(root) if audit_flag: body.append(audit_flag) body.append("\nLoop from here: INBOX consume -> plan_edit.py next -> " "status.py set -> spawn the expert in the background -> " "one line -> end the turn.") text = "\n".join(body).rstrip() + "\n" seed = os.path.join(root, ".run", "seed.md") PE.write_text(seed, text) return seed def _capture(argv, root): """Run plan_edit in-process and capture its ≤40 lines.""" import io buf = io.StringIO() old, sys.stdout = sys.stdout, buf try: PE.main(argv) except SystemExit: pass except Exception as exc: buf.write("(plan_edit %s: %s)\n" % (type(exc).__name__, exc)) finally: sys.stdout = old return buf.getvalue().rstrip("\n") # --------------------------------------------------------------------------- # def build_cmd(mode, project, phase, conf=None, args=None): spec = dict(MODES[mode]) conf = conf or {} if mode == "router": spec["perm"] = conf.get("router_permission_mode") or spec["perm"] if args is not None: if args.model: spec["model"] = args.model if args.effort: spec["effort"] = args.effort if args.perm: spec["perm"] = args.perm name = "pa:%s:%s:P%s" % (project, mode, phase) if args is not None and args.resume: return ["claude", "--resume", args.resume, "--effort", spec["effort"]], spec cmd = ["claude"] if spec["agent"]: cmd += ["--agent", spec["agent"]] cmd += ["--model", spec["model"], "--effort", spec["effort"]] if spec["perm"]: cmd += ["--permission-mode", spec["perm"]] cmd += ["--name", name] spec["name"] = name return cmd, spec def display(cmd): out = [] quote_next = False for c in cmd: if quote_next or " " in c: out.append('"%s"' % c) else: out.append(c) quote_next = (c == "--name") return " ".join(out) def child_env(mode, phase): env = dict(os.environ) env["CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH"] = "3" env["CLAUDE_CODE_TOTAL_TOKENS_REMINDER"] = "off" env["PA_MODE"] = mode env["PA_PHASE"] = str(phase) env["PA_LAUNCH_TS"] = now() return env # --------------------------------------------------------------------------- # def main(argv=None): p = argparse.ArgumentParser( prog="launch.py", description="Detect the session state and start it.") p.add_argument("--mode", choices=sorted(MODES)) p.add_argument("--effort") p.add_argument("--model") p.add_argument("--perm", choices=["acceptEdits", "default", "plan", "bypass", "auto"]) p.add_argument("--resume", metavar="SID") p.add_argument("--request", metavar="MODE", choices=sorted(MODES), help="write .run/next_mode and exit") p.add_argument("--dry-run", action="store_true") p.add_argument("--seed-only", action="store_true", help="write .run/seed.md + launch.json and print the state; no launch " "(the running pa-session calls this)") p.add_argument("--force", action="store_true") p.add_argument("--plain", action="store_true", help="fallback session: no --agent, Fable high") p.add_argument("--check-fable", action="store_true", help="read ~/.claude/usage-ledger/summary.json for the Fable window") a = p.parse_args(argv) root = PE.find_root() conf = PE.cfg(root) if a.request: PE.write_text(os.path.join(root, ".run", "next_mode"), a.request + "\n") sys.stdout.write("next_mode: %s (consumed by the next launch.py)\n" % a.request) return 0 if a.plain: mode, reason = "plain", "--plain fallback" else: mode, reason, _consumed = detect_state(root, conf, a.mode) project = conf.get("project") or os.path.basename(root) phase = phase_id(root, conf) gen = generation_id(root, conf) notes = [] preflight(root, conf, mode, a, notes) seed = write_seed(root, conf, mode, phase, gen, a) cmd, spec = build_cmd(mode, project, phase, conf, a) PE.write_text(os.path.join(root, ".run", "launch.json"), json.dumps({"mode": mode, "reason": reason, "phase": phase, "generation": gen, "project": project, "model": spec["model"], "effort": spec["effort"], "agent": spec["agent"], "name": spec.get("name"), "resume": a.resume, "ts": now(), "cmd": display(cmd)}, indent=1) + "\n") if a.seed_only: sys.stdout.write("seed=%s\nmode=%s\n" % (rel(root, seed), mode)) # Phase ownership (3.10 T28): follows whichever session ran `go` last; a relaunched # router takes over by restamping .run/status.json router_session (other keys kept). sid = own_session_id(root) if mode != "plain" else None if sid: doc = _status_doc(root) doc["router_session"], doc["updated"] = sid, now() PE.write_text(os.path.join(root, ".run", "status.json"), json.dumps(doc, ensure_ascii=False, indent=1) + "\n") sys.stdout.write("router_session=%s\n" % sid) return 0 out = ["state=%s (%s)" % (mode, reason), "project=%s phase=%s generation=%s" % (project, phase, gen), "seed=%s" % rel(root, seed)] out += notes[:6] out.append(display(cmd)) PE.emit(out) if a.dry_run: return 0 env = child_env(mode, phase) try: return subprocess.call(cmd, env=env, cwd=root) except FileNotFoundError: die("`claude` is not on PATH") if __name__ == "__main__": try: sys.exit(main()) except SystemExit: raise except Exception as exc: sys.stdout.write("refused: %s: %s\n" % (type(exc).__name__, exc)) sys.exit(1)