feat(opencode): wire 29-point CLI lifecycle scripts, drift-C reconciler, and skill docs

This commit is contained in:
2026-08-29 14:59:01 +09:00
parent 7341186ef9
commit 94af7f6b8a
14 changed files with 303 additions and 35 deletions
@@ -317,11 +317,13 @@ fi
# atomic_dump_yaml(flock + temp+rename) 로 같은 소스를 돌린다. atomic 래퍼에서는
# 'actions' 가 없으면 SystemExit(0) 으로 쓰기를 건너뛴다 (불필요한 재포맷 방지).
read -r -d '' RECON_SRC <<'PYEOF' || true
import os, json, glob, subprocess, time, sqlite3, re
import os, json, glob, subprocess, time, sqlite3, re, shutil
from datetime import datetime, timezone
import yaml
from lib_py.verify_session import verify_session_uuid, workspace_key
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
def _slug(path):
if not path:
@@ -518,7 +520,7 @@ if herdr_confirmed:
agent = None
role = 'creator'
for r_name in ('creator', 'planner', 'reviewer'):
for a_name in ('claude', 'agy', 'hermes', 'grok'):
for a_name in ('claude', 'agy', 'hermes', 'grok', 'opencode'):
if name.endswith(f"-{r_name}-{a_name}"):
role = r_name
agent = a_name
@@ -537,7 +539,7 @@ if herdr_confirmed:
if tok.startswith('MAM_MANAGED='):
managed_path = tok.split('=', 1)[1]
if os.path.realpath(managed_path) == os.path.realpath(workspace_root):
for a_name in ('claude', 'agy', 'hermes', 'grok'):
for a_name in ('claude', 'agy', 'hermes', 'grok', 'opencode'):
if a_name in pm_check.get('cmd', '') or a_name in pm_check.get('cmd_full', ''):
agent = a_name
break
@@ -598,6 +600,9 @@ if herdr_confirmed:
elif agent == 'hermes':
entry['child_pid'] = 0
entry['hermes_conversation_id_own'] = None
elif agent == 'opencode':
entry['child_pid'] = 0
entry['opencode_session_id_own'] = None
d.setdefault('herdr_sessions', []).append(entry)
yaml_session_names.add(name)
drifts.append({'class': 'B', 'name': name,
@@ -608,7 +613,7 @@ def row_agent(s):
return agent_of_row(s)
OWN_KEY_BY_AGENT = {
a: _get_own_key(a) for a in ('claude', 'agy', 'hermes', 'grok')
a: _get_own_key(a) for a in ('claude', 'agy', 'hermes', 'grok', 'opencode')
}
# === drift C0: 지정된 ID 는 발견이 아니라 '확인'만 필요하다 ===
@@ -779,6 +784,118 @@ for s in d.get('herdr_sessions', []):
else:
_pin_and_verify_resume(s, 'hermes', cwd, uuid, degraded=True)
# === drift C (opencode): opencode 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []):
if row_agent(s) != 'opencode':
continue
if s.get('status') != 'running':
continue
if s.get('opencode_session_id_own'):
continue
cwd = (s.get('pane') or {}).get('cwd', '')
if not cwd:
continue
# Resolve DB path via shared OpenCodeAgentAdapter helper with fallbacks
odb = None
try:
op_adapter = get_adapter('opencode')
if op_adapter:
odb = op_adapter._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))
except Exception:
pass
if not odb:
opencode_base = f"{home}/.local/share/opencode"
candidates = [
f"{opencode_base}/opencode.db",
f"{opencode_base}/storage.db",
]
for cand in candidates:
if os.path.exists(cand):
odb = cand
break
if not odb and os.path.isdir(f"{opencode_base}/project"):
for root, _, files in os.walk(f"{opencode_base}/project"):
for f in files:
if f.endswith(".db"):
odb = os.path.join(root, f)
break
if odb and os.path.exists(odb):
break
if not odb or not os.path.exists(odb):
continue
epoch_threshold = s.get('herdr_session_epoch', 0)
sibling_claimed = [
other.get('opencode_session_id_own')
for other in d.get('herdr_sessions', [])
if other is not s
and (other.get('pane') or {}).get('cwd') == cwd
and other.get('status') not in ('stopped', 'terminated')
and other.get('opencode_session_id_own')
]
s_eval = dict(s)
s_eval['_sibling_claimed_uuids'] = sibling_claimed
valid_candidates = []
try:
conn = sqlite3.connect(odb)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(session)")
cols = [col[1] for col in cursor.fetchall()]
if cols:
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
query = "SELECT id FROM session"
params = []
where_clauses = []
if cwd_col and cwd:
where_clauses.append(f"{cwd_col} = ?")
params.append(cwd)
if time_col and epoch_threshold:
if time_col == "time_created":
where_clauses.append(f"{time_col} >= ?")
params.append(int(float(epoch_threshold) * 1000))
else:
where_clauses.append(f"{time_col} >= ?")
params.append(epoch_threshold)
if where_clauses:
query += " WHERE " + " AND ".join(where_clauses)
if time_col:
query += f" ORDER BY {time_col} DESC"
query += " LIMIT 20"
rows = cursor.execute(query, params).fetchall()
conn.close()
for (uuid,) in rows:
if uuid in (sibling_claimed or []):
continue
if verify_session_uuid(cwd, 'opencode', uuid, s_eval, mode="discover"):
valid_candidates.append(uuid)
else:
conn.close()
except Exception:
pass
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "opencode" "{cwd}"']
rc = subprocess.run(cmd).returncode
if rc == 0:
_pin_and_verify_resume(s, 'opencode', cwd, uuid, degraded=False)
elif rc == 1:
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
else:
_pin_and_verify_resume(s, 'opencode', cwd, uuid, degraded=True)
result = {
'timestamp': now_iso,
'yaml_path': yaml_path,