#!/usr/bin/env bash
# Agentlas One persistent-session switch.
#
# Enabling or disabling One only creates or removes a state file. Each runtime's
# per-turn instruction entrypoint reads that state; the entrypoint provides persistence.
#
#   agentlas-one install        Wire the status line and Stop hook once.
#   agentlas-one on [name]      Enable One and seed its workspace.
#   agentlas-one off            Disable One.
#   agentlas-one uninstall [--purge]
#                               Back up, then remove every One footprint (hooks,
#                               directive blocks, status line, state). --purge
#                               also deletes the One workspace directory.
#   agentlas-one name <name>    Rename One.
#   agentlas-one status         Show current state.
#   agentlas-one status --runtimes [--json]
#                               Per-runtime support matrix (registry + this machine).
#   agentlas-one status --drift [--now]
#                               Last runtime-drift report (pins vs ACP registry/matrix);
#                               --now runs the check immediately.
#   agentlas-one memory         Measure memory and experience chips.
#   agentlas-one remember <text> Emit a memory-candidate ticket.
#   agentlas-one curate         Curate tickets into durable memory candidates.
#   agentlas-one seed           Idempotently seed the single-agent workspace.
#   agentlas-one statusline     Internal status-line renderer.
#   agentlas-one stop-hook      Internal session-end checkpoint.
#
# Workspace implementation: agentlas_cloud/one_workspace.py.
set -uo pipefail

ONE_DIR="${AGENTLAS_ONE_DIR:-$HOME/.agentlas/one}"
STATE_FILE="$ONE_DIR/state.json"
CLAUDE_MD="${AGENTLAS_ONE_CLAUDE_MD:-$HOME/.claude/CLAUDE.md}"
SETTINGS="${AGENTLAS_ONE_SETTINGS:-$HOME/.claude/settings.json}"
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"

BEGIN_MARK="<!-- AGENTLAS-ONE:BEGIN -->"
END_MARK="<!-- AGENTLAS-ONE:END -->"

# Resolve the interpreter the way every sibling launcher does: the runtime's own
# verified shim FIRST, then PATH. This launcher was the only one that took
# `python3` straight off PATH, and on a stock Mac that is 3.9.6 — old enough to
# fail on syntax the rest of the product is free to use. The symptom was not an
# error message but a quiet "(verification failed)" on `agentlas-one on`.
_one_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
_one_resolve_python() {
  local candidate
  for candidate in "${AGENTLAS_PYTHON:-}" "${HEPHAESTUS_PYTHON:-}" "$_one_root/bin/python3" python3 python; do
    [ -n "$candidate" ] || continue
    if "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)' >/dev/null 2>&1; then
      printf '%s' "$candidate"
      return 0
    fi
  done
  printf '%s' "python3"
}
PY="$(_one_resolve_python)"

die() { printf '%s\n' "$*" >&2; exit 1; }

# ---------------------------------------------------------------- state

state_read() {
  # $1 = key, $2 = default
  [ -f "$STATE_FILE" ] || { printf '%s' "${2:-}"; return; }
  "$PY" - "$STATE_FILE" "$1" "${2:-}" <<'PYEOF' 2>/dev/null || printf '%s' "${2:-}"
import json, sys
try:
    with open(sys.argv[1], encoding="utf-8") as fh:
        data = json.load(fh)
except Exception:
    sys.stdout.write(sys.argv[3]); raise SystemExit(0)
value = data.get(sys.argv[2], sys.argv[3])
sys.stdout.write("" if value is None else str(value))
PYEOF
}

# PRD §4.21 — 진입 파일·훅 파일을 고칠 때마다 시각 접미사 백업을 만들고 **아무도 지우지
# 않았다**. 실측 2026-08-23: ~/.claude 25개, ~/.codex 18개, ~/.gemini 400개(합 4.9MB).
# 설정 백업에는 훅 환경값이 들어갈 수 있으므로 무한히 쌓아 둘 물건이 아니다.
# 되돌리기는 최근 몇 벌이면 충분하다 — 만들되, 그 자리에서 오래된 것을 거둔다.
ONE_BACKUP_KEEP="${AGENTLAS_ONE_BACKUP_KEEP:-3}"

one_backup_file() {
  # $1 = 원본 경로. 백업을 만들고 같은 원본의 오래된 백업을 KEEP 개만 남긴다.
  local src="$1"
  [ -f "$src" ] || return 0
  cp "$src" "$src.one-backup-$(date +%s)" 2>/dev/null || return 0
  # 최신 KEEP 개를 뺀 나머지를 지운다. 이름의 시각이 곧 정렬 키다.
  local old
  old="$(ls -1 "$src".one-backup-* 2>/dev/null | sort -r | tail -n +$((ONE_BACKUP_KEEP + 1)))"
  [ -n "$old" ] || return 0
  printf '%s\n' "$old" | while IFS= read -r stale; do
    [ -n "$stale" ] && rm -f "$stale" 2>/dev/null || true
  done
  return 0
}

state_write() {
  # $1 = name, $2 = on ("true"/"false"); 생략하면 이전 값을 지킨다.
  #
  # PRD §4.15 — 예전에는 무조건 on:true 를 썼다. 그래서 **꺼져 있는 머신에서 이름만 바꿔도**
  # 상태 파일은 켜짐이 됐고(이름 변경 경로가 이 함수를 부른다), 기억 훅은 모든 프롬프트에
  # 개인 기억을 주입하면서 화면에는 "One 은 여전히 꺼져 있습니다"라고 출력했다.
  # 저장은 실제 상태를 쓴다.
  mkdir -p "$ONE_DIR" || die "Cannot create the One directory: $ONE_DIR"
  ONE_NAME="$1" ONE_ON="${2:-}" "$PY" - "$STATE_FILE" <<'PYEOF'
import json, os, sys, time
path = sys.argv[1]
prev = {}
if os.path.exists(path):
    try:
        with open(path, encoding="utf-8") as fh:
            prev = json.load(fh)
    except Exception:
        prev = {}
requested_on = (os.environ.get("ONE_ON") or "").strip().lower()
if requested_on in ("true", "1", "yes"):
    on = True
elif requested_on in ("false", "0", "no"):
    on = False
else:
    on = bool(prev.get("on", False))
state = {
    "contractVersion": "1.0.0",
    "on": on,
    "name": os.environ["ONE_NAME"],
    "mode": prev.get("mode", "assistant"),
    "since": prev.get("since") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
    "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
    json.dump(state, fh, ensure_ascii=False, indent=2)
    fh.write("\n")
os.replace(tmp, path)
os.chmod(path, 0o600)
PYEOF
}

# ---------------------------------------------------------------- directive block

directive_block() {
  # Use a quoted heredoc so the shell cannot consume backticks or dollar signs.
  ONE_NAME="$1" ONE_BEGIN="$BEGIN_MARK" ONE_END="$END_MARK" "$PY" -c '
import os, sys
body = sys.stdin.read().replace("__ONE_NAME__", os.environ["ONE_NAME"])
sys.stdout.write(os.environ["ONE_BEGIN"] + "\n" + body + os.environ["ONE_END"] + "\n")
' <<'EOF'
# __ONE_NAME__ — Agentlas One enabled

`agentlas-one on` owns this block and `off` removes it. Do not edit it directly.

## Identity
You are the owner's persistent personal agent operating the Agentlas engine, not a generic chat assistant.
Keep the same identity across sessions, projects, and hosts. Do not repeat introductions.
Begin user-facing answers with `**[__ONE_NAME__]**`; omit the prefix from intermediate tool updates.

## Available capabilities — inspect these before claiming a limitation
| Need | Capability |
|---|---|
| Expertise | `workforce.goal_context` -> reuse -> search only genuine gaps -> validate -> prepare |
| Public experts | `marketplace.search_agents`, `hephaestus_hub_invoke`, `/hep-hub` |
| Owner assets | `cargo.*`, `/hep-cloud` |
| Apparently missing tool | `agentlas_resolve_plugins`; uninstalled does not mean nonexistent, and installation is the user's decision |
| Repeated work | `/hep-graph` to build and run automation |
| Browser work | `/hep-browser` |
| New capability | `/hep-build` -> `/hep-upload` |
| Code location and impact | `context_locate`, `context_slice`, `context_impact`, `context_verify` |
| Memory | `agentlas_memory_preflight`, `agentlas_memory_ticket`; never write durable memory directly |

Operating procedures for these capabilities live in `~/.agentlas/one/skills/agentlas-operations/SKILL.md`
(tool index: `INDEX.md` beside it, generated from the live registry). Follow it for staffing, automation,
asset, and memory work instead of improvising.

## Operating rules
1. **Reuse the roster first.** Reuse the bound roster before recruiting; recruitment is additive.
2. **Prevent dead ends.**
   1. Use machine-readable markers for causes; never infer a cause by parsing prose.
   2. Keep terminal actions unconditional; close, stop, and cancel must not depend on another state.
   3. Re-read state after acting. If evidence is unchanged, say so explicitly.
3. **Never claim an uncalled tool was called.** Query before claiming that something is absent.
4. Before irreversible actions such as deletion, sending, payment, or publication, prefer live measurement over memory. Ask when verification is unavailable.
5. When memories conflict, expose the conflict instead of silently choosing one.
6. When blocked, exhaust remaining means in order. If all fail, report exactly where and why using machine evidence.

## Memory events
At the end of any answer that produced a reusable learning, append one envelope for the runtime to turn into a curator ticket.
Never write durable memory directly. Use an empty `candidates` array when there is no learning.

After `## Memory Events`, emit a fenced JSON block:
`{"schema_version":"agentlas.memory-ticket.v1","turn_summary":"one safe sentence",`
`"candidates":[{"content":"one or two sentences","memory_kind":"fact|decision|preference|risk|procedure|hypothesis","suggested_scope":"user_identity|project|agent_repo|session","evidence":["file:line or command"]}]}`
When a learning REPLACES an earlier durable block, add `"supersedes":"<its h:16hex>"` to that candidate — the old block is then hidden from recall (never deleted). Only use a hash you saw in recall or the soul file.

Never include secrets, credentials, raw transcripts, or host-absolute paths. Unsupported facts, decisions, and procedures are downgraded to hypotheses. Imperative instructions are not memories and must not be emitted as candidates.

## Communication
Lead with the outcome. State what is unknown, and support limitations with evidence. Use plain but precise language without presenting inference as fact.
EOF
}

# Runtime-specific instruction entrypoints. Marker-block upserts provide the
# same contract on hosts without hooks. A runtime counts as present when its own
# config directory exists; the entrypoint file is then created if the runtime has
# not written one yet, because an absent file would silently mean no identity.
# Never create a file for a runtime that is not installed.
# ---- registry-driven paths (PRD 2026-08-15 Phase B-2) -----------------------
# contracts/runtime-registry.json is the single truth for "which instruction
# file / which hook file per runtime". This launcher reads it; the hardcoded
# lists below survive only as the fallback when no registry is reachable (a
# checkout without Python, a runtime home from before the registry existed).
# Env overrides keep their historical names: AGENTLAS_ONE_<ID>_MD /
# AGENTLAS_ONE_<ID>_HOOKS with ID = codex|gemini|opencode|openclaw|cursor and
# AGY for antigravity (tests and users already set them).
registry_json() {
  local f
  for f in "$HOME/.agentlas/runtime/current/contracts/runtime-registry.json" \
           "$OS_ROOT/contracts/runtime-registry.json"; do
    if [ -f "$f" ]; then printf '%s' "$f"; return 0; fi
  done
  return 1
}

# Emits: <id>\t<kind: entrypoint|hookfile|hookpack>\t<env-suffix>\t<expanded path>
registry_paths() {
  local reg; reg="$(registry_json)" || return 1
  ONE_REG="$reg" ONE_HOME="$HOME" "$PY" - <<'PYEOF' 2>/dev/null
import json, os
ENV = {"antigravity": "AGY", "codex": "CODEX", "gemini": "GEMINI", "opencode": "OPENCODE",
       "openclaw": "OPENCLAW", "cursor": "CURSOR", "goose": "GOOSE"}
home = os.environ["ONE_HOME"]
def expand(p):
    return home + p[1:] if p.startswith("~/") else p
try:
    data = json.load(open(os.environ["ONE_REG"], encoding="utf-8"))
except Exception:
    raise SystemExit(1)
for row in data.get("runtimes", []):
    rid = row.get("id")
    if rid == "claude-code" or row.get("role") != "tier1":
        continue
    suffix = ENV.get(rid, rid.upper().replace("-", "_"))
    ep = row.get("entrypoint")
    if ep:
        print("%s\tentrypoint\t%s\t%s" % (rid, suffix, expand(ep)))
    hooks = row.get("hooks") or {}
    shape, hf = hooks.get("shape"), hooks.get("file")
    if hf and shape == "hookpack-dir":
        print("%s\thookpack\t%s\t%s" % (rid, suffix, expand(hf)))
    elif hf and shape not in (None, "none", "claude-settings"):
        print("%s\thookfile\t%s\t%s" % (rid, suffix, expand(hf)))
PYEOF
}

# Resolve one registry path through its env override (AGENTLAS_ONE_<SUFFIX>_MD / _HOOKS).
registry_override() {  # $1=kind $2=suffix $3=default
  local var
  case "$1" in
    entrypoint) var="AGENTLAS_ONE_${2}_MD" ;;
    hookfile)   var="AGENTLAS_ONE_${2}_HOOKS" ;;
    hookpack)   case "$2" in GOOSE) var="AGENTLAS_ONE_GOOSE_PLUGIN" ;; OPENCLAW) var="AGENTLAS_ONE_OPENCLAW_HOOK" ;; *) var="AGENTLAS_ONE_${2}_HOOKPACK" ;; esac ;;
  esac
  printf '%s' "${!var:-$3}"
}

registry_entrypoint_files() {
  local id kind suffix path
  while IFS=$'\t' read -r id kind suffix path; do
    [ "$kind" = "entrypoint" ] || continue
    registry_override entrypoint "$suffix" "$path"; printf '\n'
  done < <(registry_paths)
}

registry_hook_files() {
  local id kind suffix path
  while IFS=$'\t' read -r id kind suffix path; do
    [ "$kind" = "hookfile" ] || continue
    registry_override hookfile "$suffix" "$path"; printf '\n'
  done < <(registry_paths)
}

runtime_entrypoints() {
  printf '%s\n' "$CLAUDE_MD"
  local extra
  local list
  if list="$(registry_entrypoint_files)" && [ -n "$list" ]; then
    :
  else
    list="$(printf '%s\n' \
      "${AGENTLAS_ONE_CODEX_MD:-$HOME/.codex/AGENTS.md}" \
      "${AGENTLAS_ONE_GEMINI_MD:-$HOME/.gemini/GEMINI.md}" \
      "${AGENTLAS_ONE_OPENCODE_MD:-$HOME/.config/opencode/AGENTS.md}" \
      "${AGENTLAS_ONE_OPENCLAW_MD:-$HOME/.openclaw/workspace/AGENTS.md}")"
  fi
  while IFS= read -r extra; do
    [ -n "$extra" ] || continue
    if [ -f "$extra" ] || [ -d "$(dirname "$extra")" ]; then
      printf '%s\n' "$extra"
    fi
  done <<< "$list"
}

# Hook JSON files One may hold an entry in (Claude settings first).
one_hook_files() {
  printf '%s\n' "$SETTINGS"
  local list
  if list="$(registry_hook_files)" && [ -n "$list" ]; then
    printf '%s\n' "$list"
  else
    printf '%s\n' \
      "${AGENTLAS_ONE_CURSOR_HOOKS:-$HOME/.cursor/hooks.json}" \
      "${AGENTLAS_ONE_CODEX_HOOKS:-$HOME/.codex/hooks.json}" \
      "${AGENTLAS_ONE_AGY_HOOKS:-$HOME/.gemini/config/hooks.json}"
  fi
}

block_install() {
  local name="$1" target
  while IFS= read -r target; do
    block_install_one "$name" "$target"
  done < <(runtime_entrypoints)
}

block_install_one() {
  local name="$1" CLAUDE_MD="$2"
  mkdir -p "$(dirname "$CLAUDE_MD")"
  [ -f "$CLAUDE_MD" ] || : > "$CLAUDE_MD"
  one_backup_file "$CLAUDE_MD"
  local block; block="$(directive_block "$name")"
  ONE_BLOCK="$block" ONE_BEGIN="$BEGIN_MARK" ONE_END="$END_MARK" \
    "$PY" - "$CLAUDE_MD" <<'PYEOF'
import os, re, sys
path = sys.argv[1]
begin, end = os.environ["ONE_BEGIN"], os.environ["ONE_END"]
block = os.environ["ONE_BLOCK"]
with open(path, encoding="utf-8") as fh:
    text = fh.read()
pattern = re.compile(re.escape(begin) + r".*?" + re.escape(end), re.S)
if pattern.search(text):
    text = pattern.sub(lambda _m: block, text)
else:
    text = (text.rstrip("\n") + "\n\n" if text.strip() else "") + block + "\n"
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
    fh.write(text)
os.replace(tmp, path)
PYEOF
}

block_remove() {
  local target
  while IFS= read -r target; do
    block_remove_one "$target"
  done < <(runtime_entrypoints)
}

block_remove_one() {
  local CLAUDE_MD="$1"
  [ -f "$CLAUDE_MD" ] || return 0
  grep -q "$BEGIN_MARK" "$CLAUDE_MD" 2>/dev/null || return 0
  one_backup_file "$CLAUDE_MD"
  ONE_BEGIN="$BEGIN_MARK" ONE_END="$END_MARK" "$PY" - "$CLAUDE_MD" <<'PYEOF'
import os, re, sys
path = sys.argv[1]
begin, end = os.environ["ONE_BEGIN"], os.environ["ONE_END"]
with open(path, encoding="utf-8") as fh:
    text = fh.read()
pattern = re.compile(r"\n*" + re.escape(begin) + r".*?" + re.escape(end) + r"\n*", re.S)
text = pattern.sub("\n", text)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
    fh.write(text.rstrip("\n") + "\n" if text.strip() else "")
os.replace(tmp, path)
PYEOF
}

# ---------------------------------------------------------------- status line

cmd_statusline() {
  # Claude Code supplies session JSON on stdin; otherwise use the current directory.
  local payload; payload="$(cat 2>/dev/null || true)"
  [ -f "$STATE_FILE" ] || exit 0
  local on; on="$(state_read on false)"
  [ "$on" = "True" ] || [ "$on" = "true" ] || exit 0

  local name since
  name="$(state_read name One)"
  since="$(state_read since '')"

  local drift; drift="$(drift_summary)"
  ONE_PAYLOAD="$payload" ONE_NAME="$name" ONE_SINCE="$since" ONE_DRIFT_LINE="$drift" \
  ONE_ORCH_FILE="$ORCH_FILE" "$PY" <<'PYEOF'
import json, os, sys, time

payload = os.environ.get("ONE_PAYLOAD", "")
project = ""
try:
    data = json.loads(payload) if payload.strip() else {}
    ws = data.get("workspace") or {}
    project = os.path.basename(ws.get("current_dir") or ws.get("project_dir") or "")
except Exception:
    project = ""
if not project:
    project = os.path.basename(os.getcwd())

parts = ["\033[32m●\033[0m \033[1m%s\033[0m" % os.environ["ONE_NAME"]]
if project:
    parts.append("\033[2m%s\033[0m" % project)

since = os.environ.get("ONE_SINCE", "")
if since:
    try:
        # Interpret both values through mktime to cancel local-time bias.
        started = time.mktime(time.strptime(since, "%Y-%m-%dT%H:%M:%SZ"))
        days = int((time.mktime(time.gmtime()) - started) // 86400)
        if days >= 1:
            parts.append("\033[2menabled for %d days\033[0m" % days)
    except Exception:
        pass

# Orchestrator/worker model policy. Showing it here is the difference between
# a setting that exists and one the operator can see is in force — the policy
# was invisible before, which is why it stayed empty on every machine.
try:
    with open(os.environ.get("ONE_ORCH_FILE", ""), encoding="utf-8") as fh:
        policy = json.load(fh)
except Exception:
    policy = {}
if isinstance(policy, dict) and policy:
    def _label(role):
        scope = policy.get(role) or {}
        return scope.get("pinnedModelId") or scope.get("maxTier") or ""
    orch, worker = _label("orchestrator"), _label("worker")
    if orch or worker:
        parts.append("\033[36m⚙ %s→%s\033[0m" % (orch or "auto", worker or "auto"))

drift = os.environ.get("ONE_DRIFT_LINE", "").strip()
if drift.startswith("runtime drift "):
    # e.g. "runtime drift 2 (version 1, health 1) — …" → "⚠ drift 2"
    count = drift.split()[2] if len(drift.split()) > 2 else "?"
    parts.append("\033[33m⚠ drift %s\033[0m" % count)

sys.stdout.write(" \033[2m·\033[0m ".join(parts))
PYEOF
}

# ---------------------------------------------------------------- orchestration

# Which model runs the orchestrator, and which runs the workers.
#
# Before this existed the only way to set a model policy was to hand-write
# AGENTLAS_MODEL_ALLOCATION_POLICY_JSON into the MCP server's launch
# environment. Nobody ever did, so `model.resolve_allocation` ran with an empty
# policy on every host and every worker silently inherited the orchestrator's
# frontier model — the exact opposite of why the allocator exists.
#
# The file is the single source both the MCP server and the status line read.
ORCH_FILE="$ONE_DIR/model-policy.json"

cmd_orch() {
  mkdir -p "$ONE_DIR"
  ORCH_FILE="$ORCH_FILE" "$PY" - "$@" <<'PYEOF'
import json, os, sys

path = os.environ["ORCH_FILE"]
ROLES = ("orchestrator", "worker")
TIERS = ("economy", "balanced", "frontier")

def load():
    try:
        with open(path, encoding="utf-8") as fh:
            data = json.load(fh)
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}

def show(policy):
    if not policy:
        print("no model policy set — every role runs on the host's active model")
        print('set one with:  agentlas-one orch orchestrator=frontier worker=economy')
        return
    for role in ROLES:
        scope = policy.get(role) or {}
        if not scope:
            print(f"{role:14} (inherits)")
            continue
        bits = []
        if scope.get("pinnedModelId"): bits.append(f"model={scope['pinnedModelId']}")
        if scope.get("maxTier"): bits.append(f"maxTier={scope['maxTier']}")
        if scope.get("maxEffort"): bits.append(f"maxEffort={scope['maxEffort']}")
        print(f"{role:14} {' '.join(bits) or '(inherits)'}")

args = sys.argv[1:]
policy = load()

if not args or args[0] in ("show", "status"):
    show(policy)
    raise SystemExit(0)

if args[0] in ("clear", "reset"):
    try:
        os.remove(path)
    except FileNotFoundError:
        pass
    print("model policy cleared")
    raise SystemExit(0)

# role=value pairs. A bare tier name is a ceiling; anything else is an exact
# model pin, because a model id is the only value a host can act on directly.
for arg in args:
    if "=" not in arg:
        raise SystemExit(f"expected role=value, got: {arg}")
    role, _, value = arg.partition("=")
    role, value = role.strip().lower(), value.strip()
    if role not in ROLES:
        raise SystemExit(f"unknown role: {role} (use {' or '.join(ROLES)})")
    if not value:
        policy.pop(role, None)
        continue
    scope = dict(policy.get(role) or {})
    if value.lower() in TIERS:
        scope["maxTier"] = value.lower()
        scope.pop("pinnedModelId", None)
    else:
        # Anything that is not a tier used to be stored as a pinned model id,
        # so `worker=frontierr` saved a pin to a model that does not exist and
        # exited 0 (audit F9-5). A typo of a tier looks like a tier: short, no
        # separator, close to a known name. A real model id carries a vendor
        # separator (`anthropic/opus`, `gpt-5.6`) or is plainly long. Refuse
        # the in-between rather than promote it to a pin nothing can resolve.
        looks_like_model = ("/" in value) or ("-" in value) or ("." in value) or len(value) > 24
        if not looks_like_model:
            raise SystemExit(
                f"unknown tier: {value} (use {' or '.join(TIERS)}); "
                f"to pin an exact model instead, give its full id, e.g. anthropic/opus"
            )
        scope["pinnedModelId"] = value
        scope.pop("maxTier", None)
    policy[role] = scope

with open(path, "w", encoding="utf-8") as fh:
    json.dump(policy, fh, ensure_ascii=False, indent=2)
    fh.write("\n")
os.chmod(path, 0o600)
show(policy)
PYEOF
}

# ---------------------------------------------------------------- settings.json

# Arm the Claude Code checkpoint on its own. A refused status line must never
# take the memory checkpoint down with it, so this never depends on that result.
install_claude_stop_hook() {
  mkdir -p "$(dirname "$SETTINGS")"
  [ -f "$SETTINGS" ] || printf '{}\n' > "$SETTINGS"
  one_backup_file "$SETTINGS"
  ONE_STOP="$(install_target_bin) stop-hook claude" "$PY" - "$SETTINGS" <<'PYEOF'
import json, os, sys
path = sys.argv[1]
try:
    with open(path, encoding="utf-8") as fh:
        settings = json.load(fh)
except Exception:
    settings = {}
if not isinstance(settings, dict):
    raise SystemExit("settings.json is not an object; manual review is required")

# Upsert only the Agentlas One Stop checkpoint; preserve unrelated hooks.
hooks = settings.setdefault("hooks", {})
if not isinstance(hooks, dict):
    raise SystemExit("settings.json hooks is not an object; manual review is required")
stop_groups = hooks.setdefault("Stop", [])
if not isinstance(stop_groups, list):
    raise SystemExit("settings.json hooks.Stop is not an array; manual review is required")
entry = {"type": "command", "command": os.environ["ONE_STOP"], "timeout": 10}
for group in stop_groups:
    if not isinstance(group, dict):
        continue
    inner = group.get("hooks")
    if not isinstance(inner, list):
        continue
    for i, hook in enumerate(inner):
        if isinstance(hook, dict) and "agentlas-one" in str(hook.get("command", "")):
            inner[i] = entry           # Update only the Agentlas One entry.
            break
    else:
        continue
    break
else:
    stop_groups.append({"hooks": [entry]})   # Add a group only when absent.

tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
    json.dump(settings, fh, ensure_ascii=False, indent=2)
    fh.write("\n")
os.replace(tmp, path)
PYEOF
}

# ----------------------------------------------- self-healing (Phase M-2/M-3)

# Update-time repair of OUR OWN Claude wiring — never touches hooks we did not
# install (only entries whose command contains "agentlas-one").
#   Heal 1: when the plugin channel already fires the Stop checkpoint, remove
#           the duplicate settings.json entry (double-fire produced duplicate
#           tickets, measured 2026-08-11).
#   Heal 2: repoint our commands from checkout paths to the installed runtime
#           (a checkout path breaks when the repository moves; measured on the
#           statusLine of the machine that authored this).
# Every applied heal appends a receipt to <one>/.agentlas/migrations.jsonl —
# silent auto-repair is forbidden. Fail-open: any error leaves settings as-is.
# PRD §5.19 — 자가 치유가 **Claude 설정 한 파일만** 봤다. 그래서 다른 호스트의 진입 파일에서
# 지시문 블록이 사라져도(수동 편집·다른 도구의 덮어쓰기) 상태 표시는 계속 "설치됨"이었다
# (실측 2026-08-23: ~/.gemini/GEMINI.md 에 One 표식 0개인데 상태는 설치됨).
# 켜져 있는 동안에는 모든 진입 파일이 실제로 블록을 갖고 있어야 한다 — 없으면 다시 심는다.
self_heal_entrypoints() {
  [ "$(state_read on false)" = "True" ] || [ "$(state_read on false)" = "true" ] || return 0
  local name; name="$(state_read name One)"
  [ -n "$name" ] || name="One"
  local target repaired=0
  while IFS= read -r target; do
    [ -n "$target" ] || continue
    if [ ! -f "$target" ] || ! grep -q "$BEGIN_MARK" "$target" 2>/dev/null; then
      block_install_one "$name" "$target" && repaired=$((repaired + 1))
    fi
  done < <(runtime_entrypoints)
  [ "$repaired" -gt 0 ] && printf 'Repaired the One directive in %d entry file(s).\n' "$repaired"
  return 0
}

self_heal_claude_settings() {
  [ -f "$SETTINGS" ] || return 0
  ONE_SETTINGS="$SETTINGS" \
  ONE_DIR_ENV="$ONE_DIR" \
  ONE_PLUGIN_CACHE="${AGENTLAS_ONE_PLUGIN_CACHE:-$HOME/.claude/plugins/cache/agentlas-core-engine/hephaestus}" \
  ONE_RUNTIME_BIN="${AGENTLAS_ONE_RUNTIME_BIN:-$HOME/.agentlas/runtime/current/bin/agentlas-one}" \
  "$PY" - <<'PYEOF'
import hashlib, json, os, sys, time

settings_path = os.environ["ONE_SETTINGS"]
one_dir = os.environ["ONE_DIR_ENV"]
cache_root = os.environ["ONE_PLUGIN_CACHE"]
runtime_bin = os.environ["ONE_RUNTIME_BIN"]

def now():
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())

try:
    with open(settings_path, encoding="utf-8") as fh:
        settings = json.load(fh)
except Exception:
    sys.exit(0)
if not isinstance(settings, dict):
    sys.exit(0)

def plugin_channel_active():
    """Authoritative only when enabled AND the cached binary is executable AND
    its hooks.json wires an agentlas-one Stop — a disabled or gutted plugin
    must not cost the user their only checkpoint."""
    plugins = settings.get("enabledPlugins")
    if not isinstance(plugins, dict) or not any(
        key.startswith("hephaestus@") and value is True for key, value in plugins.items()
    ):
        return False
    try:
        versions = sorted(os.listdir(cache_root))
    except OSError:
        return False
    for version in reversed(versions):
        base = os.path.join(cache_root, version)
        try:
            if not os.access(os.path.join(base, "bin", "agentlas-one"), os.X_OK):
                continue
            with open(os.path.join(base, "hooks", "hooks.json"), encoding="utf-8") as fh:
                hooks = json.load(fh)
        except Exception:
            continue
        if "agentlas-one" in json.dumps(hooks.get("hooks", {}).get("Stop", [])):
            return True
    return False

events = []
hooks = settings.get("hooks") if isinstance(settings.get("hooks"), dict) else None

# Heal 1 — drop OUR settings Stop entry while the plugin channel fires it.
if hooks and isinstance(hooks.get("Stop"), list) and plugin_channel_active():
    kept, removed = [], []
    for group in hooks["Stop"]:
        inner = group.get("hooks") if isinstance(group, dict) else None
        if isinstance(inner, list):
            ours = [h for h in inner
                    if isinstance(h, dict) and "agentlas-one" in str(h.get("command", ""))]
            if ours:
                removed.extend(str(h.get("command", "")) for h in ours)
                inner = [h for h in inner if h not in ours]
                if not inner:
                    continue
                group = {**group, "hooks": inner}
        kept.append(group)
    if removed:
        if kept:
            hooks["Stop"] = kept
        else:
            hooks.pop("Stop", None)
        events.append({"event": "settings-stop-dedup", "removed": removed,
                       "reason": "plugin Stop hook active"})

# Heal 2 — our commands must reference the installed runtime, not a checkout.
def healed_command(text):
    if not isinstance(text, str) or "agentlas-one" not in text:
        return None
    if not os.access(runtime_bin, os.X_OK):
        return None
    tokens = text.split()
    if not tokens or not tokens[0].endswith("/agentlas-one"):
        return None
    try:
        if os.path.realpath(tokens[0]) == os.path.realpath(runtime_bin):
            return None
    except OSError:
        return None
    return " ".join([runtime_bin] + tokens[1:])

status_line = settings.get("statusLine")
if isinstance(status_line, dict):
    new_cmd = healed_command(str(status_line.get("command", "")))
    if new_cmd:
        events.append({"event": "statusline-path-heal",
                       "before": status_line.get("command"), "after": new_cmd})
        status_line["command"] = new_cmd

if hooks:
    for group in hooks.get("Stop", []) or []:
        inner = group.get("hooks") if isinstance(group, dict) else None
        for hook in inner or []:
            if isinstance(hook, dict):
                new_cmd = healed_command(str(hook.get("command", "")))
                if new_cmd:
                    events.append({"event": "stop-command-path-heal",
                                   "before": hook.get("command"), "after": new_cmd})
                    hook["command"] = new_cmd

if not events:
    sys.exit(0)

# Backup, then atomic replace — concurrent healers converge on the same output.
try:
    with open(settings_path, encoding="utf-8") as fh:
        original = fh.read()
    with open(f"{settings_path}.one-backup-{int(time.time())}", "w", encoding="utf-8") as fh:
        fh.write(original)
    tmp = settings_path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as fh:
        json.dump(settings, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    os.replace(tmp, settings_path)
except Exception:
    sys.exit(0)

# Receipts (M-3). Content-hash dedupe keeps racing healers from double-logging.
try:
    meta = os.path.join(one_dir, ".agentlas")
    os.makedirs(meta, exist_ok=True)
    ledger = os.path.join(meta, "migrations.jsonl")
    seen = set()
    try:
        with open(ledger, encoding="utf-8") as fh:
            for line in fh:
                try:
                    seen.add(json.loads(line).get("dedupe"))
                except Exception:
                    continue
    except OSError:
        pass
    with open(ledger, "a", encoding="utf-8") as fh:
        for event in events:
            key = hashlib.sha256(
                json.dumps(event, ensure_ascii=False, sort_keys=True).encode("utf-8")
            ).hexdigest()[:16]
            if key in seen:
                continue
            fh.write(json.dumps({"schemaVersion": "agentlas.one-workspace.v1",
                                 "kind": "self-heal", "dedupe": key, **event,
                                 "createdAt": now()}, ensure_ascii=False) + "\n")
except Exception:
    pass
PYEOF
}

# Prefer the installed runtime over this script's own location: a checkout
# path breaks when the repository moves (measured on this machine's statusLine).
#
# PRD §5.20 — Claude 경로만 이것을 쓰고 cursor·agy·나머지 호스트는 `$SELF`(체크아웃 경로)를
# 훅에 그대로 박았다. 저장소를 옮기거나 지우면 그 호스트들의 종료 훅이 조용히 죽는다.
# 모든 호스트가 설치 경로를 쓴다.
install_target_bin() {
  local runtime_bin="${AGENTLAS_ONE_RUNTIME_BIN:-$HOME/.agentlas/runtime/current/bin/agentlas-one}"
  if [ -x "$runtime_bin" ]; then printf '%s' "$runtime_bin"; else printf '%s' "$SELF"; fi
}

cmd_install() {
  # The checkpoint is armed first so a refused status line cannot suppress it.
  install_claude_stop_hook && printf 'Non-blocking memory checkpoint Stop hook configured.\n'
  install_codex_stop_hook
  one_backup_file "$SETTINGS"
  ONE_CMD="$(install_target_bin) statusline" "$PY" - "$SETTINGS" <<'PYEOF'
import json, os, sys
path = sys.argv[1]
try:
    with open(path, encoding="utf-8") as fh:
        settings = json.load(fh)
except Exception:
    settings = {}
if not isinstance(settings, dict):
    raise SystemExit("settings.json is not an object; manual review is required")
existing = settings.get("statusLine")
if isinstance(existing, dict) and existing.get("command") and "agentlas-one" not in existing.get("command", ""):
    sys.stderr.write("Another statusLine is already configured and was not overwritten:\n  %s\n" % existing.get("command"))
    raise SystemExit(3)
settings["statusLine"] = {"type": "command", "command": os.environ["ONE_CMD"], "padding": 0}
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
    json.dump(settings, fh, ensure_ascii=False, indent=2)
    fh.write("\n")
os.replace(tmp, path)
PYEOF
  local rc=$?
  [ $rc -eq 0 ] && printf 'Status line configured -> %s\n' "$SETTINGS"
  # Self-heal after wiring: drops a now-duplicate Stop entry and repoints stale
  # checkout paths. Installing must leave a machine consistent, not just wired.
  self_heal_claude_settings >/dev/null 2>&1 || true
  self_heal_entrypoints >/dev/null 2>&1 || true
  return $rc
}

# Install the same session-end checkpoint on runtimes with hook engines.
# Codex and Antigravity support Stop; Antigravity supplies transcript_path.
install_codex_stop_hook() {
  install_json_stop_hook "codex" "${AGENTLAS_ONE_CODEX_HOOKS:-$HOME/.codex/hooks.json}" "false"
  install_agy_stop_hook
  install_cursor_stop_hook
}

# Cursor uses ~/.cursor/hooks.json with a versioned envelope and a flat handler
# array per event. Its stop payload carries transcript_path, so no path guessing
# is needed. Only replace our own entry and leave every other handler in place.
install_cursor_stop_hook() {
  local hooks="${AGENTLAS_ONE_CURSOR_HOOKS:-$HOME/.cursor/hooks.json}"
  [ -f "$hooks" ] || { printf 'No Cursor hooks file; skipped (%s)\n' "$hooks"; return 0; }
  one_backup_file "$hooks"
  ONE_STOP="$(install_target_bin) stop-hook cursor" "$PY" - "$hooks" <<'PYEOF'
import json, os, sys
path = sys.argv[1]
try:
    with open(path, encoding="utf-8") as fh:
        data = json.load(fh)
except Exception as exc:
    sys.stderr.write("Could not read Cursor hooks.json: %s\n" % exc)
    raise SystemExit(0)              # Preserve an unreadable user-owned file.
if not isinstance(data, dict):
    raise SystemExit(0)
data.setdefault("version", 1)
hooks = data.setdefault("hooks", {})
if not isinstance(hooks, dict):
    raise SystemExit(0)
group = hooks.setdefault("stop", [])
if not isinstance(group, list):
    raise SystemExit(0)
command = os.environ["ONE_STOP"]
kept = [h for h in group if not (isinstance(h, dict) and "agentlas-one" in str(h.get("command", "")))]
kept.append({"type": "command", "command": command, "timeout": 15})
hooks["stop"] = kept
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
    json.dump(data, fh, ensure_ascii=False, indent=2)
    fh.write("\n")
os.replace(tmp, path)
print("Cursor stop hook configured -> %s" % path)
PYEOF
}

# Antigravity uses ~/.gemini/config/hooks.json, a top-level name map.
# Stop is a flat handler array without Claude-style matcher and hooks wrappers.
install_agy_stop_hook() {
  local hooks="${AGENTLAS_ONE_AGY_HOOKS:-$HOME/.gemini/config/hooks.json}"
  [ -f "$hooks" ] || { printf 'No agy hooks file; skipped (%s)\n' "$hooks"; return 0; }
  one_backup_file "$hooks"
  ONE_STOP="$(install_target_bin) stop-hook agy" "$PY" - "$hooks" <<'PYEOF'
import json, os, sys
path = sys.argv[1]
try:
    with open(path, encoding="utf-8") as fh:
        data = json.load(fh)
except Exception as exc:
    sys.stderr.write("Could not read agy hooks.json: %s\n" % exc)
    raise SystemExit(0)              # Preserve an unreadable user-owned file.
if not isinstance(data, dict):
    raise SystemExit(0)
entry = data.setdefault("agentlas-one", {})
if not isinstance(entry, dict):
    raise SystemExit(0)
entry["enabled"] = True
entry["Stop"] = [{"type": "command", "command": os.environ["ONE_STOP"], "timeout": 15}]
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
    json.dump(data, fh, ensure_ascii=False, indent=2)
    fh.write("\n")
os.replace(tmp, path)
print("agy Stop hook configured -> %s" % path)
PYEOF
}

# $1=host name, $2=settings file, $3=whether a missing hooks key may be created
install_json_stop_hook() {
  local host="$1" hooks="$2" create_missing="$3"
  [ -f "$hooks" ] || { printf 'No %s hooks file; skipped (%s)\n' "$host" "$hooks"; return 0; }
  one_backup_file "$hooks"
  ONE_STOP="$(install_target_bin) stop-hook $host" ONE_HOST="$host" ONE_CREATE="$create_missing" \
    "$PY" - "$hooks" <<'PYEOF'
import json, os, sys
path = sys.argv[1]
try:
    with open(path, encoding="utf-8") as fh:
        data = json.load(fh)
except Exception as exc:
    sys.stderr.write("Could not read %s settings: %s\n" % (os.environ["ONE_HOST"], exc))
    raise SystemExit(0)          # Preserve an unreadable user-owned file.
if not isinstance(data, dict):
    raise SystemExit(0)
if "hooks" not in data and os.environ.get("ONE_CREATE") != "true":
    raise SystemExit(0)          # Do not introduce hooks into a runtime that did not use them.
hooks = data.setdefault("hooks", {})
if not isinstance(hooks, dict):
    raise SystemExit(0)
groups = hooks.setdefault("Stop", [])
if not isinstance(groups, list):
    raise SystemExit(0)
entry = {"type": "command", "command": os.environ["ONE_STOP"], "timeout": 10}
for group in groups:
    inner = group.get("hooks") if isinstance(group, dict) else None
    if not isinstance(inner, list):
        continue
    for i, hook in enumerate(inner):
        if isinstance(hook, dict) and "agentlas-one" in str(hook.get("command", "")):
            inner[i] = entry
            break
    else:
        continue
    break
else:
    groups.append({"hooks": [entry]})
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
    json.dump(data, fh, ensure_ascii=False, indent=2)
    fh.write("\n")
os.replace(tmp, path)
print("%s Stop hook configured -> %s" % (os.environ["ONE_HOST"], path))
PYEOF
}

# ---------------------------------------------------------------- commands

cmd_on() {
  local name="${1:-}"
  [ -n "$name" ] || name="$(state_read name One)"
  [ -n "$name" ] || name="One"
  state_write "$name" true
  # Store one canonical directive file. Marker blocks are copies, while runtimes
  # without hooks can read this file directly.
  mkdir -p "$ONE_DIR"
  directive_block "$name" > "$ONE_DIR/directive.md"
  block_install "$name"
  # Idempotently seed the same memory structure used by other single agents.
  local seeded; seeded="$(cmd_workspace seed --name "$name" 2>/dev/null \
    | "$PY" -c 'import json,sys
try: d=json.load(sys.stdin)
except Exception: sys.exit(0)
c=d.get("created") or []
print(f"created {len(c)}" if c else "already present")' 2>/dev/null)"
  # Turning One on must also arm the checkpoints. Without them the identity
  # applies but nothing is ever learned, which reads as "One does not work".
  # The status line stays with `install` because it can refuse an existing one.
  install_claude_stop_hook >/dev/null 2>&1 || true
  install_codex_stop_hook >/dev/null 2>&1 || true
  install_hook_packs >/dev/null 2>&1 || true
  self_heal_claude_settings >/dev/null 2>&1 || true
  self_heal_entrypoints >/dev/null 2>&1 || true
  printf '%s enabled.\n' "$name"
  printf '  State file : %s\n' "$STATE_FILE"
  printf '  Directive  : %s (AGENTLAS-ONE block)\n' "$CLAUDE_MD"
  printf '  Workspace  : %s/.agentlas (%s)\n' "$ONE_DIR" "${seeded:-verification failed}"
  printf '  Responses will begin with [%s] starting next session.\n' "$name"
}

cmd_off() {
  local name; name="$(state_read name One)"
  block_remove
  remove_stop_hooks
  rm -f "$STATE_FILE" "$ONE_DIR/directive.md"
  printf '%s disabled. Removed the state file, directive block, and checkpoints.\n' "${name:-One}"
}

# Runtimes that load a hook from a directory rather than a settings key. These
# are installed by `on` and removed by `off` so the two stay symmetric: a pack
# that only the installer can place would stay missing until the next release.
#
# PRD §5.18 — 레지스트리에 `hookpack-dir` 행이 선언돼 있는데 **읽는 코드가 없었다**:
# 여기는 goose·openclaw 두 이름을 손으로 박고 있었다. 그래서 레지스트리에 세 번째 훅팩을
# 더해도 아무 일도 일어나지 않는다(선언이 조용히 규칙이 아니게 되는 모양).
# 이제 레지스트리 행을 소비한다 — 손으로 박은 목록은 자산 경로 매핑에만 남는다.
install_hook_packs() {
  local assets; assets="$(hook_asset_root)" || return 0
  local id kind suffix path dest source
  while IFS=$'\t' read -r id kind suffix path; do
    [ "$kind" = "hookpack" ] || continue
    dest="$(registry_override hookpack "$suffix" "$path")"
    # 자산은 런타임 홈/체크아웃 안의 그 런타임 폴더에 있다. 두 가지 알려진 모양을 본다.
    source=""
    if [ -d "$assets/$id/plugins/agentlas-one" ]; then source="$assets/$id/plugins/agentlas-one"
    elif [ -d "$assets/$id/hooks/agentlas-one" ]; then source="$assets/$id/hooks/agentlas-one"
    fi
    [ -n "$source" ] || continue
    # 그 호스트를 쓰는 흔적이 있을 때만 설치한다(안 쓰는 사람 홈에 폴더를 만들지 않는다).
    if [ -d "$(dirname "$dest")" ] || [ -d "$HOME/.config/$id" ] || [ -d "$HOME/.$id" ]; then
      rm -rf "$dest"
      mkdir -p "$(dirname "$dest")"
      cp -R "$source" "$dest" 2>/dev/null || true
    fi
  done < <(registry_paths)
}

# Hook packs ship beside the runner in the runtime home, and beside this script
# in a source checkout. Return nothing when neither carries them.
hook_asset_root() {
  local root
  for root in "${AGENTLAS_ONE_ASSETS:-}" \
              "$HOME/.agentlas/runtime/current" \
              "$OS_ROOT"; do
    [ -n "$root" ] || continue
    if [ -d "$root/openclaw/hooks/agentlas-one" ] || [ -d "$root/goose/plugins/agentlas-one" ]; then
      printf '%s\n' "$root"
      return 0
    fi
  done
  return 1
}

# Turning One off must leave no dead command behind in a user-owned config.
# Each file is rewritten only to drop our own entry; everything else is kept.
remove_stop_hooks() {
  local file
  while IFS= read -r file; do
    [ -n "$file" ] || continue
    [ -f "$file" ] || continue
    one_backup_file "$file"
    "$PY" - "$file" <<'PYEOF'
import json, os, sys

path = sys.argv[1]
try:
    with open(path, encoding="utf-8") as fh:
        data = json.load(fh)
except Exception:
    raise SystemExit(0)              # Preserve an unreadable user-owned file.
if not isinstance(data, dict):
    raise SystemExit(0)


def ours(entry):
    return isinstance(entry, dict) and "agentlas-one" in str(entry.get("command", ""))


changed = False

# Antigravity keeps our whole entry under its own top-level name.
if isinstance(data.get("agentlas-one"), dict):
    del data["agentlas-one"]
    changed = True

hooks = data.get("hooks")
if isinstance(hooks, dict):
    for event, group in list(hooks.items()):
        if not isinstance(group, list):
            continue
        kept = []
        for item in group:
            if ours(item):                       # Cursor: a flat handler array.
                changed = True
                continue
            if isinstance(item, dict) and isinstance(item.get("hooks"), list):
                inner = [h for h in item["hooks"] if not ours(h)]
                if len(inner) != len(item["hooks"]):
                    changed = True
                if not inner:                    # Drop a group we emptied.
                    continue
                item = {**item, "hooks": inner}
            kept.append(item)
        if kept:
            hooks[event] = kept
        else:
            del hooks[event]
            changed = True

if not changed:
    raise SystemExit(0)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
    json.dump(data, fh, ensure_ascii=False, indent=2)
    fh.write("\n")
os.replace(tmp, path)
PYEOF
  done < <(one_hook_files)
  # PRD §5.18 — 설치와 대칭으로 제거도 레지스트리를 읽는다. 손으로 박은 두 줄만 지우면
  # 레지스트리에 더한 세 번째 훅팩은 영원히 남는다.
  local pack_id pack_kind pack_suffix pack_path pack_dest
  while IFS=$'\t' read -r pack_id pack_kind pack_suffix pack_path; do
    [ "$pack_kind" = "hookpack" ] || continue
    pack_dest="$(registry_override hookpack "$pack_suffix" "$pack_path")"
    [ -n "$pack_dest" ] && rm -rf "$pack_dest" 2>/dev/null || true
  done < <(registry_paths)
}

# Remove ONLY a status line we installed (command contains "agentlas-one
# statusline"). A user-owned status line is left exactly as found.
remove_statusline() {
  [ -f "$SETTINGS" ] || return 0
  grep -q 'agentlas-one statusline' "$SETTINGS" 2>/dev/null || return 0
  one_backup_file "$SETTINGS"
  "$PY" - "$SETTINGS" <<'PYEOF'
import json, os, sys
path = sys.argv[1]
try:
    with open(path, encoding="utf-8") as fh:
        settings = json.load(fh)
except Exception:
    raise SystemExit(0)              # Preserve an unreadable user-owned file.
if not isinstance(settings, dict):
    raise SystemExit(0)
existing = settings.get("statusLine")
if not (isinstance(existing, dict) and "agentlas-one statusline" in str(existing.get("command", ""))):
    raise SystemExit(0)
del settings["statusLine"]
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
    json.dump(settings, fh, ensure_ascii=False, indent=2)
    fh.write("\n")
os.replace(tmp, path)
PYEOF
}

# Every file uninstall may touch, for the pre-flight backup. Only files that
# exist are archived; the archive is written before any file is modified.
uninstall_touchpoints() {
  runtime_entrypoints
  one_hook_files
  printf '%s\n' "$STATE_FILE" "$ONE_DIR/directive.md"
}

# agentlas-one uninstall [--purge]
#
# Removal is restoration, not deletion: (1) archive every touchpoint that
# exists to <one-parent>/backup/one-uninstall-<ts>.tar.gz, (2) drop our marker
# blocks, our hook entries, our status line, and the state file — each rewrite
# is atomic (tmp -> rename) and leaves everything we did not write untouched,
# (3) with --purge also delete the One workspace directory. Idempotent: a
# second run finds nothing to remove and still exits 0. Fail-open on a
# missing/unreadable file: it is skipped and named in the receipt.
cmd_uninstall() {
  local purge=false arg
  for arg in "$@"; do
    case "$arg" in
      --purge) purge=true ;;
      *) die "Unknown option: $arg (usage: agentlas-one uninstall [--purge])" ;;
    esac
  done
  local name; name="$(state_read name One)"
  local backup_dir="${AGENTLAS_ONE_BACKUP_DIR:-$(dirname "$ONE_DIR")/backup}"
  local ts; ts="$(date +%Y%m%dT%H%M%S)"
  local archive="$backup_dir/one-uninstall-$ts.tar.gz"
  mkdir -p "$backup_dir"
  local archived
  archived="$(uninstall_touchpoints | ONE_ARCHIVE="$archive" "$PY" -c '
import os, sys, tarfile
paths = [line.strip() for line in sys.stdin if line.strip()]
seen, count = set(), 0
with tarfile.open(os.environ["ONE_ARCHIVE"], "w:gz") as tar:
    for path in paths:
        if path in seen or not os.path.isfile(path):
            continue
        seen.add(path)
        tar.add(path, arcname=path.lstrip("/"))
        count += 1
print(count)
' 2>/dev/null || printf '0')"
  block_remove
  remove_stop_hooks
  remove_statusline
  rm -f "$STATE_FILE" "$ONE_DIR/directive.md"
  local purged="kept"
  if [ "$purge" = true ]; then
    # Refuse to purge anything that is not clearly the One workspace.
    case "$ONE_DIR" in
      */.agentlas/one|*/one) rm -rf "$ONE_DIR"; purged="deleted" ;;
      *) purged="refused (unexpected path: $ONE_DIR)" ;;
    esac
  fi
  printf '%s uninstalled.\n' "${name:-One}"
  printf '  Backup     : %s (%s files)\n' "$archive" "${archived:-0}"
  printf '  Removed    : directive blocks, Stop hooks, status line, state file\n'
  printf '  Workspace  : %s (%s)\n' "$ONE_DIR" "$purged"
  printf '  Restore    : tar -xzf %s -C /\n' "$archive"
}

cmd_name() {
  local name="${1:-}"
  [ -n "$name" ] || die "Provide a name: agentlas-one name <name>"
  local on; on="$(state_read on false)"
  # 이름 변경은 켜기가 아니다 — 현재 상태를 그대로 지킨다(PRD §4.15).
  state_write "$name"
  if [ "$on" = "True" ] || [ "$on" = "true" ]; then
    block_install "$name"
    printf 'Renamed to %s. The change applies next session.\n' "$name"
  else
    printf 'Saved the name %s. One remains disabled; run agentlas-one on.\n' "$name"
  fi
}

# `status --runtimes`: the per-runtime support matrix (PRD 2026-08-15 OS-5).
# Data comes from contracts/runtime-registry.json; presence/directive/hook are
# measured on this machine, never assumed from the registry.
registry_py() {
  local mod
  for mod in "$HOME/.agentlas/runtime/current/agentlas_cloud/runtime_registry.py" \
             "$OS_ROOT/agentlas_cloud/runtime_registry.py"; do
    if [ -f "$mod" ]; then printf '%s' "$mod"; return 0; fi
  done
  return 1
}

# Runtime drift — checked on the user's machine, shown where the owner already
# looks (PRD 2026-08-15 Phase B). No daemon: the session-end hook kicks one
# background check per 24h (network, 20s cap, fail-open); the status line and
# `status` read the last report. GitHub's daily job is only a backstop.
drift_py() {
  local mod
  for mod in "$HOME/.agentlas/runtime/current/agentlas_cloud/runtime_drift.py" \
             "$OS_ROOT/agentlas_cloud/runtime_drift.py"; do
    if [ -f "$mod" ]; then printf '%s' "$mod"; return 0; fi
  done
  return 1
}
DRIFT_REPORT="$ONE_DIR/runtime-drift.json"

drift_kick_daily() {
  [ "${AGENTLAS_ONE_DRIFT_CHECK:-1}" = "1" ] || return 0
  local mod; mod="$(drift_py)" || return 0
  local stamp="$ONE_DIR/.runtime-drift.last"
  if [ -f "$stamp" ]; then
    local age; age=$(( $(date +%s) - $(stat -f %m "$stamp" 2>/dev/null || stat -c %Y "$stamp" 2>/dev/null || echo 0) ))
    [ "$age" -ge 86400 ] || return 0
  fi
  mkdir -p "$ONE_DIR" 2>/dev/null || return 0
  : > "$stamp"
  # detached, quiet, bounded; never blocks the hook that called us
  ( "$PY" "$mod" --write "$DRIFT_REPORT" --quiet --timeout 20 </dev/null >/dev/null 2>&1 & ) 2>/dev/null || true
}

drift_check_now() {
  local mod; mod="$(drift_py)" || die "Cannot find runtime_drift.py"
  mkdir -p "$ONE_DIR"
  "$PY" "$mod" --write "$DRIFT_REPORT" "$@"
}

# One-line summary of the last report ("" when none / clean).
drift_summary() {
  [ -f "$DRIFT_REPORT" ] || return 0
  ONE_DRIFT="$DRIFT_REPORT" "$PY" - <<'PYEOF' 2>/dev/null || true
import json, os
try:
    r = json.load(open(os.environ["ONE_DRIFT"], encoding="utf-8"))
except Exception:
    raise SystemExit(0)
f = r.get("findings") or []
if r.get("status") != "ok":
    print("runtime drift check unavailable (%s)" % (r.get("reason") or "?"))
elif f:
    kinds = {}
    for x in f:
        kinds[x.get("kind", "?")] = kinds.get(x.get("kind", "?"), 0) + 1
    print("runtime drift %d (%s) — agentlas-one status --drift" % (len(f), ", ".join("%s %d" % kv for kv in sorted(kinds.items()))))
PYEOF
}

cmd_status_drift() {
  local mod; mod="$(drift_py)" || die "Cannot find runtime_drift.py"
  if [ "${1:-}" = "--now" ]; then shift; drift_check_now "$@"; return $?; fi
  # PRD §5.14 — 안내만 있고 구현이 없던 7일 유예. 이제 확인하면 실제로 조용해진다.
  if [ "${1:-}" = "--ack" ]; then
    shift
    "$PY" "$mod" --ack "$@"
    return $?
  fi
  if [ ! -f "$DRIFT_REPORT" ]; then
    printf 'Runtime drift: no report yet (checked once a day from the session-end hook).\n'
    printf '  Run now: agentlas-one status --drift --now\n'
    return 0
  fi
  ONE_DRIFT="$DRIFT_REPORT" "$PY" - <<'PYEOF'
import json, os
r = json.load(open(os.environ["ONE_DRIFT"], encoding="utf-8"))
print("Runtime drift (checked %s): %s" % (r.get("checkedAt", "?"), "unavailable — " + str(r.get("reason")) if r.get("status") != "ok" else ("none" if not r.get("findings") else "%d finding(s)" % len(r["findings"]))))
for f in r.get("findings") or []:
    print("  [%s] %s: %s" % (f.get("kind"), f.get("runtime"), f.get("detail")))
if r.get("findings"):
    print("  Review before adopting (cooldown 7 days). Pins live in contracts/runtime-registry.json.")
PYEOF
}

cmd_status_runtimes() {
  local mod; mod="$(registry_py)" || die "Cannot find runtime_registry.py"
  "$PY" "$mod" status "$@"
}

cmd_status() {
  if [ "${1:-}" = "--runtimes" ]; then shift; cmd_status_runtimes "$@"; return $?; fi
  if [ "${1:-}" = "--drift" ]; then shift; cmd_status_drift "$@"; return $?; fi
  if [ ! -f "$STATE_FILE" ]; then
    printf 'One: disabled\n'
    printf '  Enable: agentlas-one on [name]\n'
    return 0
  fi
  local name since
  name="$(state_read name One)"; since="$(state_read since '')"
  printf 'One: enabled\n'
  printf '  Name      : %s\n' "$name"
  printf '  Since     : %s\n' "${since:-unknown}"
  printf '  State     : %s\n' "$STATE_FILE"
  if grep -q "$BEGIN_MARK" "$CLAUDE_MD" 2>/dev/null; then
    printf '  Directive : installed (%s)\n' "$CLAUDE_MD"
  else
    printf '  Directive : missing; run agentlas-one on again\n'
  fi
  if grep -q 'agentlas-one statusline' "$SETTINGS" 2>/dev/null; then
    printf '  Status line: configured\n'
  else
    printf '  Status line: missing; run agentlas-one install\n'
  fi
  local drift; drift="$(drift_summary)"
  [ -n "$drift" ] && printf '  Drift     : %s\n' "$drift"
  return 0
}

# ---------------------------------------------------------------- workspace delegation

OS_ROOT="$(cd "$(dirname "$SELF")/.." && pwd)"

workspace_py() {
  # Prefer the installed runtime, then the repository source.
  local mod
  for mod in "$HOME/.agentlas/runtime/current/agentlas_cloud/one_workspace.py" \
             "$OS_ROOT/agentlas_cloud/one_workspace.py"; do
    if [ -f "$mod" ]; then printf '%s' "$mod"; return 0; fi
  done
  return 1
}

cmd_workspace() {
  local sub="$1"; shift
  local mod; mod="$(workspace_py)" || die "Cannot find one_workspace.py"
  "$PY" "$mod" "$sub" --root "$ONE_DIR" "$@"
}

cmd_memory() {
  local mod; mod="$(workspace_py)" || die "Cannot find one_workspace.py"
  # The heredoc owns stdin, so pass status through the environment.
  local raw; raw="$("$PY" "$mod" status --root "$ONE_DIR")" || die "Workspace status failed"
  ONE_STATUS="$raw" "$PY" <<'PYEOF'
import json, os

data = json.loads(os.environ["ONE_STATUS"])


def cell(value):
    return "not seeded" if value == -1 else value


rows = [
    ("Agent identity    ", data["agentId"]),
    ("Workspace seeded  ", "yes" if data["seeded"] else "no (agentlas-one seed)"),
    ("Memory tickets    ", cell(data["tickets"])),
    ("Curator decisions ", cell(data["curatorDecisions"])),
    ("Session receipts  ", cell(data["invocations"])),
    ("Evolution events  ", cell(data["evolutionEvents"])),
    ("Experience chips  ", cell(data["experienceChips"])),
    ("Experience packs  ", cell(data["experiencePacks"])),
]
print("One memory status")
for label, value in rows:
    print(f"  {label} : {value}")
PYEOF
}

case "${1:-status}" in
  install)    cmd_install ;;
  on)         shift; cmd_on "${1:-}" ;;
  off)        cmd_off ;;
  uninstall)  shift; cmd_uninstall "$@" ;;
  # PRD §4.18 — 첫 인자만 넘겨서 두 낱말 이름("Hope Kim")이 잘렸다. 이름은 나머지 전부다.
  name)       shift; cmd_name "$*" ;;
  status)     shift; cmd_status "$@" ;;
  memory)     cmd_memory ;;
  seed)       shift; cmd_workspace seed --name "$(state_read name One)" "$@" ;;
  remember)   shift; [ -n "${1:-}" ] || die "Provide content to remember"; cmd_workspace emit --content "$*" ;;
  curate)     cmd_workspace curate ;;
  # Chip candidates are never promoted automatically (measured unsafe), so the
  # decision has to be reachable by hand — otherwise the ban is a wall and every
  # candidate waits forever.
  chips)      shift; cmd_workspace chips ${1:+--status "$1"} ;;
  promote)    shift; [ -n "${1:-}" ] || die "Provide a chip id to promote"
              chip="$1"; shift; cmd_workspace promote --chip "$chip" ${1:+--reason "$*"} ;;
  reject)     shift; [ -n "${1:-}" ] || die "Provide a chip id to reject"
              chip="$1"; shift; cmd_workspace reject --chip "$chip" ${1:+--reason "$*"} ;;
  coverage)   cmd_workspace recall-coverage ;;
  orch)       shift; cmd_orch "$@" ;;
  statusline) cmd_statusline ;;
  stop-hook)  shift
              # Session-end is the one moment every install passes through, so
              # legacy wiring heals here (fail-open; stdout stays hook-clean).
              self_heal_claude_settings >/dev/null 2>&1 || true
  self_heal_entrypoints >/dev/null 2>&1 || true
              drift_kick_daily >/dev/null 2>&1 || true
              cmd_workspace stop-hook ${1:+--host "$1"} ;;
  *)          die "Usage: agentlas-one {install|on [name]|off|uninstall [--purge]|name <name>|status [--runtimes|--drift]|memory|remember <text>|curate|seed|chips [status]|promote <chip> [reason]|reject <chip> [reason]|coverage|orch [role=value...]}" ;;
esac
