feat(assistant): add opencode launcher and phase 6/7 plans

This commit is contained in:
autonomic-bot
2026-06-01 12:59:03 +00:00
parent df6ca04611
commit 24bf379b5b
5 changed files with 333 additions and 126 deletions

View File

@ -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 '<default>'}")
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()

View File

@ -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 <<EOF
cc-ci assistant launcher — a remote-controllable, NON-loop helper sharing the orchestrator's workspace.
$0 start resume (or first-run create) the assistant session in tmux + remote-control (default)
$0 fresh start a NEW assistant session (new id)
$0 status show tmux + remote-control state and the session id
$0 attach tmux attach to the session
$0 stop kill the tmux session (conversation persists on disk)
Env: SESSION=$SESSION WORKDIR=$WORKDIR REMOTE_CONTROL=$REMOTE_CONTROL CLAUDE_BIN=$CLAUDE_BIN
The orchestrator hands it plans/tasks; it does them, reports, and waits. Not on a loop.
EOF
;;
esac
# Thin wrapper — delegates everything to launch-assistant.py in the same directory.
exec python3 "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/launch-assistant.py" "$@"

View File

@ -98,9 +98,13 @@ PHASES = [p.split("|") for p in PHASES_SPEC.split(";")]
PHASE_IDX_FILE = os.environ.get("PHASE_IDX_FILE", f"{LOG_DIR}/.phase-idx")
# Regex patterns for session-state detection
ACTIVE_RE = re.compile(r"esc to interrupt|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Running tool")
ACTIVE_RE = re.compile(r"esc to interrupt|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Running tool|▣|Build ·|· \d+")
LIMIT_RE = re.compile(r"spend limit|usage limit|limit reached|reached your .*limit|out of (credits|tokens)", re.I)
FATAL_RE = re.compile(r"redacted_thinking|blocks cannot be modified|cannot be modified", re.I)
RECENT_ACTIVITY_RE = re.compile(r"thinking|inferring|running tool|remote control (active|connecting)|tool call|schedulewake?up", re.I)
OPENCODE_STALL_IDLE = int(os.environ.get("OPENCODE_STALL_IDLE", 900))
OPENCODE_LOG_GRACE = int(os.environ.get("OPENCODE_LOG_GRACE", 180))
# ── logging ───────────────────────────────────────────────────────────────────
@ -127,27 +131,42 @@ def capture_pane(name, lines=40):
r = subprocess.run(["tmux", "capture-pane", "-pt", name], capture_output=True, text=True)
return "\n".join(r.stdout.splitlines()[-lines:]) if r.returncode == 0 else ""
def _session_log_path(session):
return Path(LOG_DIR) / f"{session}.log"
def _log_recently_touched(session, age_seconds):
try:
return (time.time() - _session_log_path(session).stat().st_mtime) <= age_seconds
except FileNotFoundError:
return False
def _last_nonempty_line(text):
for line in reversed(text.splitlines()):
if line.strip():
return line.strip()
return ""
def pipe_to_log(session, log_path):
subprocess.run(["tmux", "pipe-pane", "-o", "-t", session, f"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

View File

@ -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/<recipe>` 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.

View File

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