Interactive onboarding for new Ouroboros users.
/ouroboros:welcome # First-time or update onboarding
/ouroboros:welcome --skip # Skip welcome, mark as shown
/ouroboros:welcome --force # Force re-run welcome even if shown
Ask every question with the active runtime's ask_user capability, in the
user's language. The Korean labels below are the canonical wording.
Define this resolver in the same shell before any snippet below. It accepts
only Python 3.12 or newer (python3, then python, then uv) and preserves
arguments and stdin. Quote every argument passed to ouroboros_python.
ouroboros_python() {
if command -v python3 >/dev/null 2>&1 &&
(unset PYTHONHOME PYTHONPATH PYTHONPLATLIBDIR PYTHONEXECUTABLE __PYVENV_LAUNCHER__; command python3 -c 'import sys; raise SystemExit(sys.version_info < (3, 12))') >/dev/null 2>&1
then
(unset PYTHONHOME PYTHONPATH PYTHONPLATLIBDIR PYTHONEXECUTABLE __PYVENV_LAUNCHER__; command python3 "$@")
return
fi
if command -v python >/dev/null 2>&1 &&
(unset PYTHONHOME PYTHONPATH PYTHONPLATLIBDIR PYTHONEXECUTABLE __PYVENV_LAUNCHER__; command python -c 'import sys; raise SystemExit(sys.version_info < (3, 12))') >/dev/null 2>&1
then
(unset PYTHONHOME PYTHONPATH PYTHONPLATLIBDIR PYTHONEXECUTABLE __PYVENV_LAUNCHER__; command python "$@")
return
fi
if command -v uv >/dev/null 2>&1; then
(unset PYTHONHOME PYTHONPATH PYTHONPLATLIBDIR PYTHONEXECUTABLE __PYVENV_LAUNCHER__; command uv run --no-project --quiet --python '>=3.12' python "$@")
return
fi
printf '%s\n' 'Ouroboros skills require Python >= 3.12 or uv on PATH.' >&2
return 127
}
Run the resolver, this section's blocks, and the migration check below in one
shell call, then print the result with
printf 'completed=%s setup=%s codex=%s migration=%s\n' "$WELCOME_COMPLETED" "${SETUP_READY:-}" "${CODEX_READY:-}" "${LEGACY_CODEX_MODEL_MIGRATION_REQUIRED:-}".
Read the completion marker (legacy welcomeShown: true also counts):
WELCOME_COMPLETED=$(ouroboros_python - <<'PY'
import json, os
try:
prefs = json.load(open(os.path.expanduser('~/.ouroboros/prefs.json'), encoding='utf-8'))
except Exception:
prefs = {}
if not isinstance(prefs, dict):
prefs = {}
print(prefs.get('welcomeCompleted') or ('legacy-welcomeShown' if prefs.get('welcomeShown') else ''))
PY
)
Before honoring that completion marker, determine whether setup is ready for the active runtime. A previously completed welcome must never hide the setup gate from a user who chose 나중에 or whose setup was later removed.
Claude Code is ready when ~/.ouroboros/config.yaml has
orchestrator.runtime_backend in {claude, claude_mcp} and llm.backend: claude. The marketplace plugin owns its MCP registration, so host-owned
~/.claude/mcp.json is never part of readiness:
if ouroboros_python - "$HOME/.ouroboros/config.yaml" <<'PY'
import sys
from pathlib import Path
try:
import yaml
except ModuleNotFoundError:
yaml = None
def scalar(raw: str) -> str:
return raw.strip().split(" #", 1)[0].strip().rstrip(",}").strip().strip("'\"")
def load_yaml(path: str) -> dict:
source = Path(path).read_text(encoding="utf-8")
if yaml is not None:
loaded = yaml.safe_load(source) or {}
return loaded if isinstance(loaded, dict) else {}
parsed: dict[str, dict[str, str]] = {}
section = None
for line in source.splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
key, separator, value = line.strip().partition(":")
if not separator:
continue
key = key.strip("'\"")
if not line[0].isspace():
section = key
parsed[section] = {}
flow = value.strip().split(" #", 1)[0].strip()
if flow.startswith("{") and flow.endswith("}"):
for part in flow[1:-1].split(","):
field, separator, field_value = part.partition(":")
if separator:
parsed[section][field.strip().strip("'\"")] = scalar(field_value)
elif section is not None:
parsed[section][key] = scalar(value)
return parsed
try:
config = load_yaml(sys.argv[1])
except (OSError, ValueError):
raise SystemExit(1)
orchestrator, llm = config.get("orchestrator"), config.get("llm")
ready = (
isinstance(orchestrator, dict)
and orchestrator.get("runtime_backend") in {"claude", "claude_mcp"}
and isinstance(llm, dict)
and llm.get("backend") == "claude"
)
raise SystemExit(0 if ready else 1)
PY
then
SETUP_READY="true"
fi
If SETUP_READY is not true, check Codex. It is ready when both backends are
codex, the Codex CLI (OUROBOROS_CODEX_CLI_PATH, else
orchestrator.codex_cli_path, else codex on PATH) is runnable, and
mcp_servers\.ouroboros in $CODEX_HOME/config.toml (any TOML key spelling)
has a runnable command or a non-blank url:
CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}"
case "$CODEX_HOME_DIR" in
"~") CODEX_HOME_DIR="$HOME" ;;
"~/"*) CODEX_HOME_DIR="$HOME/${CODEX_HOME_DIR#"~/"}" ;;
esac
if ouroboros_python - "$HOME/.ouroboros/config.yaml" "$CODEX_HOME_DIR/config.toml" <<'PY'
import os
import shutil
import sys
import tomllib
from pathlib import Path
try:
import yaml
except ModuleNotFoundError:
yaml = None
def scalar(raw: str) -> str:
return raw.strip().split(" #", 1)[0].strip().rstrip(",}").strip().strip("'\"")
def load_yaml(path: str) -> dict:
source = Path(path).read_text(encoding="utf-8")
if yaml is not None:
loaded = yaml.safe_load(source) or {}
return loaded if isinstance(loaded, dict) else {}
parsed: dict[str, dict[str, str]] = {}
section = None
for line in source.splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
key, separator, value = line.strip().partition(":")
if not separator:
continue
key = key.strip("'\"")
if not line[0].isspace():
section = key
parsed[section] = {}
flow = value.strip().split(" #", 1)[0].strip()
if flow.startswith("{") and flow.endswith("}"):
for part in flow[1:-1].split(","):
field, separator, field_value = part.partition(":")
if separator:
parsed[section][field.strip().strip("'\"")] = scalar(field_value)
elif section is not None:
parsed[section][key] = scalar(value)
return parsed
def runnable(candidate: str) -> bool:
value = candidate.strip()
if not value:
return False
if "/" not in value:
return shutil.which(value) is not None
path = Path(value).expanduser()
return path.is_file() and os.access(path, os.X_OK)
def mcp_ready(entry: object) -> bool:
if not isinstance(entry, dict):
return False
command, url = entry.get("command"), entry.get("url")
if isinstance(command, str) and command.strip():
return runnable(command)
return isinstance(url, str) and bool(url.strip())
try:
config = load_yaml(sys.argv[1])
codex_config = tomllib.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
except (OSError, ValueError):
raise SystemExit(1)
orchestrator, llm = config.get("orchestrator"), config.get("llm")
servers = codex_config.get("mcp_servers")
cli_path = os.environ.get("OUROBOROS_CODEX_CLI_PATH")
if not cli_path and isinstance(orchestrator, dict):
cli_path = orchestrator.get("codex_cli_path")
if not isinstance(cli_path, str) or not cli_path.strip():
cli_path = "codex"
ready = (
isinstance(orchestrator, dict)
and orchestrator.get("runtime_backend") == "codex"
and isinstance(llm, dict)
and llm.get("backend") == "codex"
and runnable(cli_path)
and mcp_ready(servers.get("ouroboros") if isinstance(servers, dict) else None)
)
raise SystemExit(0 if ready else 1)
PY
then
CODEX_READY="true"
fi
Older configurations saved gpt-5 into all four stage-model fields. That is
now an explicit pin that stops Codex App/CLI model changes from taking effect.
Never rewrite a possible user pin silently. When Codex is ready, detect that
exact legacy shape (or an interrupted migration) once:
if ouroboros_python - "$HOME/.ouroboros/config.yaml" "$HOME/.ouroboros/prefs.json" <<'PY'
from __future__ import annotations
import json
import sys
from pathlib import Path
config_path, prefs_path = map(Path, sys.argv[1:])
def yaml_mapping(source: str) -> dict[str, dict[str, str]]:
parsed: dict[str, dict[str, str]] = {}
section: str | None = None
for raw_line in source.splitlines():
if not raw_line.strip() or raw_line.lstrip().startswith("#"):
continue
indent = len(raw_line) - len(raw_line.lstrip())
key, separator, raw_value = raw_line.strip().partition(":")
if not separator:
continue
value = raw_value.strip().split(" #", 1)[0].strip().strip("'\"")
if indent == 0:
section = key.strip("'\"")
parsed.setdefault(section, {})
elif section is not None:
parsed[section][key.strip("'\"")] = value
return parsed
try:
config = yaml_mapping(config_path.read_text(encoding="utf-8"))
except OSError:
raise SystemExit(1)
try:
prefs = json.loads(prefs_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
prefs = {}
if not isinstance(prefs, dict):
prefs = {}
stage_values = (
config.get("clarification", {}).get("default_model"),
config.get("execution", {}).get("default_model"),
config.get("evaluation", {}).get("semantic_model"),
config.get("resilience", {}).get("reflect_model"),
)
legacy_gpt5 = all(value == "gpt-5" for value in stage_values)
partial_automatic_migration = (
any(value == "gpt-5" for value in stage_values)
and any(value == "default" for value in stage_values)
and all(value in {"gpt-5", "default"} for value in stage_values)
)
handled = prefs.get("codexModelMigration") in {"automatic-v1", "kept-gpt-5-v1"}
raise SystemExit(0 if (legacy_gpt5 or partial_automatic_migration) and not handled else 1)
PY
then
LEGACY_CODEX_MODEL_MIGRATION_REQUIRED="true"
fi
If CODEX_READY and LEGACY_CODEX_MODEL_MIGRATION_REQUIRED are both true, ask
"현재 설정은 모든 단계에서 gpt-5를 고정해 두고 있어요. Codex에서 선택한 모델을 자동으로 사용하도록 바꿀까요?"
(header 모델 설정) with these options:
ouroboros_python - "$HOME/.ouroboros/config.yaml" <<'PY'
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
path = Path(sys.argv[1])
original = path.read_text(encoding="utf-8")
replacements = {
("clarification", "default_model"): "default",
("execution", "default_model"): "default",
("evaluation", "semantic_model"): "default",
("resilience", "reflect_model"): "default",
}
seen: set[tuple[str, str]] = set()
section: str | None = None
output: list[str] = []
for line in original.splitlines(keepends=True):
stripped = line.strip()
indent = len(line) - len(line.lstrip())
key = stripped.split(":", 1)[0].strip("'\"") if ":" in stripped else ""
if indent == 0 and ":" in stripped:
section = key
target = (section or "", key)
if indent > 0 and target in replacements:
newline = "\n" if line.endswith("\n") else ""
prefix = line[:indent]
comment = ""
value_part = line.strip().split(":", 1)[1]
if " #" in value_part:
comment = " #" + value_part.split(" #", 1)[1].rstrip("\n")
output.append(f"{prefix}{key}: {replacements[target]}{comment}{newline}")
seen.add(target)
else:
output.append(line)
missing = set(replacements) - seen
if missing:
raise SystemExit(f"Cannot migrate Codex model pins; missing keys: {sorted(missing)}")
updated = "".join(output)
fd, tmp_name = tempfile.mkstemp(prefix=".config.yaml.", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as tmp:
tmp.write(updated)
tmp.flush()
os.fsync(tmp.fileno())
os.replace(tmp_name, path)
finally:
try:
os.unlink(tmp_name)
except FileNotFoundError:
pass
PY
default sends no model pin to Codex; it is not a model named "default".
Confirm the rewrite succeeded before recording the decision. An interrupted
rewrite leaves a partial gpt-5/default state that the next check offers
again.
config.yaml.For either choice, merge exactly one codexModelMigration marker into
~/.ouroboros/prefs.json, keeping existing keys:
ouroboros_python - "automatic-v1" <<'PY'
import json, os, sys
path = os.path.expanduser('~/.ouroboros/prefs.json')
try:
prefs = json.load(open(path, encoding='utf-8'))
except Exception:
prefs = {}
if not isinstance(prefs, dict):
prefs = {}
prefs['codexModelMigration'] = sys.argv[1]
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
json.dump(prefs, f, indent=2)
f.write('\n')
PY
Pass kept-gpt-5-v1 instead of automatic-v1 for the keep choice. If welcome
was already completed, confirm briefly and exit; do not also ask the generic
welcome question.
SETUP_READY or CODEX_READY true, no --force: ask
"Ouroboros welcome was already completed on $WELCOME_COMPLETED. What would
you like to do?" with Skip (continue to work, recommended; exit) and
Re-run welcome (continue to Step 1).--skip: write the completion prefs (Step 4), print
Ouroboros welcome skipped. Run /ouroboros:welcome --force to re-run onboarding.,
and exit.Use the readiness the Pre-Check printed; a global config.yaml alone is not
enough unless it names a runtime these gates recognize. To recheck after a
setup run (or when the Pre-Check did not run), use these standalone gates,
which print their verdict. Claude Code:
if ouroboros_python - "$HOME/.ouroboros/config.yaml" <<'PY'
import sys
from pathlib import Path
try:
import yaml
except ModuleNotFoundError:
yaml = None
def scalar(raw: str) -> str:
return raw.strip().split(" #", 1)[0].strip().rstrip(",}").strip().strip("'\"")
def load_yaml(path: str) -> dict:
source = Path(path).read_text(encoding="utf-8")
if yaml is not None:
loaded = yaml.safe_load(source) or {}
return loaded if isinstance(loaded, dict) else {}
parsed: dict[str, dict[str, str]] = {}
section = None
for line in source.splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
key, separator, value = line.strip().partition(":")
if not separator:
continue
key = key.strip("'\"")
if not line[0].isspace():
section = key
parsed[section] = {}
flow = value.strip().split(" #", 1)[0].strip()
if flow.startswith("{") and flow.endswith("}"):
for part in flow[1:-1].split(","):
field, separator, field_value = part.partition(":")
if separator:
parsed[section][field.strip().strip("'\"")] = scalar(field_value)
elif section is not None:
parsed[section][key] = scalar(value)
return parsed
try:
config = load_yaml(sys.argv[1])
except (OSError, ValueError):
raise SystemExit(1)
orchestrator, llm = config.get("orchestrator"), config.get("llm")
ready = (
isinstance(orchestrator, dict)
and orchestrator.get("runtime_backend") in {"claude", "claude_mcp"}
and isinstance(llm, dict)
and llm.get("backend") == "claude"
)
raise SystemExit(0 if ready else 1)
PY
then
echo "SETUP_READY"
else
echo "SETUP_REQUIRED"
fi
If that printed SETUP_READY, skip the Codex gate and continue to Step 1.
Otherwise check Codex:
CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}"
case "$CODEX_HOME_DIR" in
"~") CODEX_HOME_DIR="$HOME" ;;
"~/"*) CODEX_HOME_DIR="$HOME/${CODEX_HOME_DIR#"~/"}" ;;
esac
if ouroboros_python - "$HOME/.ouroboros/config.yaml" "$CODEX_HOME_DIR/config.toml" <<'PY'
import os
import shutil
import sys
import tomllib
from pathlib import Path
try:
import yaml
except ModuleNotFoundError:
yaml = None
def scalar(raw: str) -> str:
return raw.strip().split(" #", 1)[0].strip().rstrip(",}").strip().strip("'\"")
def load_yaml(path: str) -> dict:
source = Path(path).read_text(encoding="utf-8")
if yaml is not None:
loaded = yaml.safe_load(source) or {}
return loaded if isinstance(loaded, dict) else {}
parsed: dict[str, dict[str, str]] = {}
section = None
for line in source.splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
key, separator, value = line.strip().partition(":")
if not separator:
continue
key = key.strip("'\"")
if not line[0].isspace():
section = key
parsed[section] = {}
flow = value.strip().split(" #", 1)[0].strip()
if flow.startswith("{") and flow.endswith("}"):
for part in flow[1:-1].split(","):
field, separator, field_value = part.partition(":")
if separator:
parsed[section][field.strip().strip("'\"")] = scalar(field_value)
elif section is not None:
parsed[section][key] = scalar(value)
return parsed
def runnable(candidate: str) -> bool:
value = candidate.strip()
if not value:
return False
if "/" not in value:
return shutil.which(value) is not None
path = Path(value).expanduser()
return path.is_file() and os.access(path, os.X_OK)
def mcp_ready(entry: object) -> bool:
if not isinstance(entry, dict):
return False
command, url = entry.get("command"), entry.get("url")
if isinstance(command, str) and command.strip():
return runnable(command)
return isinstance(url, str) and bool(url.strip())
try:
config = load_yaml(sys.argv[1])
codex_config = tomllib.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
except (OSError, ValueError):
raise SystemExit(1)
orchestrator, llm = config.get("orchestrator"), config.get("llm")
servers = codex_config.get("mcp_servers")
cli_path = os.environ.get("OUROBOROS_CODEX_CLI_PATH")
if not cli_path and isinstance(orchestrator, dict):
cli_path = orchestrator.get("codex_cli_path")
if not isinstance(cli_path, str) or not cli_path.strip():
cli_path = "codex"
ready = (
isinstance(orchestrator, dict)
and orchestrator.get("runtime_backend") == "codex"
and isinstance(llm, dict)
and llm.get("backend") == "codex"
and runnable(cli_path)
and mcp_ready(servers.get("ouroboros") if isinstance(servers, dict) else None)
)
raise SystemExit(0 if ready else 1)
PY
then
echo "CODEX_READY"
else
echo "CODEX_SETUP_REQUIRED"
fi
If neither is ready (SETUP_REQUIRED and CODEX_SETUP_REQUIRED, including a
config that names a runtime neither gate recognizes), ask
"Ouroboros를 처음 사용하시네요. 시작하기 전에 실행 환경을 설정할까요?"
(header Ouroboros 시작하기):
ouroboros setup --runtime codex
when the executable is installed. For a Marketplace-plugin-only install run
uvx --isolated --python '>=3.12' --from 'ouroboros-ai[mcp]' ouroboros setup --runtime codex
instead. In Claude Code, follow ../setup/SKILL.md.After a successful Codex setup, ask "설정이 완료됐어요. 기본적으로 Codex에서
선택한 모델을 사용합니다. 모델은 언제든 나중에 바꿀 수 있어요." (header 준비 완료):
../config/SKILL.md. On the user's local Codex App
or CLI it opens the settings UI in their browser at a
temporary localhost address (not an external website). It offers Use Codex default model and
Enter another model ID… for a deliberate stage pin. Continue to Step 1
when the settings session ends.In Claude Code, ../setup/SKILL.md asks the equivalent model question itself;
do not ask it again. Do not show this gate again once a runtime is ready; the
settings UI stays available through ooo config.
Welcome to Ouroboros!
Most AI coding fails at the input, not the output.
Ouroboros exposes hidden assumptions BEFORE any code is written.
Interview -> Seed -> Execute -> Evaluate
^ |
+---- Evolutionary Loop -----+
Ordinary Claude setup uses the default [claude] Agent SDK profile on MCP 1.x.
Never inspect or mutate ~/.claude/mcp.json as a health check. If Ouroboros
MCP tools are not callable here, say that MCP-backed commands need a supported
host setup, which owns an isolated [mcp] launcher; [claude-sdk] and
[mcp] are never installed together.
Ask "What brings you to Ouroboros?" with New project idea (crystallize a vague idea into a Seed), Tired of rewriting prompts (AI keeps building the wrong thing), and Just exploring. Reply in 1-2 personalized sentences.
Show the core commands: ooo interview (Socratic Q&A that exposes hidden
assumptions), ooo seed (freeze answers into a Seed), ooo run (execute a
Seed), ooo evaluate (verify against the acceptance criteria), ooo unstuck
(lateral thinking), ooo config (settings), ooo help (full reference).
Ask "What would you like to do first?":
ooo interview.ooo tutorial.ooo help.Run Step 4 before handing off, so completion is recorded even when the next command takes over the conversation.
Run gh auth status &>/dev/null && echo GH_OK || echo GH_MISSING. If GH_OK
and star_asked is not true in ~/.ouroboros/prefs.json, ask "If you're
enjoying Ouroboros, would you like to star it on GitHub?" with Star on
GitHub (run gh api -X PUT /user/starred/Q00/ouroboros) and Maybe later.
Then merge the completion fields into ~/.ouroboros/prefs.json, keeping
existing keys. Prefix the command with STAR_ASKED=true when the star question
was asked, so it is never repeated:
ouroboros_python - <<'PY'
import json, os
from datetime import UTC, datetime
path = os.path.expanduser('~/.ouroboros/prefs.json')
os.makedirs(os.path.dirname(path), exist_ok=True)
try:
with open(path, encoding='utf-8') as f:
prefs = json.load(f)
if not isinstance(prefs, dict):
prefs = {}
except Exception:
prefs = {}
prefs.update({
'welcomeShown': True,
'welcomeCompleted': datetime.now(UTC).isoformat(),
})
if os.environ.get('STAR_ASKED') == 'true':
prefs['star_asked'] = True
with open(path, 'w', encoding='utf-8') as f:
json.dump(prefs, f, indent=2)
f.write('\n')
PY
Before the handoff, mention that phrases like "interview me", "I'm stuck", or "evaluate this" also
route to the matching command; to watch ooo run or ooo evolve live, run
uvx --python '>=3.12' --from 'ouroboros-ai[tui]' ouroboros tui monitor in a
separate terminal.
End the final response with exactly one line ◆ <current state> → next: <recommended action>. Derive the state from live session state via ouroboros_session_status when available, otherwise from this skill's actual outcome. Never use a linear Step N of M footer; when the next step is a real choice, list 2-3 options.