From 24bf379b5b8e2844504210c0ba74cebcf73c0cb9 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Mon, 1 Jun 2026 12:59:03 +0000 Subject: [PATCH] feat(assistant): add opencode launcher and phase 6/7 plans --- cc-ci-plan/launch-assistant.py | 159 ++++++++++++++++++ cc-ci-plan/launch-assistant.sh | 105 +----------- cc-ci-plan/launch.py | 102 ++++++++--- .../plan-phase6-reconcile-all-mirrors.md | 44 +++++ .../plan-phase7-upgrade-three-recipes.md | 49 ++++++ 5 files changed, 333 insertions(+), 126 deletions(-) create mode 100644 cc-ci-plan/launch-assistant.py create mode 100644 cc-ci-plan/plan-phase6-reconcile-all-mirrors.md create mode 100644 cc-ci-plan/plan-phase7-upgrade-three-recipes.md diff --git a/cc-ci-plan/launch-assistant.py b/cc-ci-plan/launch-assistant.py new file mode 100644 index 0000000..18a6db9 --- /dev/null +++ b/cc-ci-plan/launch-assistant.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +cc-ci assistant launcher — start/resume the assistant session in tmux. + +The assistant is a long-lived helper session that shares the orchestrator's access and can work +through assigned plans autonomously, but it is NOT part of the Builder/Adversary loop pair. + +Usage: + launch-assistant.py start resume the persistent session (default) + launch-assistant.py fresh start a NEW session + launch-assistant.py stop kill the tmux session + launch-assistant.py status show session state + launch-assistant.py attach tmux attach to the session + +Env: + LOOP_BACKEND claude (default) | opencode + LOOP_MODEL model flag, e.g. "sonnet" or "tinfoil/deepseek-v4-pro" + ASSISTANT_SESSION tmux / remote-control session name + ASSISTANT_DIR cwd for the assistant + ASSISTANT_STARTUP_PROMPT startup nudge / assignment +""" + +import os, sys, subprocess, time +from datetime import datetime +from pathlib import Path + +SESSION = os.environ.get("ASSISTANT_SESSION", "cc-ci-assistant") +WORKDIR = os.environ.get("ASSISTANT_DIR", "/srv/cc-ci-orch") +LOG_DIR = os.environ.get("LOG_DIR", "/srv/cc-ci/.cc-ci-logs") + +BACKEND = os.environ.get("LOOP_BACKEND", "claude") +LOOP_MODEL = os.environ.get("LOOP_MODEL", "") + +CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude") +CLAUDE_FLAGS = os.environ.get("CLAUDE_FLAGS", "--dangerously-skip-permissions") +REMOTE_CONTROL = os.environ.get("REMOTE_CONTROL", "1") == "1" +DEFAULT_ID = "9db267a4-9b9e-4818-8197-0a42dba443ea" +ID_FILE = os.environ.get("ASSISTANT_ID_FILE", f"{LOG_DIR}/.assistant-session-id") +STARTUP_PROMPT = os.environ.get("ASSISTANT_STARTUP_PROMPT", ( + "You are the cc-ci ASSISTANT. Read cc-ci-plan/JOURNAL.md for context, then wait for a " + "specific plan/task from the orchestrator or operator. Work autonomously until the assigned " + "task is complete, report the result, and then wait for the next assignment." +)) + +OPENCODE_BIN = os.environ.get("OPENCODE_BIN", "/home/loops/.local/bin/opencode") +OPENCODE_SERVER = os.environ.get("OPENCODE_SERVER", "http://127.0.0.1:4096") + + +def log(msg): + ts = datetime.now().strftime("%H:%M:%S") + print(f"[assistant {ts}] {msg}", flush=True) + + +def die(msg): + log(f"ERROR: {msg}") + sys.exit(1) + + +def session_alive(): + return subprocess.run(["tmux", "has-session", "-t", SESSION], capture_output=True).returncode == 0 + + +def _ping(session, msg): + subprocess.run(["tmux", "send-keys", "-t", session, "-l", "--", msg], capture_output=True) + time.sleep(0.5) + submit_key = "C-m" if BACKEND == "opencode" else "Enter" + for _ in range(10): + subprocess.run(["tmux", "send-keys", "-t", session, submit_key], capture_output=True) + time.sleep(1) + r = subprocess.run(["tmux", "capture-pane", "-pt", session], capture_output=True, text=True) + if msg[:28] not in r.stdout: + return + + +def resume_id(): + sid = os.environ.get("ASSISTANT_SESSION_ID") + if sid: + return sid + try: + v = Path(ID_FILE).read_text().strip() + return v or DEFAULT_ID + except FileNotFoundError: + return DEFAULT_ID + + +def start(mode="resume"): + import shutil + + if not shutil.which("tmux"): + die("tmux not found") + Path(LOG_DIR).mkdir(parents=True, exist_ok=True) + + if session_alive(): + log(f"{SESSION} already running — leaving it (use 'stop' first to relaunch)") + return + + model_flag = f"--model '{LOOP_MODEL}'" if LOOP_MODEL else "" + + if BACKEND == "claude": + if not shutil.which(CLAUDE_BIN): + die(f"claude CLI not found — set CLAUDE_BIN (currently: {CLAUDE_BIN})") + if not Path(ID_FILE).exists(): + Path(ID_FILE).write_text(DEFAULT_ID) + + rc = f"--remote-control '{SESSION}'" if REMOTE_CONTROL else "" + resume = f"--resume '{resume_id()}'" if mode == "resume" else "" + prompt = f"'{STARTUP_PROMPT}'" if STARTUP_PROMPT else "" + cmd = f"{CLAUDE_BIN} {resume} {rc} {model_flag} {CLAUDE_FLAGS} {prompt}" + detail = f"resume={resume_id()}" if mode == "resume" else "fresh" + log(f"starting {SESSION} (backend=claude, {detail}, model={LOOP_MODEL or 'default'})") + elif BACKEND == "opencode": + if not Path(OPENCODE_BIN).exists(): + die(f"opencode not found at {OPENCODE_BIN}") + cmd = ( + f"set -a; . /srv/cc-ci/.testenv; set +a; " + f"NO_COLOR=1 {OPENCODE_BIN} attach {OPENCODE_SERVER} --dir {WORKDIR}" + ) + log(f"starting {SESSION} (backend=opencode, model={LOOP_MODEL or 'default'})") + log(" visible at http://oc.commoninternet.net (tailnet only)") + else: + die(f"unknown LOOP_BACKEND '{BACKEND}' — use 'claude' or 'opencode'") + + subprocess.run(["tmux", "new-session", "-d", "-s", SESSION, "-c", WORKDIR, cmd]) + subprocess.run(["tmux", "pipe-pane", "-o", "-t", SESSION, f"cat >> '{LOG_DIR}/{SESSION}.log'"]) + log(f"started. attach: tmux attach -t {SESSION}") + + if BACKEND == "opencode": + time.sleep(8) + _ping(SESSION, STARTUP_PROMPT) + + +def main(): + cmd = sys.argv[1] if len(sys.argv) > 1 else "start" + + if cmd == "start": + start("resume") + elif cmd == "fresh": + start("fresh") + elif cmd == "stop": + if session_alive(): + log(f"killing {SESSION}") + subprocess.run(["tmux", "kill-session", "-t", SESSION]) + else: + log(f"{SESSION} not running") + elif cmd == "status": + state = "RUNNING" if session_alive() else "stopped" + log(f"{SESSION}: {state}") + subprocess.run(f"ps -eo pid,etime,args | grep '[r]emote-control {SESSION}' || true", shell=True) + if BACKEND == "claude": + log(f"resume id: {resume_id()} (file: {ID_FILE})") + log(f"backend: {BACKEND} model: {LOOP_MODEL or ''}") + elif cmd == "attach": + os.execvp("tmux", ["tmux", "attach", "-t", SESSION]) + else: + print("usage: launch-assistant.py start|fresh|stop|status|attach") + + +if __name__ == "__main__": + main() diff --git a/cc-ci-plan/launch-assistant.sh b/cc-ci-plan/launch-assistant.sh index 4054ca0..d2f5e20 100755 --- a/cc-ci-plan/launch-assistant.sh +++ b/cc-ci-plan/launch-assistant.sh @@ -1,104 +1,3 @@ #!/usr/bin/env bash -# -# launch-assistant.sh — start/resume the cc-ci ASSISTANT session in tmux under remote-control. -# -# The Assistant is a general-purpose, remote-controllable Claude session that shares the cc-ci -# workspace + access with the orchestrator, but is NOT on a loop. It sits idle until the orchestrator -# (or the operator) hands it a plan/task; it does the task against the workspace, reports back, and -# waits for the next one. Modelled on launch-orchestrator.sh. -# -# Naming: tmux session AND remote-control name are both "cc-ci-assistant" (matching -# cc-ci-orch / cc-ci-builder / cc-ci-adv / cc-ci-watchdog). -# -# Usage: -# ./launch-assistant.sh start # resume the persistent assistant session (DEFAULT); creates it on first run -# ./launch-assistant.sh fresh # start a NEW assistant session (new id) -# ./launch-assistant.sh status # show tmux + remote-control state -# ./launch-assistant.sh attach # tmux attach (Ctrl-b d to detach) -# ./launch-assistant.sh stop # kill the tmux session (conversation persists on disk) -set -euo pipefail - -SESSION="${ASSISTANT_SESSION:-cc-ci-assistant}" # tmux session name == remote-control name -WORKDIR="${ASSISTANT_DIR:-/srv/cc-ci}" # same workspace as the orchestrator -CLAUDE_BIN="${CLAUDE_BIN:-/home/loops/.local/bin/claude}" -# --dangerously-skip-permissions is blocked for root (use the env var there); as a non-root user the -# flag works. Mirror launch.sh's detection. -if [ "$(id -u)" = "0" ]; then export CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=1; CLAUDE_FLAGS="${CLAUDE_FLAGS:-}"; -else CLAUDE_FLAGS="${CLAUDE_FLAGS:---dangerously-skip-permissions}"; fi -ASSISTANT_MODEL="${ASSISTANT_MODEL:-sonnet}" # the assistant runs on sonnet (cheaper for task work) -REMOTE_CONTROL="${REMOTE_CONTROL:-1}" # 1 => --remote-control (viewable at claude.ai/code) -LOG_DIR="${LOG_DIR:-/srv/cc-ci/.cc-ci-logs}" -ID_FILE="${ASSISTANT_ID_FILE:-$LOG_DIR/.assistant-session-id}" -# Startup brief: defines the assistant's role. Injected as the session's first/next turn. No single quotes. -STARTUP_PROMPT="${ASSISTANT_STARTUP_PROMPT-You are the cc-ci ASSISTANT — a general-purpose helper sharing the cc-ci workspace (/srv/cc-ci) and access (ssh cc-ci, .testenv, the plan files) with the orchestrator. You are NOT on a loop and you do NOT supervise the Builder/Adversary loops. Sit idle until the orchestrator or operator hands you a specific plan or task; then do it carefully, report the result, and wait for the next one. Respect single-writer discipline: the loops own the cc-ci product-repo clones (/srv/cc-ci/cc-ci, /srv/cc-ci/cc-ci-adv) — do not edit those unless a task explicitly says to. If you have just (re)launched and have no pending task, briefly confirm you are online and idle, then wait.}" - -log() { printf '[assistant %(%H:%M:%S)T] %s\n' -1 "$*"; } -die() { log "ERROR: $*"; exit 1; } -session_alive() { tmux has-session -t "$SESSION" 2>/dev/null; } - -preflight() { - command -v tmux >/dev/null 2>&1 || die "missing dependency: tmux" - command -v "$CLAUDE_BIN" >/dev/null 2>&1 || die "claude CLI not found at $CLAUDE_BIN (set CLAUDE_BIN)" - [[ -d "$WORKDIR" ]] || die "workdir not found: $WORKDIR" - mkdir -p "$LOG_DIR" - # seed a stable session id on first ever launch - [[ -f "$ID_FILE" ]] || cat /proc/sys/kernel/random/uuid > "$ID_FILE" -} - -sid() { cat "$ID_FILE" 2>/dev/null; } -# does a transcript already exist for this id? (project dir derived from WORKDIR, e.g. -srv-cc-ci) -have_transcript() { - local key; key="$(printf '%s' "$WORKDIR" | sed 's#/#-#g')" - [[ -f "$HOME/.claude/projects/$key/$(sid).jsonl" ]] -} - -# $1 = resume|fresh -start() { - local mode="${1:-resume}" - preflight - if session_alive; then - log "$SESSION already running — leaving it (use '$0 stop' first to relaunch)"; return 0 - fi - local rc="" sess="" id; id="$(sid)" - [[ "$REMOTE_CONTROL" == "1" ]] && rc="--remote-control '$SESSION'" - if [[ "$mode" == "fresh" ]]; then - id="$(cat /proc/sys/kernel/random/uuid)"; echo "$id" > "$ID_FILE" - sess="--session-id '$id'"; log "starting $SESSION FRESH (new id=$id)" - elif have_transcript; then - sess="--resume '$id'"; log "starting $SESSION (resume id=$id)" - else - sess="--session-id '$id'"; log "starting $SESSION (first run, pin id=$id)" - fi - local prompt_arg="" - [[ -n "$STARTUP_PROMPT" ]] && prompt_arg="'$STARTUP_PROMPT'" - local model=""; [[ -n "$ASSISTANT_MODEL" ]] && model="--model '$ASSISTANT_MODEL'" - tmux new-session -d -s "$SESSION" -c "$WORKDIR" \ - "$CLAUDE_BIN $sess $model $rc $CLAUDE_FLAGS $prompt_arg" - tmux pipe-pane -o -t "$SESSION" "cat >> '$LOG_DIR/$SESSION.log'" - log "started. status: $0 status | attach: tmux attach -t $SESSION | id: $id" -} - -case "${1:-start}" in - start) start resume ;; - fresh) start fresh ;; - stop) if session_alive; then log "killing $SESSION"; tmux kill-session -t "$SESSION" || true; else log "$SESSION not running"; fi ;; - status) - if session_alive; then log "$SESSION: RUNNING"; ps -eo pid,etime,args | grep "[r]emote-control $SESSION" || true - else log "$SESSION: stopped"; fi - log "session id: $(sid) (file: $ID_FILE)" ;; - attach) exec tmux attach -t "$SESSION" ;; - *) - cat <> '{log_path}'"]) def ping_session(session, msg, submit_key="Enter"): """Type a message into a tmux session and submit it. - submit_key: "Enter" for claude (default); "C-s" for opencode (Ctrl+S sends in opencode TUI). - Retries the submit key until the typed prefix is no longer visible in the input area. + submit_key: "Enter" for claude; "C-m" for opencode (Ctrl+M = Enter). + Retries the submit key until the typed prefix is no longer visible in the content area. + opencode renders the input in the content area, so we check more lines. """ if not session_alive(session): return prefix = msg[:28] subprocess.run(["tmux", "send-keys", "-t", session, "-l", "--", msg], capture_output=True) time.sleep(0.5) - for _ in range(5): + for _ in range(10): subprocess.run(["tmux", "send-keys", "-t", session, submit_key], capture_output=True) time.sleep(1) - if prefix not in capture_pane(session, 4): + # Check the top 20 lines of content (not just last 4 bottom UI) + if prefix not in capture_pane(session, 20): return # message was accepted - subprocess.run(["tmux", "send-keys", "-t", session, "C-m"], capture_output=True) - time.sleep(0.5) # ── phase helpers ───────────────────────────────────────────────────────────── @@ -217,38 +236,40 @@ def start_agent(role, session, workdir): model_flag = f"--model '{LOOP_MODEL}'" if LOOP_MODEL else "" + session_cwd = workdir + if BACKEND == "claude": rc = f"--remote-control '{session}'" if REMOTE_CONTROL else "" cmd = f"{CLAUDE_BIN} {rc} {model_flag} {CLAUDE_FLAGS} \"$(cat '{kf}')\"" log(f"starting {session} (backend=claude, phase={pid}, plan={plan}, model={LOOP_MODEL or 'default'})") elif BACKEND == "opencode": - # Plain `opencode` (no subcommand) launches the persistent TUI and connects to the - # shared server automatically. `opencode attach` requires a TTY and exits in tmux; - # the plain TUI works because tmux allocates a PTY for the pane's child process. - # --dir pins the working directory; the kickoff is sent via ping_session after startup. - # Note: --dir causes opencode to exit immediately (likely a non-git-root issue). - # The working directory is set via tmux -c instead; opencode uses that as its cwd. + # Attach each TUI to the shared opencode web server so sessions are recorded the same + # way as browser-created sessions, including a populated `path` in the DB. + # We still pin the visible project root with --dir, while the kickoff instructions use + # absolute repo paths for builder/adversary work. + session_cwd = "/srv/cc-ci-orch/cc-ci" cmd = ( f"set -a; . /srv/cc-ci/.testenv; set +a; " - f"NO_COLOR=1 {OPENCODE_BIN} {model_flag}" + f"NO_COLOR=1 {OPENCODE_BIN} attach {OPENCODE_SERVER} --dir {session_cwd}" ) log(f"starting {session} (backend=opencode, phase={pid}, model={LOOP_MODEL or 'default'})") log(f" visible at http://oc.commoninternet.net (tailnet only)") else: die(f"unknown BACKEND '{BACKEND}' — set LOOP_BACKEND=claude or LOOP_BACKEND=opencode") - subprocess.run(["tmux", "new-session", "-d", "-s", session, "-c", workdir, cmd]) + subprocess.run(["tmux", "new-session", "-d", "-s", session, "-c", session_cwd, cmd]) pipe_to_log(session, f"{LOG_DIR}/{session}.log") - # opencode: send a short bootstrap once the TUI is ready (Ctrl+S submits in opencode). + # opencode: send a short bootstrap once the TUI is ready. + # opencode TUI uses C-m (Ctrl+M = Enter) to submit messages. # The full kickoff lives in the kickoff file; we point to it to stay under send-keys limits. if BACKEND == "opencode": - time.sleep(8) # opencode TUI needs more time to connect to the server than 4s + time.sleep(12) # opencode TUI needs more time to connect to the server bootstrap = ( f"Your full kickoff prompt is in {kf} — read it now with: " f"`cat '{kf}'` — then follow its instructions exactly." ) - ping_session(session, bootstrap, submit_key="C-s") + ping_session(session, bootstrap, submit_key="C-m") def start_loops(): start_agent("builder", BUILDER_SESSION, BUILDER_DIR) @@ -279,7 +300,7 @@ def heal_session(role, session, workdir): start_agent(role, session, workdir) return - if LIMIT_RE.search(pane): + if BACKEND != "opencode" and LIMIT_RE.search(pane): log(f"limit-stall on {role} ({session}) — nudging to resume") ping_session(session, "watchdog: the usage/spend limit appears lifted — RESUME your loop now. " @@ -289,10 +310,37 @@ def heal_session(role, session, workdir): # ── stall detection ─────────────────────────────────────────────────────────── _idle_since: dict[str, float] = {} +_limit_nudged_at: dict[str, float] = {} + +def _maybe_nudge_limit(role, session, pane): + if not LIMIT_RE.search(pane): + return False + + now = time.time() + last = _limit_nudged_at.get(session, 0.0) + if now - last < 300: + return True + + _limit_nudged_at[session] = now + log(f"limit-stall on {role} ({session}) — nudging to resume") + ping_session( + session, + "watchdog: the usage/spend limit appears lifted or is about to reset. " + "RESUME your loop now. Pull latest, re-read your phase STATUS/REVIEW files, " + "and continue from where you stopped; re-arm your loop pacing.", + submit_key=_SUBMIT, + ) + return True def _parse_waiting_until(pane): """Extract the epoch timestamp from a WAITING-UNTIL marker, or None.""" - m = re.search(r"WAITING-UNTIL:\s*(\S+)", pane) + if BACKEND == "opencode": + line = _last_nonempty_line(pane) + if not line.startswith("WAITING-UNTIL:"): + return None + m = re.search(r"WAITING-UNTIL:\s*(\S+)", line) + else: + m = re.search(r"WAITING-UNTIL:\s*(\S+)", pane) if not m: return None try: @@ -305,12 +353,19 @@ def _parse_waiting_until(pane): def stall_check_one(role, session, workdir): if not session_alive(session): _idle_since[session] = 0.0 + _limit_nudged_at[session] = 0.0 return now = time.time() pane = capture_pane(session, 40) - if ACTIVE_RE.search(pane): + if BACKEND == "opencode" and _maybe_nudge_limit(role, session, pane): + _idle_since[session] = now + return + + if ACTIVE_RE.search(pane) or (BACKEND == "opencode" and ( + RECENT_ACTIVITY_RE.search(pane) or _log_recently_touched(session, OPENCODE_LOG_GRACE) + )): _idle_since[session] = 0.0 return @@ -326,7 +381,8 @@ def stall_check_one(role, session, workdir): return reason = f"past its WAITING-UNTIL by {int(now - until)}s — self-wake did not fire" else: - if idle < STALL_IDLE: + stall_idle = OPENCODE_STALL_IDLE if BACKEND == "opencode" else STALL_IDLE + if idle < stall_idle: return reason = f"idle {int(idle)}s with no WAITING-UNTIL marker" @@ -405,7 +461,7 @@ def _show_pushed(path): return r.stdout return "" -_SUBMIT = "C-s" if BACKEND == "opencode" else "Enter" +_SUBMIT = "C-m" if BACKEND == "opencode" else "Enter" def handoff_check(): global _last_sha, _adv_inbox_seen, _builder_inbox_seen diff --git a/cc-ci-plan/plan-phase6-reconcile-all-mirrors.md b/cc-ci-plan/plan-phase6-reconcile-all-mirrors.md new file mode 100644 index 0000000..6d97177 --- /dev/null +++ b/cc-ci-plan/plan-phase6-reconcile-all-mirrors.md @@ -0,0 +1,44 @@ +# cc-ci Phase 6 — reconcile all enrolled recipe mirrors + +**Status:** QUEUED — runs after Builder/Adversary phase 5 is complete. +**Owner:** `cc-ci-assistant` session only. Not a Builder/Adversary loop phase. +**This file:** `/srv/cc-ci-orch/cc-ci-plan/plan-phase6-reconcile-all-mirrors.md` + +--- + +## 1. Goal + +Bring every enrolled `recipe-maintainers/` mirror back into the intended reconciled state +without involving the Builder/Adversary loops. This is a post-build operational cleanup pass, not a +loop-verification phase. + +## 2. Definition of Done + +- [ ] Enumerate the current enrolled recipe mirrors and the expected upstream source for each. +- [ ] For all mirrors, confirm `main` matches upstream `main` at the intended sync point. +- [ ] Identify stale open PRs where the change is already in upstream main and close them. +- [ ] Identify superseded PRs and replace/close them according to current reconcile behavior. +- [ ] Record the final per-recipe reconcile outcome in a written summary for the operator. +- [ ] Leave no ad-hoc test branches or temporary working copies behind. + +## 3. Method + +- Use the assistant's own working copies or scratch clones; do not edit the Builder/Adversary loop + clones unless explicitly required. +- Prefer existing reconcile helpers and documented workflows already used by cc-ci. +- Keep changes minimal and operationally reversible. +- If a mirror exposes a genuine product bug instead of a routine reconcile issue, record it clearly + and stop short of risky improvisation. + +## 4. Deliverables + +- A concise operator-facing summary listing: + - every mirror checked + - any PRs closed/replaced + - any mirror left needing manual attention + +## 5. Guardrails + +- No loop/watchdog changes are part of this phase. +- No merges without explicit operator instruction. +- No secret values in notes, commits, or summaries. diff --git a/cc-ci-plan/plan-phase7-upgrade-three-recipes.md b/cc-ci-plan/plan-phase7-upgrade-three-recipes.md new file mode 100644 index 0000000..87433cd --- /dev/null +++ b/cc-ci-plan/plan-phase7-upgrade-three-recipes.md @@ -0,0 +1,49 @@ +# cc-ci Phase 7 — run targeted upgrades on n8n, ghost, and matrix-synapse + +**Status:** QUEUED — runs after phase 6. +**Owner:** `cc-ci-assistant` session only. Not a Builder/Adversary loop phase. +**This file:** `/srv/cc-ci-orch/cc-ci-plan/plan-phase7-upgrade-three-recipes.md` + +--- + +## 1. Goal + +Run the targeted post-build recipe upgrade work for: + +- `n8n` +- `ghost` +- `matrix-synapse` + +This is operational recipe-maintenance work and should be driven by the assistant, not the +Builder/Adversary loops. + +## 2. Definition of Done + +- [ ] Research the current upstream upgrade target for each of the three recipes. +- [ ] For each recipe, determine whether an upgrade is actually available and worth opening. +- [ ] Run the established upgrade workflow for each upgradeable recipe. +- [ ] For each recipe PR opened, verify the expected cc-ci testing path is triggered and record the outcome. +- [ ] Summarize all opened PRs, blocked upgrades, and any follow-up needed from the operator. +- [ ] Leave the assistant session idle and ready for the next assignment when complete. + +## 3. Method + +- Prefer the existing assistant-accessible upgrade tooling and documented workflows. +- Default to the smallest correct per-recipe change set. +- Keep one clear record per recipe: checked, upgradeable or not, PR opened or not, verified or blocked. +- If a stale cc-ci test is the only blocker, report that explicitly instead of improvising beyond the + established workflow. + +## 4. Deliverables + +- A summary covering: + - `n8n` + - `ghost` + - `matrix-synapse` +- For each: upstream target, action taken, PR URL if any, verification result, and remaining risk. + +## 5. Guardrails + +- Never merge PRs. +- Do not use the Builder/Adversary loop clones as the assistant's scratch workspace. +- Record blockers clearly rather than pushing through uncertain migrations.