From 2598def77913139a90af762939beb2f0c10a3006 Mon Sep 17 00:00:00 2001 From: treylom Date: Sun, 16 Aug 2026 18:59:35 +0900 Subject: [PATCH 1/5] feat: add reversible identity v2 migration --- README.ko.md | 18 +- README.md | 18 +- bin/thiscodex.mjs | 32 ++- docs/SETUP-CONFIG-GUIDE.md | 250 +++++++++++------- docs/SETUP.md | 5 +- docs/guided-installer-design.md | 11 +- docs/guided-installer-requirements.md | 6 +- docs/skill-portability.md | 8 +- docs/yolo-bridge-contract.md | 4 +- examples/AGENTS.md | 33 ++- examples/bot.py | 5 +- examples/rules-seed.md | 6 + install/thiscodex.install.json | 2 +- plugin.lock.json | 4 +- rules/INDEX.md | 2 +- rules/orchestration.md | 51 +++- scripts/launch.sh | 2 +- scripts/lib/apply.mjs | 5 +- scripts/lib/materialize.mjs | 162 +++++++++++- scripts/lib/prompts.mjs | 2 +- skills/prompt/SKILL.md | 4 +- .../instructions/Bot-Persona-Generator.md | 47 ++-- skills/setup/SKILL.md | 36 ++- skills/thiscodex/SKILL.md | 17 +- tests/init/absorption.test.mjs | 21 ++ tests/init/apply.test.mjs | 4 +- tests/init/cli.test.mjs | 83 ++++++ tests/init/materialize.test.mjs | 133 +++++++++- tests/init/orchestration-rules.test.mjs | 21 ++ 29 files changed, 812 insertions(+), 180 deletions(-) create mode 100644 tests/init/orchestration-rules.test.mjs diff --git a/README.ko.md b/README.ko.md index 2e29e4a..fa603a0 100644 --- a/README.ko.md +++ b/README.ko.md @@ -62,7 +62,7 @@ npx github:treylom/ThisCodex doctor --non-interactive # (터미널) 설치 검 |---|---|---| | Codex CLI를 상시 디스코드 봇으로 | ✅ 작동 | `codex app-server`(화면 없는 백그라운드 실행) + 파이썬 bridge(다리) 데몬 `bot.py` + discord.py | | 멀티 클라이언트 동일 스레드(봇 대화를 터미널 화면으로 관전·개입) | ✅ 작동 | 같은 app-server에 `codex resume <스레드ID> --remote ws://…` | -| 페르소나·볼트 규칙 자동 로드 | ✅ 작동 | `~/.codex/config.toml`의 `project_doc_fallback_filenames = ["SOUL.md","AGENTS.md"]` | +| 페르소나·볼트 규칙 자동 로드 | ✅ 작동 | 정본 `BOT_WD/AGENTS.md`; `SOUL.md`는 `AGENTS.md`가 없을 때만 쓰는 구형 fallback | | 봇끼리 호출 + 회의 규율 | ✅ 작동 | `bot-roster.yaml`(단일 기준 파일)을 세션 시작 시 주입 | | 안전 기본 / YOLO(전체 권한) opt-in | ✅ 작동 | 기본 `workspace-write`, `THISCODEX_YOLO=1` 시에만 `thread/start`·`thread/resume` 둘 다 `danger-full-access`·`never` 전송 ([계약](docs/yolo-bridge-contract.md)) | | 이미지 생성 | ✅ 작동 | codex 내장 `image_gen.imagegen` 도구 | @@ -205,7 +205,8 @@ zsh가 없어도 실행되게 합니다. ### 3.2 `~/.codex/config.toml` ```toml -project_doc_fallback_filenames = ["SOUL.md", "AGENTS.md"] +# AGENTS.md가 Codex 정본 파일명이며 SOUL.md는 구형 fallback 전용. +project_doc_fallback_filenames = ["SOUL.md"] project_doc_max_bytes = 65536 [mcp_servers.discord] @@ -216,7 +217,12 @@ DISCORD_STATE_DIR = "~/.claude/channels/discord-<봇이름>" ``` ### 3.3 봇 작업 디렉토리 -`SOUL.md`(페르소나)와 `AGENTS.md`(규칙 — 정적 디스코드 답장 규칙 포함, §4)를 봇 작업 폴더에 둠. 매 스레드 자동 로드되므로 **매 턴 페르소나 텍스트 재주입 금지**. +봇 작업 폴더에는 정본 `AGENTS.md` 하나를 둔다. 이 파일 안에 SOUL v2 +페르소나 capsule과 정적 디스코드 답장 규칙(§4)을 넣고 rules 라우터를 +가리킨다. Codex는 디렉터리마다 지시 파일 하나만 선택하므로 같은 폴더의 +`SOUL.md`를 `AGENTS.md`와 함께 로드하지 않는다. `SOUL.md`는 `AGENTS.md`가 +없는 구형 설치의 fallback으로만 남긴다. **매 턴 정적 페르소나 텍스트를 +재주입하지 않는다.** ### 3.4 실행 (접근 권한을 주는 건 bridge) 2-윈도우 tmux 런처(`scripts/launch.sh`): `infra` 윈도우는 `LAUNCH_CMD`(codex app-server + bridge 데몬), `codex` 윈도우는 같은 app-server에 터미널 화면(TUI)을 붙여 실시간 관전·개입. @@ -276,7 +282,7 @@ Claude Code + Codex 에이전트가 공존하게 하는 규칙. `bot-roster.yaml - **직통 채널은 멘션 규칙 면제**(`require_mention: false`). - **회의 = 전용 스레드**: 봇 2개↑·10분↑·안건 있음 중 2개 충족 시 전용 스레드 신설, 본 채널엔 안내만. 단발 relay/ACK는 본문 유지. - **세션 시작 주입**: 단일 렌더러(`roster-inject.py`)가 같은 좌표·규칙을 Claude Code 봇(세션 init 훅)과 Codex 봇(`~/.codex/hooks.json`) 양쪽에 주입. -- **디스코드 답장 규칙(정적, AGENTS.md — 매 턴 아님)**: 각 턴은 ``로 들어옴 → `mcp__discord__reply(chat_id, reply_to=message_id)`로 답장. 페르소나·볼트 규율은 `SOUL.md`/`AGENTS.md`가 자동 로드되므로 항상 적용. +- **디스코드 답장 규칙(정적, AGENTS.md — 매 턴 아님)**: 각 턴은 ``로 들어옴 → `mcp__discord__reply(chat_id, reply_to=message_id)`로 답장. 페르소나·볼트 규율은 정본 `AGENTS.md`가 자동 로드되므로 항상 적용. --- @@ -284,7 +290,7 @@ Claude Code + Codex 에이전트가 공존하게 하는 규칙. `bot-roster.yaml | 항목 | Claude Code | Codex 대응 | |---|---|---| -| 페르소나·규칙 로드 | `CLAUDE.md` + 세션 시작 훅 | `project_doc_fallback_filenames`로 `AGENTS.md`/`SOUL.md` | +| 페르소나·규칙 로드 | `CLAUDE.md` + 세션 시작 훅 | 정본 `AGENTS.md`; `SOUL.md`는 `AGENTS.md`가 없을 때만 구형 fallback | | 들어오는 디스코드 이벤트 | `claude --channels` 내장 | `bot.py` bridge → `turn/start` | | 나가는 응답 | `mcp__discord__reply` 도구 | 동일(discord 플러그인=codex MCP) | | 도구 승인 | 권한 모드 | `approvalPolicy` + bridge 자동수락 | @@ -320,6 +326,6 @@ Claude Code + Codex 에이전트가 공존하게 하는 규칙. `bot-roster.yaml - ✅ Progressive-disclosure **rules 시스템**(규칙을 다 넣지 않고 상황별 참조 — context bloat 방지) — 컨벤션 동봉, [docs/rules-system.md](docs/rules-system.md). - ✅ **가역 메모리 정리(지우지 않고 옮김)** (`scripts/memory_dreaming.py`) — 안 쓰는 메모리를 작업공간 밖 보관소로 옮기고 명령 한 줄로 체크섬 검증 복원. 9칸 전부 같은 기준표 **Codex 메모리 칸 포함**(`~/.codex/memories`, cold subdir env 설정), 보수적(자동이동 게이트·애매하면 사람검토)·기준 자기보정·주1회 강제. 쉬운 설명: [docs/memory-dreaming.md](docs/memory-dreaming.md). - ✅ **회의 watchdog** (`scripts/meeting_watchdog.py`) — **회의마다 권장 (감시 봇 1개 초대, 첫 dispatch 전에 가동)**. 회의 스레드 신설 시 YAML 강제 ~5분 진행 점검(메인테이너 vault 는 ~3분 운영), 목표+전체 작업 완료 시에만 자동 종료(Claude `/goal` 응용), fail-closed = 살아있는 회의 절대 잘못 종료 안 함. [docs/05-meeting-thread-protocol.md](docs/05-meeting-thread-protocol.md) §2.3 + [rules/meeting-protocol.md](rules/meeting-protocol.md) §5 와 짝. -- ⚙️ **설정 가이드**(AGENTS.md · soul.md · rules · Skills 2.0 체크리스트) — [docs/SETUP-CONFIG-GUIDE.md](docs/SETUP-CONFIG-GUIDE.md). +- ⚙️ **설정 가이드**(정본 AGENTS.md + SOUL v2 capsule · rules · Skills 2.0 체크리스트) — [docs/SETUP-CONFIG-GUIDE.md](docs/SETUP-CONFIG-GUIDE.md). 라이선스: 레포 참조. 본인이 통제하는 머신 + 신뢰 가능한 비공개 디스코드 서버에서만 사용. diff --git a/README.md b/README.md index 9550ec7..38791d1 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ npx github:treylom/ThisCodex doctor --non-interactive # (terminal) verificatio |---|---|---| | Codex CLI as a persistent Discord bot | ✅ working | `codex app-server` (headless) + Python bridge daemon (`bot.py`) + discord.py | | Multi-client same-thread (watch/steer the bot's conversation from a TUI) | ✅ working | `codex resume --remote ws://…` against the same app-server | -| Persona / vault rules auto-loaded | ✅ working | `~/.codex/config.toml` → `project_doc_fallback_filenames = ["SOUL.md","AGENTS.md"]` | +| Persona / vault rules auto-loaded | ✅ working | canonical `BOT_WD/AGENTS.md`; `SOUL.md` is configured only as a legacy fallback when `AGENTS.md` is absent | | Cross-bot addressing + meeting discipline | ✅ working | `bot-roster.yaml` SoT injected at SessionStart | | YOLO (full-access) execution | ✅ working | `thread/start` **and** `thread/resume` both send `sandbox:"danger-full-access"`, `approvalPolicy:"never"` | | Image generation | ✅ working | codex built-in `image_gen.imagegen` tool | @@ -228,7 +228,8 @@ zero-config default. ### 3.2 `~/.codex/config.toml` ```toml -project_doc_fallback_filenames = ["SOUL.md", "AGENTS.md"] +# AGENTS.md is Codex's canonical filename; SOUL.md is legacy fallback only. +project_doc_fallback_filenames = ["SOUL.md"] project_doc_max_bytes = 65536 [mcp_servers.discord] @@ -239,7 +240,12 @@ DISCORD_STATE_DIR = "~/.claude/channels/discord-" ``` ### 3.3 Bot working directory -Put `SOUL.md` (persona) and `AGENTS.md` (rules — including the static Discord-reply rule, see §4) in the bot WD. They are auto-loaded every thread; **do not** re-inject persona text per turn. +Put one canonical `AGENTS.md` in the bot WD. It contains the SOUL v2 persona +capsule and the static Discord-reply rule (see §4), and points to the rules +router. Codex selects one instruction file per directory; a same-directory +`SOUL.md` is not loaded alongside `AGENTS.md`. `SOUL.md` remains a legacy +fallback only when `AGENTS.md` is absent. **Do not** re-inject static persona +text per turn. ### 3.4 Run it (the bridge is what grants access) A 2-window tmux launcher (`scripts/launch.sh`): window `infra` runs your @@ -387,7 +393,7 @@ These are the rules that make Claude Code + Codex agents coexist. They live in ` - **Direct channels are exempt** from the mention rule (`require_mention: false`). - **Meetings = dedicated threads**: any task with ≥2 bots, ≥10 min, or an agenda (2-of-3) gets its own thread; the main channel only gets a redirect. One-shot relays/ACKs stay inline. - **SessionStart injection**: a single renderer (`roster-inject.py`) feeds the same coordinates + rules into both Claude Code bots (via the session-init hook) and Codex bots (via `~/.codex/hooks.json`). -- **Discord-reply rule (static, in AGENTS.md — not per turn)**: each turn arrives as ``; reply with `mcp__discord__reply(chat_id, reply_to=message_id)`. Persona/vault discipline is always on because `SOUL.md`/`AGENTS.md` are project-doc auto-loaded. +- **Discord-reply rule (static, in AGENTS.md — not per turn)**: each turn arrives as ``; reply with `mcp__discord__reply(chat_id, reply_to=message_id)`. Persona/vault discipline is always on because the canonical `AGENTS.md` is auto-loaded. --- @@ -397,7 +403,7 @@ Bringing a Claude Code agent's behavior to Codex (and back): | Concern | Claude Code | Codex equivalent | |---|---|---| -| Persona/rules load | `CLAUDE.md` + SessionStart hook | `AGENTS.md`/`SOUL.md` via `project_doc_fallback_filenames` | +| Persona/rules load | `CLAUDE.md` + SessionStart hook | canonical `AGENTS.md`; `SOUL.md` only as a legacy fallback when `AGENTS.md` is absent | | Inbound Discord event | built into `claude --channels` | `bot.py` bridge → `turn/start` | | Outbound | `mcp__discord__reply` tool | identical (discord plugin as codex MCP) | | Tool approvals | permission modes | `approvalPolicy` + bridge auto-accept elicitation | @@ -433,6 +439,6 @@ When upstream exposes `computer_use` to the CLI, **do not** pipe untrusted Disco - ✅ Progressive-disclosure **rules system** (no context bloat — situational rule routing) — convention shipped, see [docs/rules-system.md](docs/rules-system.md). - ✅ **Reversible memory archival** (`scripts/memory_dreaming.py`) — *move-not-delete* cleanup to out-of-WD cold storage, one-command checksum-verified restore; one tier-agnostic rubric across all tiers **incl. the Codex memory tier** (`~/.codex/memories`, env-configurable cold subdir); conservative (auto-archive gated, ambiguous → human review), criteria self-correct from restores; weekly-enforced. Plain-language: [docs/memory-dreaming.md](docs/memory-dreaming.md). - ✅ **Meeting watchdog** (`scripts/meeting_watchdog.py`) — **recommended on every meeting (invite one watchdog bot per meeting, start before the first dispatch)**. On meeting-thread creation, YAML-enforced ~5-min progress check (maintainer's vault runs ~3 min); self-terminates only when goal AND all tasks complete (models Claude Code `/goal`); fail-closed = never falsely terminate a live meeting. Pairs with [docs/05-meeting-thread-protocol.md](docs/05-meeting-thread-protocol.md) §2.3 + [rules/meeting-protocol.md](rules/meeting-protocol.md) §5. -- ⚙️ **Config guide** (AGENTS.md · soul.md · rules · Skills 2.0 checklist) — [docs/SETUP-CONFIG-GUIDE.md](docs/SETUP-CONFIG-GUIDE.md). +- ⚙️ **Config guide** (canonical AGENTS.md + SOUL v2 capsule · rules · Skills 2.0 checklist) — [docs/SETUP-CONFIG-GUIDE.md](docs/SETUP-CONFIG-GUIDE.md). License: see repo. Use on machines you control, with trusted private Discord servers only. diff --git a/bin/thiscodex.mjs b/bin/thiscodex.mjs index 5ac9551..b3b9d61 100755 --- a/bin/thiscodex.mjs +++ b/bin/thiscodex.mjs @@ -16,7 +16,7 @@ import { } from '../scripts/lib/state.mjs'; import { verifyStep } from '../scripts/lib/doctor.mjs'; import { applySkillInstall, marketplaceHint, patchCodexConfig } from '../scripts/lib/apply.mjs'; -import { aliasBlock, materializeBotFiles } from '../scripts/lib/materialize.mjs'; +import { aliasBlock, materializeBotFiles, migrateIdentity } from '../scripts/lib/materialize.mjs'; import { promptForStep } from '../scripts/lib/prompts.mjs'; import { appendAutomationAudit, @@ -45,7 +45,7 @@ import { } from '../scripts/lib/browser-tools.mjs'; const args = process.argv.slice(2); -const command = ['init', 'doctor', 'smoke', 'discord-thread', 'browser-e2e', 'automation-flow', 'automation-attempt', 'automation-gate'].includes(args[0]) ? args.shift() : 'init'; +const command = ['init', 'doctor', 'smoke', 'discord-thread', 'browser-e2e', 'automation-flow', 'automation-attempt', 'automation-gate', 'migrate-identity'].includes(args[0]) ? args.shift() : 'init'; const has = flag => args.includes(flag); const arg = name => { const found = args.find(a => a.startsWith(`${name}=`)); @@ -251,6 +251,34 @@ if (command === 'discord-thread') { process.exit(output.ok ? 0 : 2); } +if (command === 'migrate-identity') { + let output; + try { + if (has('--preview') && has('--apply')) throw new Error('migrate-identity accepts either --preview (the default) or --apply, not both'); + const installState = loadInstallState(); + const bot = arg('--bot-wd') || installState.confirmed_bot_wd; + if (!bot) throw new Error('migrate-identity requires --bot-wd or a confirmed guided-install BOT_WD'); + const rollback = has('--rollback'); + output = migrateIdentity({ + repo: resolve(arg('--repo-root') || repoRoot), + bot: resolve(bot), + apply: has('--apply'), + rollback, + }); + output.next_command = output.ok && output.mode === 'preview' + ? rollback + ? 'Review the exact candidate path, then rerun with --rollback --apply to remove only the unchanged staged candidate.' + : 'Review the exact source, current, target, backup, and candidate paths, then rerun with --apply to stage without overwriting the active identity file.' + : output.ok && output.action === 'backed_up_then_staged_v2_candidate_no_overwrite' + ? `Review AGENTS.md.v2 against ${output.current_kind === 'legacy_soul' ? 'SOUL.md' : 'AGENTS.md'}; the original remains active and the backup/receipt are the rollback point.` + : ''; + } catch (error) { + output = { ok: false, code: 'identity_migration_error', action: 'none', message: error.message }; + } + console.log(JSON.stringify(output, null, 2)); + process.exit(output.ok ? 0 : 2); +} + const automationPolicy = getAutomationPolicy(); if (command === 'browser-e2e') { diff --git a/docs/SETUP-CONFIG-GUIDE.md b/docs/SETUP-CONFIG-GUIDE.md index d2b8a47..cd4f59a 100644 --- a/docs/SETUP-CONFIG-GUIDE.md +++ b/docs/SETUP-CONFIG-GUIDE.md @@ -13,10 +13,13 @@ New here and not a developer? Think of your bot as a **new teammate**: - **CLAUDE.md / AGENTS.md / GEMINI.md** = their *one-page job sheet* — what the project is, where they sit, and "check the handbook when the situation calls for it." Keep it short. -- **soul.md** = their *personality & voice* — copy a ready-made template, fill the blanks. +- **SOUL v2 capsule** = their *personality & voice* — a compact section inside + that one canonical instruction file, not a second same-directory file. - **rules/** = the *company handbook* — they do NOT memorize it; they open the one page that matches the moment. -You do three things: (1) put one job sheet, (2) pick a personality template, (3) point at the handbook. That is the whole setup. Everything below is just the detail of those three. +You do three things: (1) put one canonical instruction file, (2) fill its SOUL +v2 capsule, (3) point it at the handbook. That is the whole setup. Everything +below is just the detail of those three. > **Don't want to hand-write any of it?** Run **§0 guided onboarding** — the > installing AI designates your workspace, makes the skill plugin available, @@ -30,12 +33,13 @@ You do three things: (1) put one job sheet, (2) pick a personality template, (3) Start by giving the installing AI this prompt: ```text -Configure my Codex bot from this ThisCodex repo. Read docs/SETUP-CONFIG-GUIDE.md first, designate the workspace, make the bundled `/prompt` skill available, scan only the needed files, draft AGENTS.md and soul.md through `/prompt`, then verify the files and explain the result in plain language. +Configure my Codex bot from this ThisCodex repo. Read docs/SETUP-CONFIG-GUIDE.md first, designate the workspace, make the bundled `/prompt` skill available, scan only the needed files, draft one canonical AGENTS.md with its SOUL v2 capsule through `/prompt`, then verify the files and explain the result in plain language. ``` The non-developer path. The installing AI runs it **once**, and the output is -a finished `AGENTS.md` + `soul.md` per bot — produced by the bundled `/prompt` -skill and a short `/using-superpowers` interview, not hand-written. §1-§6 +a finished canonical `AGENTS.md` per bot — including its SOUL v2 capsule — +produced by the bundled `/prompt` skill and a short `/using-superpowers` +interview, not hand-written. §1-§6 below are the manual equivalent if you'd rather author by hand or audit what the AI produced. @@ -47,7 +51,7 @@ the AI produced. bots search and store into (**working directory** = 작업 폴더, the folder a bot "lives in"). - *"For each bot, what is its working directory?"* — one bot = one working - directory; that folder is where that bot's `AGENTS.md` + `soul.md` go. + directory; that folder is where that bot's canonical `AGENTS.md` goes. Don't guess the vault root — ask. (The companion [ThisCode](https://github.com/treylom/ThisCode) repo ships @@ -69,16 +73,16 @@ Verify the skill resolves with `/skills` (or a description-match invoke). 1. **Scan the designated workspace.** For the vault root and each bot working directory, the AI reads: folder structure (top ~2 levels), any existing - `AGENTS.md`/`CLAUDE.md`/`soul.md`, a *sample* of notes for dominant topics + `AGENTS.md`/`CLAUDE.md`/legacy `SOUL.md`, a *sample* of notes for dominant topics (it does not slurp the whole vault), and which Discord channel/role each bot will own. This grounds the draft in your *actual* workspace, not a generic template. -2. **Auto-invoke `/prompt` to draft the two meta files.** For each bot working +2. **Auto-invoke `/prompt` to draft the canonical instruction file.** For each bot working directory, the AI **must** invoke the bundled `skills/prompt/` skill - (force-invoke — see §6; never hand-roll) to produce: a thin `AGENTS.md` - (the job sheet — §1 shape) and a `soul.md` seeded from the closest - companion `ThisCode/templates/soul-*.md` (the persona — §2 shape). - `/prompt` is mandatory here precisely because these two files *are* prompts + (force-invoke — see §6; never hand-roll) to produce one canonical + `AGENTS.md`: a thin job-sheet/rules pointer plus the SOUL v2 capsule + described in §2. `/prompt` is mandatory here precisely because this file + *is* the prompt (the bot's standing instruction); ad-hoc authoring is the exact regression §6 exists to stop. Also **scaffold the bot's `rules/`** by copying the bundled `rules/` skeleton (INDEX router + generic topical stubs) into the @@ -104,8 +108,10 @@ Verify the skill resolves with `/skills` (or a description-match invoke). (search/write paths; Obsidian-present vs. Obsidian-less). Answers are written back into the drafts; the meta file is then pointed at `rules/INDEX.md` (rules are never inlined — §3). -4. **Verify before declaring done.** `soul.md` frontmatter valid · signature - line present · meta file points *only* at `rules/INDEX.md` · `/prompt` was +4. **Verify before declaring done.** SOUL v2 frontmatter valid · both capsule + markers present (`` / ``) + · signature line present · canonical meta file points *only* at + `rules/INDEX.md` for rules · `/prompt` was actually entered (not free-handed) · the bridge actually injected the persona (SKILL.md §Verify). Then continue with the normal §4 flow. @@ -124,37 +130,40 @@ So this is not skippable, wire it the same way §6 wires `/prompt`: scan → `/prompt` draft → `/using-superpowers` interview) before normal work."* - Or a `rules/INDEX.md` row: - `First run / WD has no soul.md | onboarding.md | Run SETUP-CONFIG-GUIDE §0: workspace → superpowers → scan → /prompt → /using-superpowers` + `First run / WD has no AGENTS.md | onboarding.md | Run SETUP-CONFIG-GUIDE §0: workspace → superpowers → scan → /prompt → /using-superpowers` ## The config surfaces (and load order) -![Config surfaces and load order — AGENTS.md → soul.md → rules/INDEX.md → memory/meetings](../assets/config-surfaces-loading-order.png) +![Config surfaces and load order — project/root AGENTS.md → bot AGENTS.md +with SOUL v2 capsule → rules/INDEX.md → memory/meetings](../assets/config-surfaces-loading-order.png) -A ThisCodex (Codex CLI) bot composes behavior from these, in order: +Codex discovers instruction files from the project/Git root down to the +current working directory; more-local instructions are applied later. At each +directory it selects **one** instruction file (the configured precedence +applies). ThisCodex's guided default is `AGENTS.md`. Do not put both `SOUL.md` +and `AGENTS.md` in one bot directory and assume they will both load. + +The canonical bot `AGENTS.md` holds a compact SOUL v2 capsule and points to +the rules router: ``` -1. AGENTS.md ← project + bot working-dir meta. Codex auto-loads this - (Codex's CLAUDE.md- as the project doc. Points ONLY at rules/INDEX.md. - equivalent) - ↓ -2. soul.md / SOUL.md ← persona / voice / model meta. The bridge injects it - (persona doc) at session start (mirrors Claude's SessionStart). +project/root AGENTS.md ← shared project guidance (if present) ↓ -3. rules/INDEX.md ← progressive disclosure. Bridge injects per-turn - (router; on demand) dynamic state; static rules stay here, pulled by - trigger — never re-injected every turn. +bot-working-dir AGENTS.md + ├─ SOUL v2 capsule ← persona / voice / model / domain tools + └─ rules/INDEX.md ← progressive-disclosure router; read matching topic ↓ - memory / meetings ← run-time state, not config +memory / meetings ← run-time state, not configuration ``` **single source of truth** (단일 기준 출처 — one place each fact lives): keep -each surface to its own concern. Do not copy rules into `AGENTS.md`/`soul.md` -— that is the context-bloat failure the rules system exists to prevent. +each surface to its own concern. Do not copy rules into the `AGENTS.md` SOUL +v2 capsule — that is the context-bloat failure the rules system exists to +prevent. | Surface | What it owns | Author from | |---|---|---| -| `AGENTS.md` | project meta + bot-WD meta + INDEX pointer | §1 | -| `soul.md` | persona, voice, signatures, model | §2 | +| `AGENTS.md` | project/bot-WD meta, SOUL v2 capsule, and INDEX pointer | §1–§2 | | `rules/` | situational operating rules | §3 → [rules-system.md](rules-system.md) | See also [skill-portability.md](skill-portability.md) §3 for *why* Codex uses @@ -162,26 +171,36 @@ See also [skill-portability.md](skill-portability.md) §3 for *why* Codex uses ## §1 — AGENTS.md (the meta file, Codex side) -Codex auto-loads `AGENTS.md` as the project document — it is the Codex -equivalent of Claude Code's `CLAUDE.md`. Keep it thin (it is always in -context). It carries: project, the bot's working-dir role, the **load order -above**, and a single pointer to `rules/INDEX.md` — never the rule bodies. +Codex auto-loads the selected `AGENTS.md` as a project instruction document — +it is the Codex equivalent of Claude Code's `CLAUDE.md`. Keep it thin (it is +always in context). It carries the project, the bot's working-dir role, the +SOUL v2 capsule, and a single pointer to `rules/INDEX.md` — never rule bodies. Minimal template: ```markdown +--- +name: +description: +version: 2.0.0 +triggers: [""] +--- + # — bot working-dir meta (Codex) -This dir is the project root and **'s working dir**. On a bot -session, load in order: +This is **'s canonical bot-working-directory instruction file**. +Codex merges instruction files from the project root to this directory. Do +not add a same-directory `SOUL.md` expecting both files to load. -0. ./AGENTS.md (this file — project + bot-WD meta; Codex auto-loads) -1. /soul.md (persona · voice · model) -2. rules/INDEX.md (situational rules — Read the matched topic file on demand) -3. meetings/-/ (current task context) + +## SOUL v2 capsule -**Bot meta**: (`<@discord-id>`) · · model `` · -WD ``. +- **Identity and voice:** +- **Specialist domain and tool chain:** ; prefer +- **Local gates and boundaries:** +- **Delegation defaults:** follow `rules/orchestration.md` §11 R1–R5 when its + trigger matches. + ## Operating rules = rules/ (progressive disclosure) Every turn: self-check rules/INDEX.md trigger table → Read the matched row's @@ -189,13 +208,14 @@ file → apply. Conflict priority: **explicit user instruction > rule file > inline default**. ``` -Gotcha: the pointer block must be the *only* rules content here. A rule that -grows inline moves to `rules/.md`, leaving one INDEX row. +Gotcha: the pointer block must be the *only* operating-rule content here. A +rule that grows inline moves to `rules/.md`, leaving one INDEX row. -## §2 — soul.md (persona / voice / model) +## §2 — SOUL v2 capsule (persona / voice / model) -ThisCodex ships no template of its own — reuse the companion repo's fillable -soul templates (anatomy is harness-agnostic): +SOUL v2 is a **content contract**, not a requirement to create a second +`SOUL.md` in the bot working directory. ThisCodex's guided installer seeds the +canonical `AGENTS.md` example with these required slots: | Template | For | |---|---| @@ -206,13 +226,39 @@ soul templates (anatomy is harness-agnostic): | [soul-schedule-bot.md](https://github.com/treylom/ThisCode/blob/main/templates/soul-schedule-bot.md) | scheduling | Steps: -1. Copy the closest template into your bot's working dir as `soul.md`. +1. Use the closest companion template to draft the persona slots, then copy + the resulting capsule into the canonical `AGENTS.md`; do not keep the + template as a second same-directory instruction file. 2. Fill the **frontmatter** (문서 맨 위 `---` 메타 블록 — `name`, - `description`, `version`, `triggers`). The bridge reads this to inject. -3. Keep the **forced-persona self-check table** + **completion signature** - (`— `) — signature absence is the #1 persona-regression symptom. -4. Set the model meta to a real Codex model id (e.g. a `gpt-5.x` id your - Codex CLI exposes). + `description`, `version: 2.0.0`, `triggers`). This makes the v2 identity + contract visible and portable. +3. Keep the fixed `` and + `` markers around the **identity/voice + self-check**, **specialist domain + preferred tool chain**, **local + gates/boundaries**, and **completion signature** (`— `). A missing + marker or signature is a common persona-regression signal. +4. Set model metadata only to a real Codex model id your CLI exposes. + +### New install versus existing install + +**New guided install.** `thiscodex init --apply` copy-once seeds the canonical +`/AGENTS.md` from `examples/AGENTS.md` only when **both** `AGENTS.md` +and `SOUL.md` are absent. It does not add a same-directory `SOUL.md`, and a +later init does not update the installed copy. + +**Existing install.** Treat either an `AGENTS.md` or a legacy `SOUL.md` as an +existing install. Use `thiscodex migrate-identity --preview` first (or omit +`--preview`); review the proposed `AGENTS.md.v2` alongside the active legacy +file. `--apply` stages the v2 candidate and a migration receipt without +overwriting the active file. For a legacy `SOUL.md`, it also saves +`SOUL.md.thiscodex.pre-v2.bak`; for an existing `AGENTS.md`, it saves +`AGENTS.md.thiscodex.pre-v2.bak`. The operator performs the project-specific +cutover only after review, selecting one canonical instruction file for that +directory. `thiscodex migrate-identity --rollback --apply` removes only an +unchanged staged candidate and its receipt; it preserves the original active +file and backup. A changed candidate is refused, so later manual edits are +never clobbered. This migration is deliberately not an automatic update or +merge. ## §3 — rules/ (progressive-disclosure operating rules) @@ -379,9 +425,10 @@ Example asks and what to expect: |---|---| | "Set up codex as a discord bot like claude code" | walks the bridge + persona + rules wiring (this guide) | | "Port these Claude Code skills/rules to Codex" | applies the [skill-portability.md](skill-portability.md) path | -| "Why did you do X?" | answers from the injected soul + the rule that applied (it names which) | +| "Why did you do X?" | answers from the SOUL v2 capsule + the rule that applied (it names which) | -Off-persona / rule ignored? Check (a) `soul.md` frontmatter valid, (b) the +Off-persona / rule ignored? Check (a) the canonical `AGENTS.md` SOUL v2 +frontmatter is valid, (b) the situation matches an `rules/INDEX.md` trigger row, (c) the bridge actually injected the persona (see SKILL.md §Verify / §Troubleshooting). @@ -417,9 +464,9 @@ Wire enforcement into the Codex bot's config: pointer: *"Prompt-authoring tasks → MUST invoke the `prompt` skill before producing any prompt (no ad-hoc prompts)."* - Or a `rules/INDEX.md` row: `Producing a prompt for a model | prompt-skill.md | Invoke skills/prompt first; never hand-roll`. -- In `soul.md`, put a hard rule in the forced-persona self-check table (reuse - the companion `ThisCode/templates/soul-custom.md`, which includes a `/prompt` - enforcement line). +- In the `AGENTS.md` SOUL v2 capsule, put a hard rule in the identity/voice + self-check table (reuse the companion `ThisCode/templates/soul-custom.md` + only as a drafting aid; it is not a second same-directory instruction file). Why a hard rule: prompt quality regresses to ad-hoc without enforced routing; the skill's frameworks (IFCN fact-check base, 5-stage image, GPTs/Gems @@ -428,18 +475,23 @@ structure) apply only if the skill is actually entered. ## Stuck? — friendly FAQ **Q. The bot ignores its personality / signature.** -A. Open `soul.md`. Is the top `---` block (frontmatter) filled and valid? Is the completion-signature line still there? Missing signature is the #1 cause. +A. Open the canonical `AGENTS.md`. Is the top `---` block (SOUL v2 +frontmatter) filled and valid? Is the completion-signature line still there? +Missing signature is the #1 cause. **Q. The bot did not follow a rule I expected.** A. A rule only loads when its trigger row in `rules/INDEX.md` matches the situation. Check that a row actually describes your case; if not, add one. **Q. Where do I put these files?** -A. The job sheet (`CLAUDE.md`/`AGENTS.md`) at the project/bot root; `soul.md` in the bot working dir; `rules/` next to it. The load order at the top of this guide shows the sequence. +A. Put one selected instruction file in each directory: shared +`AGENTS.md`/`CLAUDE.md` where needed and the canonical `AGENTS.md` in the bot +working dir. Its SOUL v2 capsule lives inside that file; put `rules/` next to +it. The load order at the top of this guide shows the sequence. **Q. Do I really need Obsidian?** A. For full memory + internal search, yes — recommended. Without it a plain Discord bot still works for basic connectivity, but memory/search quality is not guaranteed (see the README "Before you start"). -**Q. What model id do I write in soul.md?** +**Q. What model id do I write in the SOUL v2 capsule?** A. A real id your tool exposes (e.g. an Opus/Sonnet/Haiku id for Claude Code, a gpt-5.x id for Codex). Not a made-up name. **Q. It still feels overwhelming.** @@ -461,16 +513,17 @@ A. Do only the three steps in "In one minute" first. Skip §1-§6 detail until s ### §0 가이드 온보딩 (첫 실행 — 권장, 비개발자용 경로) -직접 안 쓰고 싶으면 이 경로. 설치 AI 가 **한 번** 실행, 결과물은 봇별 완성된 -`AGENTS.md` + `soul.md` — 번들 `/prompt` 스킬 + 짧은 `/using-superpowers` -인터뷰가 생성(손으로 안 씀). 아래 §1~§6 은 손수 작성/감수 시의 수동 대응판. +직접 안 쓰고 싶으면 이 경로. 설치 AI 가 **한 번** 실행, 결과물은 봇별 정본 +`AGENTS.md` 1개(안의 SOUL v2 capsule 포함) — 번들 `/prompt` 스킬 + 짧은 +`/using-superpowers` 인터뷰가 생성(손으로 안 씀). 아래 §1~§6 은 손수 +작성/감수 시의 수동 대응판. **선행(이 순서로 먼저):** - **0a. 작업공간 지정.** 설치 AI 가 평이하게: ① "옵시디언 볼트 / 전체 작업 폴더 어디예요?"(봇 검색·저장 루트 — **working directory** = 작업 폴더) ② - "봇마다 작업 폴더 어디예요?"(봇 1개=폴더 1개, 그 폴더에 그 봇 - `AGENTS.md`+`soul.md`). 볼트 루트 추측 ❌ — 물어볼 것. 동반 + "봇마다 작업 폴더 어디예요?"(봇 1개=폴더 1개, 그 폴더에 그 봇의 정본 + `AGENTS.md`). 볼트 루트 추측 ❌ — 물어볼 것. 동반 [ThisCode](https://github.com/treylom/ThisCode) 레포의 `scripts/claude-discode-init.sh --detect-only` 가 볼트 후보·노트 수 자동탐지 — 가용 시 후보 채워 사용자 확인/수정. 루트 틀리면 이후 전 단계 @@ -486,13 +539,14 @@ A. Do only the three steps in "In one minute" first. Skip §1-§6 detail until s **가이드 흐름(설치 AI 실행):** 1. **지정 작업공간 스캔** — 볼트 루트+각 봇 작업폴더의 폴더 구조(상위 ~2 - 레벨)·기존 `AGENTS.md`/`CLAUDE.md`/`soul.md`·노트 *샘플*(전체 흡입 ❌) + 레벨)·기존 `AGENTS.md`/`CLAUDE.md`/legacy `SOUL.md`·노트 *샘플*(전체 흡입 ❌) 주제·담당 Discord 채널/역할. 일반 템플릿 아닌 *실제* 작업공간 근거. -2. **`/prompt` 자동 호출로 메타 2파일 초안** — 봇 작업폴더마다 번들 - `skills/prompt/` **반드시** 호출(force-invoke, §6 — 손작성 금지)해 얇은 - `AGENTS.md`(업무지시서 §1) + 가장 가까운 동반 - `ThisCode/templates/soul-*.md` 기반 `soul.md`(페르소나 §2). 이 두 파일이 - 곧 prompt(봇 상시 지시) → `/prompt` 강제, 즉흥 작성이 §6 이 막는 회귀. +2. **`/prompt` 자동 호출로 정본 지시 파일 초안** — 봇 작업폴더마다 번들 + `skills/prompt/` **반드시** 호출(force-invoke, §6 — 손작성 금지)해 정본 + `AGENTS.md` 1개(업무지시서 §1 + SOUL v2 capsule §2)를 만든다. 동반 + `ThisCode/templates/soul-*.md`는 capsule 초안용일 뿐 같은 폴더에 두 번째 + 지시 파일로 남기지 않는다. 이 파일이 곧 prompt(봇 상시 지시) → `/prompt` + 강제, 즉흥 작성이 §6 이 막는 회귀. 동시에 **봇 `rules/` 스캐폴드**: 번들 `rules/` 스켈레톤(INDEX 라우터 + generic topical 스텁)을 봇 WD 로 복사 — `AGENTS.md` 는 `rules/INDEX.md` 만 가리킴(§3), 스텁은 다음 단계에서 채움(inline ❌). @@ -511,8 +565,9 @@ A. Do only the three steps in "In one minute" first. Skip §1-§6 detail until s Codex `gpt-5.x` id, 지어냄 ❌) · Discord 표면(채널/스레드·mention id·회의 스레드 거버넌스) · 볼트 범위(옵시디언 유/무). 답을 초안 반영 후 메타를 `rules/INDEX.md` 포인팅(규칙 inline ❌ — §3). -4. **완료 선언 전 검증** — soul.md frontmatter 유효 · 서명 줄 · 메타가 - `rules/INDEX.md` *만* 가리킴 · `/prompt` 실제 진입 · bridge 페르소나 주입 +4. **완료 선언 전 검증** — AGENTS.md 안 SOUL v2 frontmatter 유효 · capsule + marker 2개(`` / ``) · + 서명 줄 · 메타가 `rules/INDEX.md` *만* 가리킴 · `/prompt` 실제 진입 · bridge 페르소나 주입 (SKILL.md §Verify). 이후 §4 흐름. **옵시디언 없는 경로:** 0a 미사용 선택 시 0b~3 그대로, 볼트 범위=연결 전용 @@ -522,25 +577,35 @@ A. Do only the three steps in "In one minute" first. Skip §1-§6 detail until s `AGENTS.md` 규칙 포인터 아래 한 줄: *"첫 실행 / 미설정 작업폴더 → 일반 작업 전 SETUP-CONFIG-GUIDE §0(작업공간 지정 → superpowers 가용화 → 스캔 → `/prompt` 초안 → `/using-superpowers` 인터뷰) 필수."* 또는 `rules/INDEX.md` -행: `첫 실행 / WD 에 soul.md 없음 | onboarding.md | SETUP-CONFIG-GUIDE §0 실행: 작업공간→superpowers→스캔→/prompt→/using-superpowers` +행: `첫 실행 / WD 에 AGENTS.md 없음 | onboarding.md | SETUP-CONFIG-GUIDE §0 실행: 작업공간→superpowers→스캔→/prompt→/using-superpowers` ### 설정 표면 + 로딩 순서 -`AGENTS.md`(프로젝트+봇 WD 메타 — Codex 가 프로젝트 문서로 자동 로드, Claude 의 -CLAUDE.md 대응, **rules/INDEX.md 만 가리킴**) → `soul.md`(페르소나·말투·모델, -bridge 가 세션 시작 시 주입) → `rules/INDEX.md`(라우터; bridge 는 매 턴 동적 -상태만 주입, 정적 규칙은 트리거로 pull — 매 턴 재주입 안 함) → 메모리/회의록. -**single source of truth(단일 기준 출처)**: 규칙을 AGENTS.md/soul.md 에 복붙 -금지 — context 비대화 방지가 rules 시스템의 존재 이유. +Codex 는 프로젝트/Git root에서 현재 작업 폴더까지 지시 파일을 합치며, 디렉터리 +하나당 선택되는 지시 파일은 **하나**다. ThisCodex 기본은 `AGENTS.md`: 같은 봇 +폴더에 `SOUL.md`와 `AGENTS.md`를 두고 둘 다 자동 로드된다고 가정하지 않는다. +봇 WD의 정본 `AGENTS.md` 안에 SOUL v2 capsule(페르소나·말투·모델·도구 체인)을 +넣고 `rules/INDEX.md`(라우터; 매칭 파일만 그때 Read)로 잇는다 → 메모리/회의록. +**single source of truth(단일 기준 출처)**: 운영 규칙을 AGENTS.md capsule에 +복붙 금지 — context 비대화 방지가 rules 시스템의 존재 이유. ### §1 AGENTS.md (Codex 메타) 항상 context — 얇게. (a)프로젝트 (b)봇 WD 역할 (c)위 로딩 순서 (d) `rules/INDEX.md` 포인터 1개. 템플릿은 위 영문 §1 코드블록. -### §2 soul.md -ThisCodex 자체 템플릿 없음 → 동반 레포 ThisCode 의 `templates/soul-*.md`(절대 -링크, 위 표) 중 가까운 것 복사 → frontmatter(맨 위 `---` 메타 블록) 채움 → -자가점검 표 + 완료 서명(`— <봇이름>`) 유지 → 모델 메타를 Codex CLI 가 실제 -노출하는 `gpt-5.x` id 로. +### §2 SOUL v2 capsule +SOUL v2는 별도 `SOUL.md` 파일명이 아니라 정본 AGENTS.md 안 내용 계약이다. +동반 레포 ThisCode 의 `templates/soul-*.md`(위 표)는 capsule 초안용으로만 쓰고 +같은 폴더에 두 번째 지시 파일로 남기지 않는다. frontmatter(맨 위 `---` 메타 +블록: `name`·`description`·`version: 2.0.0`·`triggers`) + 정체성/말투·도구 +체인·고유 게이트/경계·완료 서명(`— <봇이름>`)을 고정 marker +`` / `` 사이에 유지한다. + +**신규 설치**는 `AGENTS.md`와 `SOUL.md`가 **모두 없을 때만** `thiscodex init +--apply`가 정본 AGENTS.md를 copy-once로 만든다. `SOUL.md`만 있으면 기존 설치다: +`thiscodex migrate-identity --preview`로 먼저 확인하고, `--apply`는 legacy +SOUL.md를 active로 보존한 채 `AGENTS.md.v2`+receipt와 +`SOUL.md.thiscodex.pre-v2.bak`만 만든다. 검토 뒤 manual cutover하며, +`--rollback --apply`는 unchanged candidate+receipt만 제거한다. ### §3 rules/ 정본 = [rules-system.md](rules-system.md)(본 레포, 중복 금지). "Applying to a @@ -550,8 +615,8 @@ Read → 적용. 우선순위 = **사용자 명시 지시 > rule 파일 > inline ### §4 설정·질문 방법 [README §Setup](../README.md) + [SKILL.md](../skills/thiscodex/SKILL.md) 대로 설치(`codex plugin marketplace add treylom/ThisCodex`, `/skills thiscodex` 로 -호출 — `codex plugin install` 서브커맨드 없음). 페르소나/규칙 벗어나면 soul.md -frontmatter·INDEX 매칭·bridge 주입(SKILL.md §Verify/§Troubleshooting) 점검. +호출 — `codex plugin install` 서브커맨드 없음). 페르소나/규칙 벗어나면 AGENTS.md +안 SOUL v2 frontmatter·INDEX 매칭·bridge 주입(SKILL.md §Verify/§Troubleshooting) 점검. ### §5 Skills 2.0 체크리스트 `skills//SKILL.md`: frontmatter 존재 · `name` kebab-case · @@ -565,16 +630,17 @@ Skills 2.0 12-check 루브릭). push 전 매 항목 수동 확인 + diff 에서 봇을 **새 팀원**이라고 생각하세요: - `CLAUDE.md/AGENTS.md/GEMINI.md` = 한 장짜리 **업무 지시서**(프로젝트가 뭔지·어디 앉는지·"상황 맞으면 매뉴얼 펴봐"). 짧게. -- `soul.md` = **성격·말투**. 완성 템플릿 복사 후 빈칸 채우기. +- `AGENTS.md` 안 **SOUL v2 capsule** = 성격·말투. 완성 템플릿으로 초안 후 + 같은 정본 파일의 빈칸 채우기. - `rules/` = **사내 매뉴얼**. 통째로 외우지 않고, 그 순간에 맞는 한 페이지만 펴봄. 할 일 3개: ① 지시서 1장 ② 성격 템플릿 1개 ③ 매뉴얼 가리키기. 이게 전부입니다. 아래는 그 3개의 세부일 뿐. ### 막히면? — 자주 묻는 질문 -- **봇이 성격/서명을 무시해요** → `soul.md` 맨 위 `---` 블록 채워졌는지 + 완료 서명 줄 남아있는지 (서명 누락이 1순위 원인). +- **봇이 성격/서명을 무시해요** → 정본 `AGENTS.md` 맨 위 SOUL v2 `---` 블록 채워졌는지 + 완료 서명 줄 남아있는지 (서명 누락이 1순위 원인). - **기대한 규칙을 안 따라요** → 규칙은 `rules/INDEX.md` 트리거 행이 상황과 맞을 때만 로드. 내 상황 설명하는 행 있는지 확인, 없으면 추가. -- **파일 어디 둬요?** → 지시서=프로젝트/봇 루트, `soul.md`=봇 작업폴더, `rules/`=그 옆. 맨 위 로딩 순서 그림 참고. +- **파일 어디 둬요?** → 디렉터리마다 선택 지시 파일 1개: 공용 `AGENTS.md`/`CLAUDE.md`와 봇 작업폴더의 정본 `AGENTS.md`; SOUL v2는 그 안, `rules/`는 옆. 맨 위 로딩 순서 그림 참고. - **옵시디언 꼭 필요해요?** → 메모리·내부검색 제대로 쓰려면 권장. 없이도 단순 연결은 되지만 품질 미보장. - **모델 id 뭘 써요?** → 도구가 실제 노출하는 id(Claude=Opus/Sonnet/Haiku id, Codex=gpt-5.x id). 지어낸 이름 ❌. - **너무 복잡해요** → 위 "1분 설명" 3단계만 먼저. 뭔가 깨지기 전엔 §1~§6 세부 skip. 이 문서는 한 번에 끝낼 체크리스트가 아니라 참조용. diff --git a/docs/SETUP.md b/docs/SETUP.md index d9ef514..c2931fd 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -104,7 +104,8 @@ thiscodex doctor ## 5. Next Docs - [SETUP-BEGINNER.md](SETUP-BEGINNER.md) — same flow in simpler words. -- [SETUP-CONFIG-GUIDE.md](SETUP-CONFIG-GUIDE.md) — author `AGENTS.md`, - `soul.md`, and rules with the bundled `/prompt` skill. +- [SETUP-CONFIG-GUIDE.md](SETUP-CONFIG-GUIDE.md) — author the canonical + `AGENTS.md` (including its SOUL v2 capsule) and rules with the bundled + `/prompt` skill. - [RECENT-CHANGES.md](RECENT-CHANGES.md) — newest behavior changes an installed bot must reflect. diff --git a/docs/guided-installer-design.md b/docs/guided-installer-design.md index 2dc0b4e..cb6b09b 100644 --- a/docs/guided-installer-design.md +++ b/docs/guided-installer-design.md @@ -140,14 +140,15 @@ before prompt authoring. The installer routes the bundled prompt flow to draft: -- `AGENTS.md` -- `soul.md` +- one canonical `AGENTS.md` with the SOUL v2 identity slots - `rules/` Then it runs or directs the `/using-superpowers` interview. The interview is a -required gate for full guided onboarding because it shapes `AGENTS.md`, -`soul.md`, and `rules/`. If the interview cannot run or cannot be routed, the -guided flow stops before file generation and prints the next command. +required gate for full guided onboarding because it shapes `AGENTS.md` and +`rules/`. A pre-existing `SOUL.md` is handled only as a legacy migration +source; the installer does not generate it beside `AGENTS.md`. If the interview +cannot run or cannot be routed, the guided flow stops before file generation +and prints the next command. The output must be stored as part of the install log so the user can inspect what changed. Non-interactive mode never runs the interview; it only verifies diff --git a/docs/guided-installer-requirements.md b/docs/guided-installer-requirements.md index f44828d..f806dd7 100644 --- a/docs/guided-installer-requirements.md +++ b/docs/guided-installer-requirements.md @@ -47,7 +47,9 @@ The guided sequence is ordered: 6. On WSL, detect paired Windows profiles and offer Windows-side sync. 7. Check and optionally patch `~/.codex/config.toml`. 8. Check the Codex-native superpowers path and record that the check ran. -9. Route the prompt flow that drafts `AGENTS.md`, `soul.md`, and `rules/`. +9. Route the prompt flow that drafts one canonical `AGENTS.md` (SOUL v2 + identity slots included) and `rules/`. A pre-existing `SOUL.md` is a legacy + migration source, not a second generated peer. 10. Run or route the `/using-superpowers` interview. This is required for full guided onboarding; if it is unavailable, the installer must stop before prompt-file generation and print the next command. @@ -97,7 +99,7 @@ that drafts bot instructions. If `/using-superpowers` is not available and cannot be made available in the current environment, the installer must stop the guided onboarding flow and -explain the next command. It must not continue into `AGENTS.md`, `soul.md`, or +explain the next command. It must not continue into canonical `AGENTS.md` or `rules/` generation as if the interview happened. The interview execution model is explicit: diff --git a/docs/skill-portability.md b/docs/skill-portability.md index f80e02d..7ef7005 100644 --- a/docs/skill-portability.md +++ b/docs/skill-portability.md @@ -48,9 +48,9 @@ Portability rule: place the shared `SKILL.md` at `~/.agents/skills//SKILL. ## 3. The SDK 0.130 trap & workaround `[source-backed — 루돌프, hard-code 확인 2026-05-15: runner.ts:135 + test/runner.test.ts:149, commit 5e90004]` -`@openai/codex-sdk` 0.130 has **no dedicated system slot**. Persona/rules can't be injected as a true system message. Workaround in production: inject `SOUL + AGENTS + TOOLS` as an inline `[system]\n…\n\n[user]\n…` prefix. The native path (per-persona `AGENTS.md` in `workingDirectory`, codex auto-load) is deferred to a follow-up turn — so first-turn persona is the inline prefix. Hard-code 증거(루돌프, 2026-05-15): `packages/gateway/src/codex/runner.ts:135` 가 `[system]…[user]` 프리픽스를 직접 return, `test/runner.test.ts:149` 가 `lastInput` 에 `[system]` 포함을 assert — 코드+테스트 양측에 박힘 (commit 5e90004). +`@openai/codex-sdk` 0.130 has **no dedicated system slot**. In the external `codex-bots` runtime measured in May 2026, the bridge worked around that limitation by injecting its static instruction bundle as an inline `[system]\n…\n\n[user]\n…` prefix. Hard-code 증거(루돌프, 2026-05-15): `packages/gateway/src/codex/runner.ts:135` 가 `[system]…[user]` 프리픽스를 직접 return, `test/runner.test.ts:149` 가 `lastInput` 에 `[system]` 포함을 assert — 코드+테스트 양측에 박힘 (commit 5e90004). This is historical evidence for that external bridge, not ThisCodex's current project-document contract. -`[session-verified — Karpathy]` Same symptom on the Mac bridge: codex `app-server` exposes only a generic tool/turn protocol, so `bot.py` injects the dynamic `` block per turn while **static** persona/rules ride on `project_doc_fallback_filenames = ["SOUL.md","AGENTS.md"]` auto-load (per-turn re-injection removed — P1.5 trim). Net: identical mitigation, two runtimes. +`[session-verified — Karpathy]` The Mac bridge injects only the dynamic `` block per turn. ThisCodex now keeps static identity and local rules in the canonical `AGENTS.md`, which Codex discovers natively. `project_doc_fallback_filenames = ["SOUL.md"]` exists only for legacy workspaces that have not migrated yet; it is not a second same-directory identity source. ## 4. Winning pattern — one SKILL.md, dual invoke `[source-backed — 루돌프; openclaw/hermes-agent pattern absorbed]` @@ -60,7 +60,7 @@ Portability rule: place the shared `SKILL.md` at `~/.agents/skills//SKILL. |---|---|---|---| | Auth | per-channel pairing | reuse `~/.codex/auth.json` | 0 keys issued | | Daemon | tmux + alias | systemd template `codex-bot@` | systemd > tmux for stability | -| Persona | single `soul.md` | SOUL+AGENTS+TOOLS+ROUTES.yaml | split = clean handoff | +| Persona | external harness-specific file | canonical `AGENTS.md`; legacy `SOUL.md` fallback only | one active project-document source per directory | | Skill location | `~/.claude/skills/` | `.agents/skills/` 4-layer | **same SKILL.md invoked by CLI *and* bot** | | Memory | per-WD memory | `personas//state/` (gitignored) | cross-bot contamination blocked | @@ -72,7 +72,7 @@ Portability rule: place the shared `SKILL.md` at `~/.agents/skills//SKILL. 1. `~/.agents/skills//SKILL.md` — `name` + `description` frontmatter (CC-compatible). 2. MCP deps → `metadata` (auto-installed via stable `skill_mcp_dependency_install`). -3. Persona/rules → `AGENTS.md`+`SOUL.md` in bot WD + `project_doc_fallback_filenames`; first-turn = inline `[system]` prefix workaround (SDK 0.130). +3. Persona/rules → one canonical `AGENTS.md` in the bot WD. Keep `project_doc_fallback_filenames = ["SOUL.md"]` only while a legacy SOUL-only workspace is awaiting the explicit `migrate-identity` flow; do not install both as active peers. 4. Dynamic per-message state → bridge prompt only (``); everything static → `AGENTS.md` (auto-loaded, don't re-inject). 5. Verify: `codex features list | grep skill_` both targets; `/skills ` explicit invoke; implicit description-match. diff --git a/docs/yolo-bridge-contract.md b/docs/yolo-bridge-contract.md index b4e3a7b..17b8316 100644 --- a/docs/yolo-bridge-contract.md +++ b/docs/yolo-bridge-contract.md @@ -85,7 +85,7 @@ A long model turn must not be a silent gap. Two layers, both required (defense-in-depth — one is a model instruction that can be forgotten, the other is code that cannot): -- **Model layer**: the bot's `SOUL.md`/`AGENTS.md` carries a hard rule to +- **Model layer**: the bot's canonical `AGENTS.md` carries a hard rule to proactively post a new message on completion / partial-artifact / awaiting-permission / external-handoff / blocked > ~10–15 min — without waiting to be pinged. (See the deployed soul templates + rules-system.) @@ -244,7 +244,7 @@ YOLO bridge = 신뢰 불가 Discord 텍스트를 호스트 무제한 권한 모 ### progress heartbeat 계약 (B-fix — 침묵 갭 방지) 긴 턴이 침묵 갭이면 안 됨. 2계층 병행 필수(defense-in-depth — 하나는 잊힐 수 있는 모델 지시, 하나는 못 잊는 코드): -- **모델 계층**: 봇 `SOUL.md`/`AGENTS.md` 의 hard rule — 완료 / 부분산출 / +- **모델 계층**: 봇 정본 `AGENTS.md` 의 hard rule — 완료 / 부분산출 / 권한대기 / 외부핸드오프 / blocked 10~15분+ 시 ping 없이 새 메시지 능동 보고. - **bridge 계층**: `examples/bot.py` 가 기존 bridge Discord reply/send 경로로 턴 진행 중 heartbeat 를 보내고, 턴 종료 즉시 취소. 간격은 diff --git a/examples/AGENTS.md b/examples/AGENTS.md index ca5ea67..1293421 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,11 +1,42 @@ +--- +name: +description: +version: 2.0.0 +triggers: [""] +--- + # Example AGENTS.md — source-backed plain reporting pattern -> **Loading note (convention)**: a bot-WD `AGENTS.md` like this one is the *end* of the Codex 3-chain (`~/.codex/AGENTS.md` → git-root `AGENTS.md` → this file). Shared operational rules arrive via the earlier links — state that in your WD file's opening lines like this, and never paste the shared rules body in (a copy forks the SSOT). See `docs/rules-system.md` §Root-instruction unification, caveat 3. +> **Loading note (Codex convention)**: Codex merges instruction files from the +> project/Git root down to the current working directory. Each directory +> contributes one selected instruction file; this bot-WD `AGENTS.md` is the +> canonical local file. Do not add `SOUL.md` beside it and assume both will +> load. Shared operational rules arrive through the root-to-WD chain — state +> that in your WD opening lines and never paste the shared rules body in (a +> copy forks the SSOT). See `docs/rules-system.md` §Root-instruction +> unification, caveat 3. > > **Rules seed**: this working directory also carries `rules-seed.md` (copied once at guided init, same never-overwrite contract as this file) — DM reply-thread echo policy and wiki save policy. Read it alongside this file. Use this pattern when a Codex bot writes user-facing reports. + +## SOUL v2 capsule — fill before launch + +- **Identity and voice:** `` is `` for ``; use + ``. End completed user-facing reports with `— `. +- **Specialist domain and tool chain:** Own ``; prefer `` → + `` for ``. +- **Local gates and boundaries:** Before ``, verify + ``; never ``. +- **Delegation defaults:** When the situation matches, read + rules/orchestration.md §11 and apply R1–R5 (document distribution, Workflow, + specialist routing, orchestrator capacity, and repository-first research). + + +Replace every placeholder before launch. Keep this capsule specific to this +bot; shared operating rules belong in `rules/`, not here. + ## Discord Reply Rule (CRITICAL — read first) **Your assistant text does NOT reach the human.** This session is a headless diff --git a/examples/bot.py b/examples/bot.py index a07a0bf..c40d4eb 100644 --- a/examples/bot.py +++ b/examples/bot.py @@ -64,7 +64,8 @@ sys.exit(1) # The bridge's working dir is the BOT working dir (holds .codex-thread-id, -# dedup.json, bot-info.json, and the auto-loaded SOUL.md/AGENTS.md) — NOT this +# dedup.json, bot-info.json, and the canonical auto-loaded AGENTS.md; a SOUL.md +# may remain only as the legacy fallback during migration) — NOT this # examples/ folder. launch.sh starts the infra with cwd=$BOT_WD and exports # BOT_WD; honor that. Never use __file__'s parent (that would scope state to # ThisCodex/examples/ and mis-wire persona auto-load). @@ -1268,7 +1269,7 @@ async def on_ready(): async def _heartbeat(channel, started: float, stop: asyncio.Event) -> None: """B-fix: while a turn runs, emit a progress note every interval so a long or blocked task is never a silent gap (defense-in-depth alongside the - model's proactive-report rule in SOUL.md/AGENTS.md).""" + model's proactive-report rule in canonical AGENTS.md).""" if HEARTBEAT_INTERVAL_SEC <= 0 or channel is None: return while not stop.is_set(): diff --git a/examples/rules-seed.md b/examples/rules-seed.md index 5e428d2..15a9889 100644 --- a/examples/rules-seed.md +++ b/examples/rules-seed.md @@ -10,6 +10,12 @@ > line (see `infra-launch.sh`) — that warning never auto-merges or > auto-updates your copy. Apply changes only by an explicit operator or bot > command. +> +> **SOUL v2 note:** `AGENTS.md` is the one canonical Codex instruction file in +> this directory. This seed is a companion rule file, not a second instruction +> document; do not create a same-directory `SOUL.md` and expect both it and +> `AGENTS.md` to load. Existing copies remain untouched until an explicit +> preview/apply migration is chosen. ## Rule 1 — No DM (1:1) reply-thread echo diff --git a/install/thiscodex.install.json b/install/thiscodex.install.json index f2d000d..c171c81 100644 --- a/install/thiscodex.install.json +++ b/install/thiscodex.install.json @@ -68,7 +68,7 @@ "order": 40, "when": "always", "action": "prompt", - "reason": "ThisCodex needs one stable bot working directory for AGENTS.md, SOUL.md, thread id, and runner files.", + "reason": "ThisCodex needs one stable bot working directory for the canonical AGENTS.md (including its SOUL v2 capsule), thread id, and runner files.", "safety": "user-confirmed-path", "verify": { "type": "path-writable", "state_key": "confirmed_bot_wd" }, "on_fail": { "next_command": "thiscodex init --apply --bot-wd " } diff --git a/plugin.lock.json b/plugin.lock.json index ba6d9b0..e269bc6 100644 --- a/plugin.lock.json +++ b/plugin.lock.json @@ -36,7 +36,7 @@ "type": "repo", "path": "skills/prompt/SKILL.md" }, - "integrity": "sha256-487c905e8683f23e98e6b24504ed5235385e6d7512d0a826dfaec9d35e2eb439" + "integrity": "sha256-d7f965f50fdddb8d01dcfa8bee1ac8b2c9071aa9e5cef5fdac51348274d1cb28" }, { "id": "thiscodex", @@ -45,7 +45,7 @@ "type": "repo", "path": "skills/thiscodex/SKILL.md" }, - "integrity": "sha256-82e7d04e07a261e8a3cea6ceccb8bb22ea5e2cdf10963808e8e86bf7774cc684" + "integrity": "sha256-86939dae2b18b1cf20db3fdefadb7e298eb09fca5dfb2ada9903854d3536186b" } ] } diff --git a/rules/INDEX.md b/rules/INDEX.md index bdf10ea..a5e1f5a 100644 --- a/rules/INDEX.md +++ b/rules/INDEX.md @@ -30,7 +30,7 @@ | Porting a tool/skill / deploying / pushing / adding an MCP | [porting-infra.md](porting-infra.md) | Check upstream before hand-rolling. Secret-scan before any push. MCP health-check | | Writing a persona response (voice) | [voice.md](voice.md) | Keep the persona's voice + completion signature every response; no echo drift | | Generating/editing/labeling images or dispatching image work | [image-ops.md](image-ops.md) | Choose edit vs generate vs overlay. Real people/brands/products are reference-first, no-imagination | -| Delegating to / waiting on a bot · asserting a bot's identity/health · multi-agent coordination · handing someone a duration estimate | [orchestration.md](orchestration.md) | Verify bot identity (chain-load guard), don't assume; teammate idle = drive not wait; meeting = adopt domain frames, no solo lock | +| Delegating to / waiting on a bot · asserting a bot's identity/health · multi-agent coordination · handing someone a duration estimate · starting work that needs delegation, a Workflow, or bot-document research | [orchestration.md](orchestration.md) | Verify bot identity (chain-load guard), don't assume; teammate idle = drive not wait; meeting = adopt domain frames, no solo lock; apply §11 R1–R5 delegation defaults | | Coordinating an active meeting / dispatch verification / Stop while meeting active / writing any timestamp | [meeting-protocol.md](meeting-protocol.md) | SessionStart injects active meeting + rules INDEX; verify dispatch execution; KST progress rows; Stop rereads active meeting | | Searching the project's own KB · **before planning a task · on failure** · **before starting a task** | [knowledge-retrieval.md](knowledge-retrieval.md) | Active recall (§2): search before planning + on failure (decide, don't apologize) + propagate after. Pick tool by query shape: structured KB CLI → graph/semantic search skill → plain grep. Don't raw-grep our own docs first. **Active exploration before a task (§3): tool sweep · owner-repo sweep · expert agent + peer-agent memory (read-only)**. **Two-store routing (§1.5): conversation-memory vs curated KB — a no-hit in one store is not absence in the other.** | | About to query KB search · a search just failed/came back empty | [search-usage.md](search-usage.md) | 3–7 word keyword queries; trust tool defaults (no per-query weight overrides). Never re-throw the same failed query — rephrase once → CLI full-text 2nd tier → plain search. Outside latency envelope = report, don't retry | diff --git a/rules/orchestration.md b/rules/orchestration.md index aaa7389..fe3022a 100644 --- a/rules/orchestration.md +++ b/rules/orchestration.md @@ -1,7 +1,8 @@ # Rule: multi-agent orchestration Trigger: delegating to / waiting on another bot, convening a meeting, -asserting another bot's identity or health, or coordinating multiple agents. +asserting another bot's identity or health, coordinating multiple agents, or +starting work that may require delegation, a Workflow, or repository research. ## 1. Bot identity = verify, never assume - A bot's identity SoT is the persona injected at session start for **its own** @@ -148,6 +149,54 @@ in-flight tracks; your maintainer-confirmation channel. - Usage: discover peers fresh each time (names are auto-generated per session — never hard-code addresses); **reply by copying the inbound `from` address verbatim**; fall back to the external channel when the peer isn't listed. Scope test = the live peer listing itself (sessions started before harness support join only after a restart). - Wake-ladder placement (canonical ladder = discord-comms §5 R3, revised there): external-channel re-send → **direct ping in parallel (if peer-listed)** → still silent → bridge classification → maintainer escalation. +## 11. Delegation and parallel-work defaults (R1–R5) + +These are defaults for new work, not permission to bypass an explicit user +instruction, an ownership boundary, or a safer existing process. Record the +applicable declaration before the relevant action in the work item's plan or +progress log; a project may provide a more specific location. + +- **R1 — three or more planned deliverable documents: distribute the work.** + When one request has three or more planned created or changed deliverable + documents, do not edit them serially by yourself. Before the first write, + record the work split and assign independent writable units to workers with + write authority. Meeting ledgers, status files, and incidental notes do not + count as deliverables. If no writable delegation path exists, report that + constraint instead of silently absorbing all documents yourself. +- **R2 — three or more distinct stages: use a Workflow.** A task with at + least three heterogeneous stages (for example collect → transform → verify) + uses a Workflow or equivalent checkpointed plan. Record the stage count and + number of independent fan-out units before starting. Spawn parallel workers + only when there are at least two genuinely independent units; otherwise run + the stages sequentially inside the Workflow. This rule is a procedural gate, + not evidence that every three-stage task should create workers. +- **R3 — specialist domain match: delegate by default.** Use your roster or + ownership map as the source of truth. Priority is: an explicit user assignee; + one matching specialist; an orchestrator for multiple matches; then local + execution or a blocked report when there is no match, the specialist is + unavailable, or delivery cannot be reached. A routing suggestion is not an + automatic dispatch: check reachability, send a HOW-complete request, and + record the request identifier, target, method, and receipt before dispatch. +- **R4 — orchestrator capacity: hand off owned work early.** If your + orchestrator has three or more active owned tracks, hand off at least one of + its own eligible tracks to the configured implementation lead. Keep a + handoff ledger with task ID, current owner, delegated-to target, dispatch + identifier, and accepted/active state so a wake-up does not re-delegate the + same task. Do not take over someone else's work merely to satisfy this rule. + A project without a durable task ledger treats this as a procedural + self-check until it establishes one. +- **R5 — research the repository before creating bot documentation.** For + ThisCode/ThisCodex-style bot-document creation, inspect existing templates, + rules, and setup paths before drafting. For a knowledge-base-backed project, + also search its existing documentation before writing and link/register the + finished artifact through that project's normal knowledge workflow. This is + a procedural gate: it prevents duplicate conventions; it does not authorize + broad or speculative searching. + +▶ Fill in: your writable delegation layers; Workflow/checkpoint tool; roster +or ownership-map path and reachability check; orchestrator/implementation lead +and handoff-ledger path; and repository/knowledge-base search command. + ## Deferred instructions — re-check the ledger at fire time A scheduled instruction (cron, reminder, queued dispatch) is a **snapshot of the world at registration time** — and it is the *whole instruction* that goes stale, not just one value. Before executing any deferred instruction, re-read the tail of the relevant ledger/SoT: if a correction, cancellation, or resolution has been recorded since, **hold and report instead of executing** (a hold is a normal branch, not a failure). Stamp every scheduled instruction with its as-of time in the payload. Case-based (2026-07-25: an errand scheduled at 13:04 was resolved at 15:00 and cancelled in the ledger at 18:05, yet fired and executed verbatim at 19:00, emitting a stale credential request — a counterpart's refusal gate contained the damage); re-judge per situation; the maintainer's call wins. diff --git a/scripts/launch.sh b/scripts/launch.sh index 064e8fa..5c878cf 100755 --- a/scripts/launch.sh +++ b/scripts/launch.sh @@ -22,7 +22,7 @@ # BOT_WD=/path/to/bot SESSION=mybot LAUNCH_CMD="./infra-launch.sh" ./scripts/launch.sh # # Env: -# BOT_WD (required) bot working dir (holds SOUL.md/AGENTS.md, .codex-thread-id) +# BOT_WD (required) bot working dir (holds canonical AGENTS.md, .codex-thread-id) # SESSION (default: thiscodex) tmux session name # WS (default: ws://127.0.0.1:4222) app-server listen URL # TID_FILE (default: $BOT_WD/.codex-thread-id) bridge writes the thread id here diff --git a/scripts/lib/apply.mjs b/scripts/lib/apply.mjs index e4bee99..000acea 100644 --- a/scripts/lib/apply.mjs +++ b/scripts/lib/apply.mjs @@ -42,7 +42,10 @@ export function applySkillInstall(repoRoot, home, layer = 'user') { export function patchCodexConfig(home, dryRun = true, opts = {}) { const dir = join(home, '.codex'); const cfg = join(dir, 'config.toml'); - const lines = ['project_doc_fallback_filenames = ["SOUL.md", "AGENTS.md"]']; + // AGENTS.md is Codex's canonical instruction filename and is discovered + // before fallbacks. Keep SOUL.md only as a legacy fallback for working + // directories that have not migrated to the canonical AGENTS.md surface. + const lines = ['project_doc_fallback_filenames = ["SOUL.md"]']; if (opts.yoloCeiling) { lines.push('sandbox_mode = "danger-full-access"'); lines.push('approval_policy = "never"'); diff --git a/scripts/lib/materialize.mjs b/scripts/lib/materialize.mjs index 62401fa..1df2d4f 100644 --- a/scripts/lib/materialize.mjs +++ b/scripts/lib/materialize.mjs @@ -1,4 +1,5 @@ -import { chmodSync, copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { chmodSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { join } from 'node:path'; import { rejectProvisionalPath } from './state.mjs'; import { progressConfigForState, progressEnvForState } from './progress.mjs'; @@ -15,6 +16,152 @@ function runtimeName(state) { return value; } +const IDENTITY_SOURCE_REL = ['examples', 'AGENTS.md']; +const IDENTITY_TARGET = 'AGENTS.md'; +const IDENTITY_LEGACY_TARGET = 'SOUL.md'; +const IDENTITY_CANDIDATE = 'AGENTS.md.v2'; +const IDENTITY_BACKUP = 'AGENTS.md.thiscodex.pre-v2.bak'; +const IDENTITY_LEGACY_BACKUP = 'SOUL.md.thiscodex.pre-v2.bak'; +const IDENTITY_RECEIPT = 'AGENTS.md.thiscodex.migration.json'; + +function sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +function copyNew(source, target) { + copyFileSync(source, target, constants.COPYFILE_EXCL); +} + +// Codex discovers only one project instruction file at a directory level. +// AGENTS.md is therefore the single v2 identity source for a newly created +// ThisCodex bot. An existing AGENTS.md is never replaced: the explicit +// migration path stages a separately named candidate for operator review. +export function planIdentityMigration({ repo, bot }) { + const source = join(repo, ...IDENTITY_SOURCE_REL); + const target = join(bot, IDENTITY_TARGET); + const legacy = join(bot, IDENTITY_LEGACY_TARGET); + const candidate = join(bot, IDENTITY_CANDIDATE); + const receipt = join(bot, IDENTITY_RECEIPT); + const targetExists = existsSync(target); + const legacyExists = existsSync(legacy); + const current = targetExists ? target : legacyExists ? legacy : null; + const currentKind = targetExists ? 'agents' : legacyExists ? 'legacy_soul' : 'none'; + const backup = join(bot, currentKind === 'legacy_soul' ? IDENTITY_LEGACY_BACKUP : IDENTITY_BACKUP); + return { + source, + target, + legacy, + current, + current_kind: currentKind, + candidate, + backup, + receipt, + source_exists: existsSync(source), + target_exists: targetExists, + legacy_exists: legacyExists, + current_exists: current !== null, + candidate_exists: existsSync(candidate), + backup_exists: existsSync(backup), + receipt_exists: existsSync(receipt), + }; +} + +export function migrateIdentity({ repo, bot, apply = false, rollback = false }) { + const cleanRepo = rejectProvisionalPath(repo); + const cleanBot = rejectProvisionalPath(bot); + const plan = planIdentityMigration({ + repo: cleanRepo, + bot: cleanBot, + }); + if (rollback) { + if (!plan.receipt_exists) { + return { ok: false, code: 'identity_rollback_receipt_missing', action: 'none', ...plan }; + } + let receipt; + try { + receipt = JSON.parse(readFileSync(plan.receipt, 'utf8')); + } catch { + return { ok: false, code: 'identity_rollback_receipt_invalid', action: 'none', ...plan }; + } + if (!plan.candidate_exists || receipt.candidate !== plan.candidate || receipt.candidate_sha256 !== sha256(plan.candidate)) { + return { ok: false, code: 'identity_rollback_refused_candidate_changed', action: 'none', ...plan }; + } + if (!apply) { + return { ok: true, mode: 'preview', action: 'would_remove_unchanged_candidate', ...plan }; + } + // The original AGENTS.md was never touched. Removing only the receipt-bound + // candidate is the rollback; preserve the original backup for audit/retry. + unlinkSync(plan.candidate); + unlinkSync(plan.receipt); + return { + ok: true, + mode: 'apply', + action: 'removed_unchanged_candidate', + ...planIdentityMigration({ repo: cleanRepo, bot: cleanBot }), + }; + } + + if (!plan.source_exists) { + return { ok: false, code: 'identity_source_missing', action: 'none', ...plan }; + } + + if (!plan.current_exists) { + if (!apply) return { ok: true, mode: 'preview', action: 'would_seed_canonical_identity', ...plan }; + mkdirSync(cleanBot, { recursive: true }); + const current = planIdentityMigration({ repo: cleanRepo, bot: cleanBot }); + if (current.current_exists) return { ok: false, code: 'identity_target_appeared', action: 'none', ...current }; + copyNew(plan.source, plan.target); + return { ok: true, mode: 'apply', action: 'seeded_canonical_identity', ...planIdentityMigration({ repo: cleanRepo, bot: cleanBot }) }; + } + + if (plan.candidate_exists) { + return { ok: false, code: 'identity_candidate_exists_no_overwrite', action: 'none', ...plan }; + } + if (plan.receipt_exists) { + return { ok: false, code: 'identity_receipt_exists_no_overwrite', action: 'none', ...plan }; + } + if (plan.backup_exists && sha256(plan.backup) !== sha256(plan.current)) { + return { ok: false, code: 'identity_backup_conflicts_no_overwrite', action: 'none', ...plan }; + } + if (!apply) { + return { + ok: true, + mode: 'preview', + action: plan.backup_exists + ? 'would_stage_v2_candidate_with_existing_backup' + : plan.current_kind === 'legacy_soul' + ? 'would_backup_legacy_soul_then_stage_v2_candidate' + : 'would_backup_then_stage_v2_candidate', + ...plan, + }; + } + + mkdirSync(cleanBot, { recursive: true }); + // Re-plan at the write boundary: none of the three files may be overwritten. + const current = planIdentityMigration({ repo: cleanRepo, bot: cleanBot }); + if (current.current !== plan.current || current.candidate_exists || current.receipt_exists + || (current.backup_exists && sha256(current.backup) !== sha256(current.current))) { + return { ok: false, code: 'identity_migration_target_changed_no_overwrite', action: 'none', ...current }; + } + if (!current.backup_exists) copyNew(current.current, current.backup); + try { + copyNew(current.source, current.candidate); + writeFileSync(current.receipt, JSON.stringify({ + schema_version: 1, + source: current.source, + source_sha256: sha256(current.source), + original: current.current, + backup: current.backup, + backup_sha256: sha256(current.backup), + candidate: current.candidate, + candidate_sha256: sha256(current.candidate), + }, null, 2) + '\n', { flag: 'wx' }); + } catch (error) { + return { ok: false, code: 'identity_migration_write_failed', action: 'backup_preserved_candidate_may_require_review', error: error.message, ...planIdentityMigration({ repo: cleanRepo, bot: cleanBot }) }; + } + return { ok: true, mode: 'apply', action: 'backed_up_then_staged_v2_candidate_no_overwrite', ...planIdentityMigration({ repo: cleanRepo, bot: cleanBot }) }; +} + export function planBotFiles(state) { // A missing field otherwise surfaces later as mkdirSync(undefined) // TypeError / a literal "undefined" in generated shell — name the actual @@ -218,14 +365,15 @@ export function materializeBotFiles(state) { chmodSync(plan.run, 0o755); chmodSync(plan.infra, 0o755); // 막힘 20 (2026-08-09 WSL, root-cause confirmed by timing: 0 reply-tool calls - // before an instruction file existed, 7 after): config points codex at - // SOUL.md/AGENTS.md as project docs, but nothing ever CREATED one — so the - // model never learns that its text does not reach Discord. Materialize the - // reference AGENTS.md into the bot WD; never overwrite an existing one. + // before an instruction file existed, 7 after): materialize the canonical + // v2 identity AGENTS.md into the bot WD. Codex discovers at most one + // project-document filename in this directory, so this is intentionally not + // paired with a same-level SOUL.md. Preserve the copy-once contract. const agentsDoc = join(plan.bot, 'AGENTS.md'); + const legacySoulDoc = join(plan.bot, 'SOUL.md'); const agentsSrc = join(plan.repo, 'examples', 'AGENTS.md'); - if (!existsSync(agentsDoc) && existsSync(agentsSrc)) { - copyFileSync(agentsSrc, agentsDoc); + if (!existsSync(agentsDoc) && !existsSync(legacySoulDoc) && existsSync(agentsSrc)) { + copyNew(agentsSrc, agentsDoc); } // B3 (2026-08-09/10 night batch, PRD success criteria 3-5): copy-once rules // seed — DM reply-thread echo policy + wiki save policy. Same never-overwrite diff --git a/scripts/lib/prompts.mjs b/scripts/lib/prompts.mjs index dfdd857..6193f18 100644 --- a/scripts/lib/prompts.mjs +++ b/scripts/lib/prompts.mjs @@ -24,7 +24,7 @@ const PROMPTS = { defaultKey: 'workspace_root', }, confirm_bot_wd: { - question: 'Confirm the bot working directory for AGENTS.md, SOUL.md, thread id, and runner files', + question: 'Confirm the bot working directory for the canonical AGENTS.md (including its SOUL v2 capsule), thread id, and runner files', defaultKey: 'cwd', }, confirm_runtime_name: { diff --git a/skills/prompt/SKILL.md b/skills/prompt/SKILL.md index 7fdfc85..0d73ea3 100644 --- a/skills/prompt/SKILL.md +++ b/skills/prompt/SKILL.md @@ -36,7 +36,7 @@ See `references/` for detailed procedures only when the matched prompt task asks | Worked examples (Claude / GPT / image) | [`references/examples/`](references/examples/) | | Build a GPTs instruction | [`references/instructions/GPTs-Prompt-Generator.md`](references/instructions/GPTs-Prompt-Generator.md) | | Build a Gems instruction | [`references/instructions/Gems-Prompt-Generator.md`](references/instructions/Gems-Prompt-Generator.md) | -| Build bot meta files (soul.md / CLAUDE.md / AGENTS.md) | [`references/instructions/Bot-Persona-Generator.md`](references/instructions/Bot-Persona-Generator.md) | +| Build Codex bot meta file (canonical AGENTS.md + SOUL v2 capsule) | [`references/instructions/Bot-Persona-Generator.md`](references/instructions/Bot-Persona-Generator.md) | Procedure: @@ -56,7 +56,7 @@ This is a single-invocation skill — call `/prompt` with your task description | Review a prompt for robustness | `/prompt review: ` — checks completeness, phrasing, and edge cases. | | Build GPT system instructions | `/prompt gpt: ` — generates structured GPT instructions using the GPT-Prompt-Generator framework. | | Build Gem system instructions | `/prompt gem: ` — generates structured Gem instructions using the Gems-Prompt-Generator framework. | -| Build bot persona/meta files | `/prompt bot-persona: ` — generates soul.md + CLAUDE.md/AGENTS.md using the Bot-Persona-Generator framework (used by guided onboarding). | +| Build bot persona/meta file | `/prompt bot-persona: ` — generates one canonical Codex `AGENTS.md` with its SOUL v2 capsule using the Bot-Persona-Generator framework (used by guided onboarding). | | Image generation prompting | `/prompt image: ` — applies the 5-stage image-generation framework (subject, style, composition, quality, mood). | | Fact-check or research prompting | `/prompt research: ` — wraps the IFCN base template for verifiable claims and source-grounded research. | | Study the framework | Load `references/prompt-engineering-guide.md` — deep techniques, mental models, and worked examples. | diff --git a/skills/prompt/references/instructions/Bot-Persona-Generator.md b/skills/prompt/references/instructions/Bot-Persona-Generator.md index c694b1d..c0d6252 100644 --- a/skills/prompt/references/instructions/Bot-Persona-Generator.md +++ b/skills/prompt/references/instructions/Bot-Persona-Generator.md @@ -1,22 +1,23 @@ -# Bot-Persona-Generator — soul.md / CLAUDE.md / AGENTS.md 생성 프레임워크 +# Bot-Persona-Generator — canonical AGENTS.md + SOUL v2 생성 프레임워크 > `/prompt bot-persona: <역할> <범위> ` 로 호출되거나, > SETUP-CONFIG-GUIDE §0 가이드 온보딩이 봇 메타 파일을 만들 때 이 레퍼런스를 따른다. -> 이 두 파일은 곧 **봇의 상시 프롬프트**다 — ad-hoc 작성 금지, 본 프레임워크로 생성. +> 산출물은 Codex 봇의 **하나의 상시 프롬프트**다 — ad-hoc 작성 금지, 본 +> 프레임워크로 생성. -## 산출물 (봇 1개당 2~3개 파일) +## 산출물 (Codex 봇 1개당 정본 1파일) | 파일 | 역할 | 위치 | |---|---|---| -| `soul.md` | 페르소나·말투·강제 규율 (SessionStart 주입) | `~/.claude/channels/discord-/soul.md` | -| `CLAUDE.md` | 봇 WD job sheet — 역할·경로·rules/INDEX.md 포인터 (Claude Code) | 봇 WD 루트 | -| `AGENTS.md` | CLAUDE.md 의 Codex 등가물 (Codex 봇이면 CLAUDE.md 대신) | 봇 WD 루트 | +| `AGENTS.md` | Codex 봇 WD 정본 — frontmatter, SOUL v2 capsule, 역할·경계, `rules/INDEX.md` 포인터 | 봇 WD 루트 | +| `SOUL.md` | **legacy fallback 또는 외부 bridge-capsule source만**. AGENTS.md가 있는 같은 WD에 생성·배치 금지 | manual migration 전 legacy BOT_WD, 그 밖에는 bridge state dir 등 Codex WD 밖 | ## 생성 절차 1. **입력 수집 (부족하면 한 번에 하나씩 질문)** - **USER-PROFILE 선독**: `~/.claude/USER-PROFILE.md` 가 있으면 먼저 Read — - roles/pain_points 에서 역할·템플릿을 *유도해 제안*하고, soul.md 말미에 + roles/pain_points 에서 역할·템플릿을 *유도해 제안*하고, AGENTS.md SOUL v2 + capsule 말미에 `## 사용자 컨텍스트` 절(응답 톤 보정용 요약 — 보조층)을 삽입한다. 없으면 온보딩 인터뷰(SETUP-CONFIG-GUIDE §0 step 3 앵커 6종)로 먼저 생성. **프로필의 본 정착지는 구조다**: `north_star` → AGENTS.md/meta 최상단 목표 @@ -30,18 +31,27 @@ - 모델 id: 사용자의 하네스가 실제 노출하는 id 만 (지어내기 금지) - Discord: 채널/스레드 범위, 봇 user_id, 회의 스레드 규칙 적용 여부 - vault 범위: 검색/쓰기 허용 경로, Obsidian 유무 -2. **soul.md 생성** — 가장 가까운 `templates/soul-*.md` (research-bot / - writing-bot / schedule-bot / general-assistant / custom) 를 베이스로: - - frontmatter: `name` / `description` / `version` / `triggers` 필수 - - "강제 페르소나 규율" 섹션: 매 응답 자가점검 항목 (시그니처 포함) ≥ 3개 +2. **canonical AGENTS.md 생성** — 가장 가까운 `templates/soul-*.md` + (research-bot / writing-bot / schedule-bot / general-assistant / custom)를 + capsule 초안으로만 사용하고, 결과를 하나의 AGENTS.md에 넣는다: + - 파일 byte 0에서 frontmatter 시작: `---`, `name` / `description` / + `version: 2.0.0` / `triggers`, 닫는 `---`, 그 다음 H1 + - `` / `` 사이에 + 정체성·말투·시그니처, 전문영역+확정 도구 체인, 고유 게이트·경계, + `rules/orchestration.md` §11 R1–R5 포인터를 둔다 - 비개발자 사용자가 주인이면 "쉬운 말 우선" 규율 명시 - 개인 식별 정보(타인 실명·채널 id)는 placeholder 로 -3. **CLAUDE.md / AGENTS.md 생성** — 얇게 (job sheet): - - 봇 정체성 1줄 + WD 경로 + soul.md 로딩 순서 - - 운영 규칙은 인라인 금지 → `rules/INDEX.md` 포인터만 - - Codex 봇이면 AGENTS.md (`project_doc_fallback_filenames` 가 읽음) -4. **검증 후 완료 선언** - - soul.md frontmatter 파싱 OK · 시그니처 라인 존재 + - 운영 규칙 본문은 인라인 금지 → `rules/INDEX.md` 포인터만 + - 같은 WD에 `SOUL.md`를 함께 만들지 않는다. legacy fallback은 + AGENTS.md가 없을 때만, bridge capsule은 Codex WD 밖의 외부 source일 때만 + - `SOUL.md`만 있는 WD는 existing install: `thiscodex migrate-identity + --preview`로 먼저 확인하고, `--apply`는 `AGENTS.md.v2`와 receipt를 + stage하며 legacy SOUL.md를 active로 보존한다. backup은 + `SOUL.md.thiscodex.pre-v2.bak`; cutover는 수동이고 rollback은 unchanged + candidate+receipt만 제거한다 +3. **검증 후 완료 선언** + - AGENTS.md frontmatter가 byte 0에서 파싱되고, capsule marker 2개와 + 시그니처 라인이 존재 - meta 파일이 rules 본문을 인라인하지 않고 INDEX 만 가리킴 - `<@본인 봇 ID>` 같은 placeholder 가 실값으로 치환됐는지 (Discord Developer Portal → Bot → Copy User ID 가 그 값) @@ -51,4 +61,5 @@ - 규칙 전체를 CLAUDE.md 에 욱여넣기 (context bloat → recall 저하) - 다른 사람·다른 봇의 고유명사/채널 id 를 템플릿에서 그대로 복사 - 존재하지 않는 모델 id·스킬 이름 발명 -- soul.md 와 CLAUDE.md 에 같은 내용 중복 (drift 원인) +- AGENTS.md와 같은 WD의 SOUL.md에 같은 내용을 중복 (Codex discovery·drift + 원인) diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 8066470..00c88eb 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -75,18 +75,28 @@ and the PreToolUse hook reject a handoff without a current-turn receipt. --reason-code host_permission_required ``` 2. Confirm repo root, workspace, BOT_WD, and Discord state dir before generating - aliases. Then create `SOUL.md` (persona) + `AGENTS.md` (rules pointer) in - BOT_WD — **REQUIRED, never skip silently** (2026-08-12 regression fix: real - setups were observed ending without them). `AGENTS.md` carries the static - reply rule and points only at `rules/INDEX.md` (see `/thiscodex` §3). - Explicit user decline only, recorded in the completion contract below. + aliases. If both `AGENTS.md` and `SOUL.md` are absent, create one canonical + `AGENTS.md` in BOT_WD — **REQUIRED, never skip silently** (2026-08-12 + regression fix: real setups were observed ending without it). It contains + the SOUL v2 capsule, static reply rule, and `rules/INDEX.md` pointer (see + `/thiscodex` §3). Do not create a same-level + `SOUL.md`: it is only a legacy fallback when `AGENTS.md` is absent, or an + external bridge-capsule source. Explicit user decline only, recorded in the + completion contract below. + A `SOUL.md`-only BOT_WD is an existing install, not an empty directory: use + `thiscodex migrate-identity --preview` before any change. Its `--apply` + stages `AGENTS.md.v2`, leaves legacy SOUL.md active, and saves + `SOUL.md.thiscodex.pre-v2.bak` plus a receipt. The operator performs manual + cutover; `--rollback --apply` removes only an unchanged candidate and its + receipt. During the persona interview, ask for the **exact operator address** (for - example, `고객님`) and write it as a hard rule in `SOUL.md`: **Always address - the operator as ``; do not reconfirm it or offer alternatives - unless the operator explicitly changes it.** A descriptive sentence such as - “the bot calls the operator X” is not strong enough. Before declaring setup - complete, run the behavior probe `너는 나를 뭐라고 불러야 해?`; the bot must - answer with the exact address without a follow-up question. + example, `고객님`) and write it as a hard rule in the `AGENTS.md` SOUL v2 + capsule: **Always address the operator as ``; do not reconfirm + it or offer alternatives unless the operator explicitly changes + it.** A descriptive sentence such as “the bot calls the operator X” is not + strong enough. Before declaring setup complete, run the behavior probe + `너는 나를 뭐라고 불러야 해?`; the bot must answer with the exact address + without a follow-up question. 3. Use tmux for the daemon/TUI split. Do not use cmux for this flow. 4. Present safe mode first. Offer YOLO only as an explicit opt-in using the bridge contract and operator-controlled sentinel. Before asking the operator @@ -173,8 +183,8 @@ never be empty or omitted — a silent skip reads as an incomplete setup: ```yaml setup_completion: aliases: generated | declined() # step 8 — REQUIRED - wd_docs: created | declined() # step 2 — SOUL.md + AGENTS.md in BOT_WD (REQUIRED) - operator_address: # step 2 — exact SOUL rule + behavior probe + wd_docs: created | declined() # step 2 — canonical AGENTS.md with SOUL v2 capsule (REQUIRED) + operator_address: # step 2 — exact capsule rule + behavior probe hooks_trusted: true # step 6 — trusted_hash present dispatch_gate: probe 6/6 | skipped() # step 6 — multi-bot gate probe doctor: pass # step 9 diff --git a/skills/thiscodex/SKILL.md b/skills/thiscodex/SKILL.md index b85ecc2..57b1fb5 100644 --- a/skills/thiscodex/SKILL.md +++ b/skills/thiscodex/SKILL.md @@ -76,9 +76,10 @@ Deep reference lives in the repo (load only when a step needs it — progressive ## Setup procedure (in order) 1. **Prereqs**: `codex` CLI, `tmux`, Python 3 + `websockets`, the Claude Code Discord plugin (reused as a codex MCP server), `gh auth login`. Platforms: macOS / Linux / WSL2. -2. **`~/.codex/config.toml`** — auto-load persona/rules + wire the Discord MCP: +2. **`~/.codex/config.toml`** — use the canonical AGENTS instruction document + and wire the Discord MCP: ```toml - project_doc_fallback_filenames = ["SOUL.md", "AGENTS.md"] + project_doc_fallback_filenames = ["SOUL.md"] project_doc_max_bytes = 65536 [mcp_servers.discord] command = "bun" @@ -86,7 +87,17 @@ Deep reference lives in the repo (load only when a step needs it — progressive [mcp_servers.discord.env] DISCORD_STATE_DIR = "~/.claude/channels/discord-" ``` -3. **Bot working directory**: put `SOUL.md` (persona) + `AGENTS.md` (rules) there. `AGENTS.md` carries the static Discord-reply rule and points at `rules/INDEX.md` only (not inline rules). +3. **Bot working directory**: if both `AGENTS.md` and `SOUL.md` are absent, + create one canonical `AGENTS.md`, with its SOUL v2 capsule, static + Discord-reply rule, and `rules/INDEX.md` pointer (not inline rules). Do not + put a same-directory `SOUL.md` beside it and expect + both files to load. A `SOUL.md` is only a legacy fallback when `AGENTS.md` + is absent, or an external bridge-capsule source — not a second Codex project + document. A `SOUL.md`-only BOT_WD is an existing install: run + `thiscodex migrate-identity --preview`, then `--apply` only after review. + It stages `AGENTS.md.v2`, preserves the active SOUL.md, saves + `SOUL.md.thiscodex.pre-v2.bak` plus a receipt, and leaves cutover to the + operator. Rollback removes only an unchanged candidate and receipt. 4. **Bridge + launcher**: use the shipped **`../../scripts/launch.sh`** (hardened 2-window tmux launcher: `infra` runs app-server + `bot.py` bridge; `codex` resumes the SAME bridge thread). It enforces the invariants — command-as-window-process (never `send-keys` into a bare shell), and the codex window **always `codex resume --remote`, never a bare fresh `codex --remote`**. Set `BOT_WD`, `SESSION`, `LAUNCH_CMD`. Do not hand-roll this (see Troubleshooting for why). 5. **YOLO**: `bot.py` sends `sandbox:"danger-full-access"` + `approvalPolicy:"never"` on **both** `thread/start` AND `thread/resume` (resume silently degrades otherwise — the nastiest bug). 6. **Skills/rules portability**: your own skills → `~/.agents/skills//SKILL.md`. Plugin/framework skills (e.g. superpowers) → install via the framework's own codex path, never hand-symlink. diff --git a/tests/init/absorption.test.mjs b/tests/init/absorption.test.mjs index d9db471..e7f0ce8 100644 --- a/tests/init/absorption.test.mjs +++ b/tests/init/absorption.test.mjs @@ -106,3 +106,24 @@ test('docs require superpowers availability or a clear next command', () => { assert.match(docs, /superpowers.*Next command|superpowers.*next command|superpowers.*다음 명령/i); assert.match(docs, /\/using-superpowers/i); }); + +test('active setup docs expose one canonical AGENTS file and only a legacy SOUL fallback', () => { + const docs = [ + readFileSync('README.md', 'utf8'), + readFileSync('README.ko.md', 'utf8'), + readFileSync('docs/SETUP-CONFIG-GUIDE.md', 'utf8'), + readFileSync('docs/SETUP.md', 'utf8'), + readFileSync('docs/skill-portability.md', 'utf8'), + readFileSync('docs/guided-installer-requirements.md', 'utf8'), + readFileSync('docs/guided-installer-design.md', 'utf8'), + readFileSync('docs/yolo-bridge-contract.md', 'utf8'), + readFileSync('examples/bot.py', 'utf8'), + ].join('\n'); + assert.match(docs, /one canonical `AGENTS\.md`|정본 `AGENTS\.md` 하나/i); + assert.match(docs, /legacy fallback|구형 fallback/i); + assert.doesNotMatch(docs, /Put `SOUL\.md`[^\n]+and `AGENTS\.md`[^\n]+auto-loaded/i); + assert.doesNotMatch(docs, /`SOUL\.md`\(페르소나\)와 `AGENTS\.md`[^\n]+자동 로드/i); + assert.doesNotMatch(docs, /project_doc_fallback_filenames\s*=\s*\["SOUL\.md"\s*,\s*"AGENTS\.md"\]/i); + assert.doesNotMatch(docs, /drafts? `AGENTS\.md`, `soul\.md`/i); + assert.doesNotMatch(docs, /SOUL\.md\s*\/\s*AGENTS\.md|SOUL\+AGENTS/i); +}); diff --git a/tests/init/apply.test.mjs b/tests/init/apply.test.mjs index 0e47a3d..e929005 100644 --- a/tests/init/apply.test.mjs +++ b/tests/init/apply.test.mjs @@ -37,7 +37,9 @@ test('patchCodexConfig backs up and adds project_doc_fallback_filenames', () => const result = patchCodexConfig(home, false); assert.equal(result.changed, true); assert.ok(existsSync(`${cfg}.thiscodex.bak`)); - assert.match(readFileSync(cfg, 'utf8'), /project_doc_fallback_filenames/); + const text = readFileSync(cfg, 'utf8'); + assert.match(text, /project_doc_fallback_filenames = \["SOUL\.md"\]/); + assert.doesNotMatch(text, /project_doc_fallback_filenames = \[[^\n]*AGENTS\.md/); rmSync(home, { recursive: true, force: true }); }); diff --git a/tests/init/cli.test.mjs b/tests/init/cli.test.mjs index a228958..3e7c3cf 100644 --- a/tests/init/cli.test.mjs +++ b/tests/init/cli.test.mjs @@ -55,6 +55,89 @@ test('--tone=dev switches output', () => { rmSync(dir, { recursive: true, force: true }); }); +test('migrate-identity is preview-first, never overwrites an existing AGENTS.md, and has a receipt-bound rollback', () => { + const repo = mkdtempSync(join(tmpdir(), 'tcx-identity-repo-')); + const bot = mkdtempSync(join(tmpdir(), 'tcx-identity-bot-')); + mkdirSync(join(repo, 'examples'), { recursive: true }); + const v2 = '---\nversion: 2.0.0\n---\n## SOUL v2 capsule\n'; + writeFileSync(join(repo, 'examples', 'AGENTS.md'), v2); + writeFileSync(join(bot, 'AGENTS.md'), '# operator identity\n'); + const args = ['migrate-identity', '--repo-root', repo, '--bot-wd', bot]; + const env = { ...process.env, ...TEST_CLI_ENV, THISCODEX_REPO_ROOT: repo }; + try { + const preview = spawnSync(process.execPath, [BIN, ...args, '--preview'], { encoding: 'utf8', env }); + assert.equal(preview.status, 0, preview.stdout + preview.stderr); + const previewResult = JSON.parse(preview.stdout); + assert.equal(previewResult.mode, 'preview'); + assert.equal(previewResult.action, 'would_backup_then_stage_v2_candidate'); + assert.equal(existsSync(join(bot, 'AGENTS.md.v2')), false); + assert.equal(existsSync(join(bot, 'AGENTS.md.thiscodex.pre-v2.bak')), false); + + const applied = spawnSync(process.execPath, [BIN, ...args, '--apply'], { encoding: 'utf8', env }); + assert.equal(applied.status, 0, applied.stdout + applied.stderr); + const appliedResult = JSON.parse(applied.stdout); + assert.equal(appliedResult.action, 'backed_up_then_staged_v2_candidate_no_overwrite'); + assert.equal(readFileSync(join(bot, 'AGENTS.md'), 'utf8'), '# operator identity\n'); + assert.equal(readFileSync(join(bot, 'AGENTS.md.v2'), 'utf8'), v2); + assert.equal(readFileSync(join(bot, 'AGENTS.md.thiscodex.pre-v2.bak'), 'utf8'), '# operator identity\n'); + + const refused = spawnSync(process.execPath, [BIN, ...args, '--apply'], { encoding: 'utf8', env }); + assert.equal(refused.status, 2, refused.stdout + refused.stderr); + assert.equal(JSON.parse(refused.stdout).code, 'identity_candidate_exists_no_overwrite'); + + const rollbackPreview = spawnSync(process.execPath, [BIN, ...args, '--rollback'], { encoding: 'utf8', env }); + assert.equal(rollbackPreview.status, 0, rollbackPreview.stdout + rollbackPreview.stderr); + assert.equal(JSON.parse(rollbackPreview.stdout).action, 'would_remove_unchanged_candidate'); + + const rollback = spawnSync(process.execPath, [BIN, ...args, '--rollback', '--apply'], { encoding: 'utf8', env }); + assert.equal(rollback.status, 0, rollback.stdout + rollback.stderr); + const rollbackResult = JSON.parse(rollback.stdout); + assert.equal(rollbackResult.action, 'removed_unchanged_candidate'); + assert.equal(rollbackResult.candidate_exists, false); + assert.equal(rollbackResult.receipt_exists, false); + assert.equal(rollbackResult.backup_exists, true); + assert.equal(existsSync(join(bot, 'AGENTS.md.v2')), false); + assert.equal(readFileSync(join(bot, 'AGENTS.md'), 'utf8'), '# operator identity\n'); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(bot, { recursive: true, force: true }); + } +}); + +test('migrate-identity CLI stages beside a legacy SOUL-only install without changing its active file', () => { + const repo = mkdtempSync(join(tmpdir(), 'tcx-identity-repo-')); + const bot = mkdtempSync(join(tmpdir(), 'tcx-identity-bot-')); + mkdirSync(join(repo, 'examples'), { recursive: true }); + const v2 = '---\nversion: 2.0.0\n---\n## SOUL v2 capsule\n'; + const legacy = '# legacy operator persona\n'; + writeFileSync(join(repo, 'examples', 'AGENTS.md'), v2); + writeFileSync(join(bot, 'SOUL.md'), legacy); + const args = ['migrate-identity', '--repo-root', repo, '--bot-wd', bot]; + const env = { ...process.env, ...TEST_CLI_ENV, THISCODEX_REPO_ROOT: repo }; + try { + const preview = spawnSync(process.execPath, [BIN, ...args, '--preview'], { encoding: 'utf8', env }); + assert.equal(preview.status, 0, preview.stdout + preview.stderr); + assert.equal(JSON.parse(preview.stdout).action, 'would_backup_legacy_soul_then_stage_v2_candidate'); + assert.equal(existsSync(join(bot, 'AGENTS.md')), false); + + const applied = spawnSync(process.execPath, [BIN, ...args, '--apply'], { encoding: 'utf8', env }); + assert.equal(applied.status, 0, applied.stdout + applied.stderr); + assert.match(JSON.parse(applied.stdout).next_command, /against SOUL\.md/); + assert.equal(readFileSync(join(bot, 'SOUL.md'), 'utf8'), legacy); + assert.equal(readFileSync(join(bot, 'SOUL.md.thiscodex.pre-v2.bak'), 'utf8'), legacy); + assert.equal(readFileSync(join(bot, 'AGENTS.md.v2'), 'utf8'), v2); + assert.equal(existsSync(join(bot, 'AGENTS.md')), false); + + const rollback = spawnSync(process.execPath, [BIN, ...args, '--rollback', '--apply'], { encoding: 'utf8', env }); + assert.equal(rollback.status, 0, rollback.stdout + rollback.stderr); + assert.equal(existsSync(join(bot, 'AGENTS.md.v2')), false); + assert.equal(readFileSync(join(bot, 'SOUL.md'), 'utf8'), legacy); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(bot, { recursive: true, force: true }); + } +}); + test('automation gate consumes bridge-observed evidence and emits a current-turn receipt', () => { const home = mkdtempSync(join(tmpdir(), 'tcx-home-')); const audit = join(home, 'attempts.jsonl'); diff --git a/tests/init/materialize.test.mjs b/tests/init/materialize.test.mjs index eec2868..a740902 100644 --- a/tests/init/materialize.test.mjs +++ b/tests/init/materialize.test.mjs @@ -4,7 +4,10 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, existsSync, wr import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; -import { planBotFiles, materializeBotFiles, aliasBlock, runScript, infraScript } from '../../scripts/lib/materialize.mjs'; +import { fileURLToPath } from 'node:url'; +import { planBotFiles, materializeBotFiles, aliasBlock, runScript, infraScript, migrateIdentity } from '../../scripts/lib/materialize.mjs'; + +const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)); // Runs the generated `export =...` line from a materialize.mjs output // through REAL bash and reports what actually landed. Bracketed by MARKER @@ -135,15 +138,16 @@ test('materializeBotFiles seeds access.json.example into the state dir and never rmSync(state, { recursive: true, force: true }); }); -test('materializeBotFiles seeds BOT_WD/AGENTS.md from examples and never overwrites', () => { +test('materializeBotFiles seeds the canonical soul-v2 AGENTS.md from examples and never overwrites', () => { const root = mkdtempSync(join(tmpdir(), 'tcx-repo-')); const bot = mkdtempSync(join(tmpdir(), 'tcx-bot-')); const state = mkdtempSync(join(tmpdir(), 'tcx-state-')); mkdirSync(join(root, 'examples'), { recursive: true }); - writeFileSync(join(root, 'examples', 'AGENTS.md'), '# ref\nDiscord Reply Rule\n'); + const v2 = '---\nversion: 2.0.0\n---\n## SOUL v2 capsule\nDiscord Reply Rule\n'; + writeFileSync(join(root, 'examples', 'AGENTS.md'), v2); const s = { confirmed_repo_root: root, confirmed_bot_wd: bot, confirmed_state_dir: state }; materializeBotFiles(s); - assert.match(readFileSync(join(bot, 'AGENTS.md'), 'utf8'), /Discord Reply Rule/); + assert.equal(readFileSync(join(bot, 'AGENTS.md'), 'utf8'), v2); writeFileSync(join(bot, 'AGENTS.md'), '# customized by operator\n'); materializeBotFiles(s); assert.match(readFileSync(join(bot, 'AGENTS.md'), 'utf8'), /customized by operator/); @@ -152,6 +156,127 @@ test('materializeBotFiles seeds BOT_WD/AGENTS.md from examples and never overwri rmSync(state, { recursive: true, force: true }); }); +test('production soul-v2 template has every required schema slot and new install lands it as the one canonical instruction file', () => { + const bot = mkdtempSync(join(tmpdir(), 'tcx-bot-')); + const state = mkdtempSync(join(tmpdir(), 'tcx-state-')); + const expected = readFileSync(join(REPO_ROOT, 'examples', 'AGENTS.md'), 'utf8'); + try { + materializeBotFiles({ confirmed_repo_root: REPO_ROOT, confirmed_bot_wd: bot, confirmed_state_dir: state }); + assert.match(expected, /^name: $/m); + assert.match(expected, /^description: $/m); + assert.match(expected, /^version: 2\.0\.0$/m); + assert.match(expected, /^triggers: \[""\]$/m); + assert.match(expected, //); + assert.match(expected, /^## SOUL v2 capsule/m); + assert.match(expected, //); + assert.match(expected, /— /); + assert.match(expected, /Specialist domain and tool chain/); + assert.match(expected, /Local gates and boundaries/); + assert.match(expected, /rules\/orchestration\.md §11 and apply R1–R5/); + assert.equal(readFileSync(join(bot, 'AGENTS.md'), 'utf8'), expected); + assert.equal(existsSync(join(bot, 'SOUL.md')), false); + } finally { + rmSync(bot, { recursive: true, force: true }); + rmSync(state, { recursive: true, force: true }); + } +}); + +test('guided materialization never supersedes a legacy SOUL-only install', () => { + const root = mkdtempSync(join(tmpdir(), 'tcx-repo-')); + const bot = mkdtempSync(join(tmpdir(), 'tcx-bot-')); + const state = mkdtempSync(join(tmpdir(), 'tcx-state-')); + mkdirSync(join(root, 'examples'), { recursive: true }); + writeFileSync(join(root, 'examples', 'AGENTS.md'), '---\nversion: 2.0.0\n---\n'); + writeFileSync(join(bot, 'SOUL.md'), '# legacy operator persona\n'); + try { + materializeBotFiles({ confirmed_repo_root: root, confirmed_bot_wd: bot, confirmed_state_dir: state }); + assert.equal(readFileSync(join(bot, 'SOUL.md'), 'utf8'), '# legacy operator persona\n'); + assert.equal(existsSync(join(bot, 'AGENTS.md')), false); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(bot, { recursive: true, force: true }); + rmSync(state, { recursive: true, force: true }); + } +}); + +test('migrateIdentity previews, stages a v2 candidate without overwrite, and rolls it back only while unchanged', () => { + const root = mkdtempSync(join(tmpdir(), 'tcx-repo-')); + const bot = mkdtempSync(join(tmpdir(), 'tcx-bot-')); + mkdirSync(join(root, 'examples'), { recursive: true }); + const v2 = '---\nversion: 2.0.0\n---\n## SOUL v2 capsule\n'; + writeFileSync(join(root, 'examples', 'AGENTS.md'), v2); + writeFileSync(join(bot, 'AGENTS.md'), '# operator identity\n'); + + const preview = migrateIdentity({ repo: root, bot }); + assert.equal(preview.ok, true); + assert.equal(preview.mode, 'preview'); + assert.equal(preview.action, 'would_backup_then_stage_v2_candidate'); + assert.equal(existsSync(preview.candidate), false); + assert.equal(existsSync(preview.backup), false); + + const applied = migrateIdentity({ repo: root, bot, apply: true }); + assert.equal(applied.ok, true); + assert.equal(applied.action, 'backed_up_then_staged_v2_candidate_no_overwrite'); + assert.equal(readFileSync(join(bot, 'AGENTS.md'), 'utf8'), '# operator identity\n'); + assert.equal(readFileSync(join(bot, 'AGENTS.md.v2'), 'utf8'), v2); + assert.equal(readFileSync(join(bot, 'AGENTS.md.thiscodex.pre-v2.bak'), 'utf8'), '# operator identity\n'); + + const refused = migrateIdentity({ repo: root, bot, apply: true }); + assert.equal(refused.ok, false); + assert.equal(refused.code, 'identity_candidate_exists_no_overwrite'); + + writeFileSync(join(bot, 'AGENTS.md.v2'), '# edited after migration\n'); + const changedCandidate = migrateIdentity({ repo: root, bot, rollback: true, apply: true }); + assert.equal(changedCandidate.ok, false); + assert.equal(changedCandidate.code, 'identity_rollback_refused_candidate_changed'); + writeFileSync(join(bot, 'AGENTS.md.v2'), v2); + const rollbackPreview = migrateIdentity({ repo: root, bot, rollback: true }); + assert.equal(rollbackPreview.action, 'would_remove_unchanged_candidate'); + const rolledBack = migrateIdentity({ repo: root, bot, rollback: true, apply: true }); + assert.equal(rolledBack.action, 'removed_unchanged_candidate'); + assert.equal(rolledBack.candidate_exists, false); + assert.equal(rolledBack.receipt_exists, false); + assert.equal(rolledBack.backup_exists, true); + assert.equal(existsSync(join(bot, 'AGENTS.md.v2')), false); + assert.equal(readFileSync(join(bot, 'AGENTS.md'), 'utf8'), '# operator identity\n'); + assert.equal(readFileSync(join(bot, 'AGENTS.md.thiscodex.pre-v2.bak'), 'utf8'), '# operator identity\n'); + rmSync(root, { recursive: true, force: true }); + rmSync(bot, { recursive: true, force: true }); +}); + +test('migrateIdentity treats a legacy SOUL-only bot as existing and preserves its active file', () => { + const root = mkdtempSync(join(tmpdir(), 'tcx-repo-')); + const bot = mkdtempSync(join(tmpdir(), 'tcx-bot-')); + mkdirSync(join(root, 'examples'), { recursive: true }); + const v2 = '---\nversion: 2.0.0\n---\n## SOUL v2 capsule\n'; + const legacy = '# legacy operator persona\n'; + writeFileSync(join(root, 'examples', 'AGENTS.md'), v2); + writeFileSync(join(bot, 'SOUL.md'), legacy); + try { + const preview = migrateIdentity({ repo: root, bot }); + assert.equal(preview.action, 'would_backup_legacy_soul_then_stage_v2_candidate'); + assert.equal(preview.current_kind, 'legacy_soul'); + assert.equal(existsSync(join(bot, 'AGENTS.md')), false); + + const applied = migrateIdentity({ repo: root, bot, apply: true }); + assert.equal(applied.action, 'backed_up_then_staged_v2_candidate_no_overwrite'); + assert.equal(readFileSync(join(bot, 'SOUL.md'), 'utf8'), legacy); + assert.equal(readFileSync(join(bot, 'SOUL.md.thiscodex.pre-v2.bak'), 'utf8'), legacy); + assert.equal(readFileSync(join(bot, 'AGENTS.md.v2'), 'utf8'), v2); + assert.equal(existsSync(join(bot, 'AGENTS.md')), false); + + const rolledBack = migrateIdentity({ repo: root, bot, rollback: true, apply: true }); + assert.equal(rolledBack.action, 'removed_unchanged_candidate'); + assert.equal(rolledBack.current_kind, 'legacy_soul'); + assert.equal(readFileSync(join(bot, 'SOUL.md'), 'utf8'), legacy); + assert.equal(existsSync(join(bot, 'AGENTS.md')), false); + assert.equal(existsSync(join(bot, 'AGENTS.md.v2')), false); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(bot, { recursive: true, force: true }); + } +}); + test('aliasBlock launches the materialized runner from confirmed BOT_WD', () => { const text = aliasBlock({ confirmed_repo_root: '/repo/ThisCodex', confirmed_bot_wd: '/bots/sonseokhee', session: 'thiscodex' }); assert.match(text, /\/bots\/sonseokhee\/run\.sh.*start/); diff --git a/tests/init/orchestration-rules.test.mjs b/tests/init/orchestration-rules.test.mjs new file mode 100644 index 0000000..e752e4f --- /dev/null +++ b/tests/init/orchestration-rules.test.mjs @@ -0,0 +1,21 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const index = readFileSync('rules/INDEX.md', 'utf8'); +const orchestration = readFileSync('rules/orchestration.md', 'utf8'); + +test('INDEX routes delegation, Workflow, and bot-document research to orchestration §11', () => { + const row = index.split('\n').find(line => line.includes('[orchestration.md](orchestration.md)')) || ''; + assert.match(row, /delegat|위임|Delegating/i); + assert.match(row, /Workflow/); + assert.match(row, /bot-document research|repository research/i); + assert.match(row, /R1–R5/); +}); + +test('orchestration §11 exposes all five delegation defaults', () => { + assert.match(orchestration, /^## 11\. Delegation and parallel-work defaults \(R1–R5\)$/m); + for (const rule of ['R1', 'R2', 'R3', 'R4', 'R5']) { + assert.match(orchestration, new RegExp(`\\*\\*${rule} —`)); + } +}); From 3d2c6e488ae14b006390054c284f8167752140ca Mon Sep 17 00:00:00 2001 From: treylom Date: Sun, 16 Aug 2026 19:07:56 +0900 Subject: [PATCH 2/5] docs: align Korean identity migration safety --- docs/SETUP-CONFIG-GUIDE.md | 12 +++++++----- tests/init/absorption.test.mjs | 9 +++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/SETUP-CONFIG-GUIDE.md b/docs/SETUP-CONFIG-GUIDE.md index cd4f59a..6def342 100644 --- a/docs/SETUP-CONFIG-GUIDE.md +++ b/docs/SETUP-CONFIG-GUIDE.md @@ -601,11 +601,13 @@ SOUL v2는 별도 `SOUL.md` 파일명이 아니라 정본 AGENTS.md 안 내용 `` / `` 사이에 유지한다. **신규 설치**는 `AGENTS.md`와 `SOUL.md`가 **모두 없을 때만** `thiscodex init ---apply`가 정본 AGENTS.md를 copy-once로 만든다. `SOUL.md`만 있으면 기존 설치다: -`thiscodex migrate-identity --preview`로 먼저 확인하고, `--apply`는 legacy -SOUL.md를 active로 보존한 채 `AGENTS.md.v2`+receipt와 -`SOUL.md.thiscodex.pre-v2.bak`만 만든다. 검토 뒤 manual cutover하며, -`--rollback --apply`는 unchanged candidate+receipt만 제거한다. +--apply`가 정본 AGENTS.md를 copy-once로 만든다. `AGENTS.md`나 `SOUL.md` 중 +하나라도 있으면 기존 설치다. `thiscodex migrate-identity --preview`로 먼저 +확인하고, `--apply`는 active 파일을 보존한 채 `AGENTS.md.v2`+receipt를 만든다. +legacy SOUL.md는 `SOUL.md.thiscodex.pre-v2.bak`에, 기존 AGENTS.md는 +`AGENTS.md.thiscodex.pre-v2.bak`에 백업한다. 검토 뒤 manual cutover하며, +`--rollback --apply`는 unchanged candidate+receipt만 제거한다. receipt 기록 뒤 +candidate가 바뀌었으면 rollback을 거부해 수동 편집을 덮어쓰지 않는다. ### §3 rules/ 정본 = [rules-system.md](rules-system.md)(본 레포, 중복 금지). "Applying to a diff --git a/tests/init/absorption.test.mjs b/tests/init/absorption.test.mjs index e7f0ce8..c5cf311 100644 --- a/tests/init/absorption.test.mjs +++ b/tests/init/absorption.test.mjs @@ -127,3 +127,12 @@ test('active setup docs expose one canonical AGENTS file and only a legacy SOUL assert.doesNotMatch(docs, /drafts? `AGENTS\.md`, `soul\.md`/i); assert.doesNotMatch(docs, /SOUL\.md\s*\/\s*AGENTS\.md|SOUL\+AGENTS/i); }); + +test('Korean setup guidance preserves both existing-install backups and changed-candidate rollback refusal', () => { + const guide = readFileSync('docs/SETUP-CONFIG-GUIDE.md', 'utf8'); + const korean = guide.slice(guide.indexOf('### §2 SOUL v2 capsule')); + assert.match(korean, /SOUL\.md\.thiscodex\.pre-v2\.bak/); + assert.match(korean, /AGENTS\.md\.thiscodex\.pre-v2\.bak/); + assert.match(korean, /candidate가 바뀌었으면 rollback을 거부/); + assert.match(korean, /수동 편집을 덮어쓰지 않는다/); +}); From 7322b30f8988c058cd76e2745f56b740c47186be Mon Sep 17 00:00:00 2001 From: treylom Date: Sun, 16 Aug 2026 19:18:22 +0900 Subject: [PATCH 3/5] test: make Windows smoke portable --- hooks/automation-handoff-gate.py | 4 +++- tests/init/automation-evidence.test.mjs | 8 +++++++- tests/init/hard-hooks.test.mjs | 11 ++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/hooks/automation-handoff-gate.py b/hooks/automation-handoff-gate.py index b799dc8..4756423 100644 --- a/hooks/automation-handoff-gate.py +++ b/hooks/automation-handoff-gate.py @@ -40,11 +40,13 @@ def _payload() -> dict: def _decision(allow: bool, reason: str = "") -> None: value = "allow" if allow else "deny" + # Emit ASCII-only JSON so Windows runners with a legacy console encoding + # cannot truncate Korean denial reasons before the hook host parses them. print(json.dumps({"hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": value, **({"permissionDecisionReason": reason} if reason else {}), - }}, ensure_ascii=False)) + }}, ensure_ascii=True)) def _state_dir() -> Path: diff --git a/tests/init/automation-evidence.test.mjs b/tests/init/automation-evidence.test.mjs index 333a2a9..e273c3e 100644 --- a/tests/init/automation-evidence.test.mjs +++ b/tests/init/automation-evidence.test.mjs @@ -59,7 +59,13 @@ test('current-turn evidence is consumed once and binds a flow to one provider', dir, policy, gatePolicy, flow: 'slack-auth', provider: 'playwright', status: 'failed', now: NOW, }); assert.equal(second.code, 'active_attempt_required'); - assert.equal(statSync(paths.consumed).mode & 0o777, 0o600); + // Windows does not expose meaningful POSIX permission bits through stat; + // the chmod contract is asserted on platforms that implement those bits. + if (process.platform !== 'win32') { + assert.equal(statSync(paths.consumed).mode & 0o777, 0o600); + } else { + assert.equal(existsSync(paths.consumed), true); + } rmSync(dir, { recursive: true, force: true }); }); diff --git a/tests/init/hard-hooks.test.mjs b/tests/init/hard-hooks.test.mjs index 2b4ba9e..c3f0c2f 100644 --- a/tests/init/hard-hooks.test.mjs +++ b/tests/init/hard-hooks.test.mjs @@ -75,11 +75,16 @@ test('automatic handoff hook flow state denies unmarked prose and atomically con tool_name: 'mcp__discord__reply', tool_input: { text: ' 직접 로그인해 주세요.' }, }; - const run = value => spawnSync('python3', ['hooks/automation-handoff-gate.py'], { + const run = (value, extraEnv = {}) => spawnSync('python3', ['hooks/automation-handoff-gate.py'], { cwd: process.cwd(), encoding: 'utf8', input: JSON.stringify(value), - env: { ...process.env, THISCODEX_AUTOMATION_EVIDENCE_DIR: dir, THISCODEX_AUTOMATION_MODE: 'auto' }, + env: { ...process.env, THISCODEX_AUTOMATION_EVIDENCE_DIR: dir, THISCODEX_AUTOMATION_MODE: 'auto', ...extraEnv }, }); - const denied = run(input); + // Reproduce the legacy Windows console encoding that used to truncate the + // Korean denial reason and leave the hook host with incomplete JSON. + const denied = run( + { ...input, tool_input: { text: ' Please log in to continue.' } }, + { PYTHONIOENCODING: 'cp1252' }, + ); assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny'); for (const text of [ 'Please enter your GitHub login credentials to continue.', From e937601310952c841728d7787aa8a8f711bf3465 Mon Sep 17 00:00:00 2001 From: treylom Date: Sun, 16 Aug 2026 19:26:43 +0900 Subject: [PATCH 4/5] fix: decode hook protocol as UTF-8 --- hooks/automation-handoff-gate.py | 5 ++++- tests/init/hard-hooks.test.mjs | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/hooks/automation-handoff-gate.py b/hooks/automation-handoff-gate.py index 4756423..c494e4c 100644 --- a/hooks/automation-handoff-gate.py +++ b/hooks/automation-handoff-gate.py @@ -33,7 +33,10 @@ def _payload() -> dict: try: - return json.load(sys.stdin) + # Hook hosts send JSON as UTF-8 bytes. Decode the byte stream + # explicitly so a legacy Windows console code page cannot mojibake + # Korean handoff text before policy matching. + return json.loads(sys.stdin.buffer.read().decode("utf-8")) except Exception: return {} diff --git a/tests/init/hard-hooks.test.mjs b/tests/init/hard-hooks.test.mjs index c3f0c2f..2d29a89 100644 --- a/tests/init/hard-hooks.test.mjs +++ b/tests/init/hard-hooks.test.mjs @@ -80,12 +80,14 @@ test('automatic handoff hook flow state denies unmarked prose and atomically con env: { ...process.env, THISCODEX_AUTOMATION_EVIDENCE_DIR: dir, THISCODEX_AUTOMATION_MODE: 'auto', ...extraEnv }, }); // Reproduce the legacy Windows console encoding that used to truncate the - // Korean denial reason and leave the hook host with incomplete JSON. + // Korean input and denial reason around the hook's UTF-8 JSON protocol. const denied = run( - { ...input, tool_input: { text: ' Please log in to continue.' } }, + input, { PYTHONIOENCODING: 'cp1252' }, ); - assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny'); + const deniedPayload = JSON.parse(denied.stdout).hookSpecificOutput; + assert.equal(deniedPayload.permissionDecision, 'deny'); + assert.match(deniedPayload.permissionDecisionReason, /자동 모드/); for (const text of [ 'Please enter your GitHub login credentials to continue.', 'GitHub 계정으로 로그인해 주세요.', From a37e1a46a5cd74c915cd2b7152438d29e65f9286 Mon Sep 17 00:00:00 2001 From: treylom Date: Sun, 16 Aug 2026 19:33:55 +0900 Subject: [PATCH 5/5] fix: keep active flow start idempotent --- scripts/lib/automation-evidence.mjs | 12 +++++++++--- tests/init/automation-evidence.test.mjs | 13 +++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/scripts/lib/automation-evidence.mjs b/scripts/lib/automation-evidence.mjs index 3397d10..3953a44 100644 --- a/scripts/lib/automation-evidence.mjs +++ b/scripts/lib/automation-evidence.mjs @@ -152,16 +152,22 @@ export function startAutomationFlow({ dir, policy, flow, now = new Date() }) { if (prior && (prior.thread_id !== active.thread_id || prior.flow !== flow)) { return { ok: false, code: 'different_flow_already_active' }; } + if (prior) { + // This command is idempotent for the already-active matching flow. A + // rewrite adds no state (FLOW_TTL uses started_at) and makes concurrent + // Windows callers contend on rename-over-existing semantics. + return { ok: true, code: 'flow_already_active', flow: prior }; + } const row = { schema_version: 1, thread_id: active.thread_id, flow, - provider: prior?.provider || '', - started_at: prior?.started_at || now.toISOString(), + provider: '', + started_at: now.toISOString(), updated_at: now.toISOString(), }; writePrivateJson(automationEvidencePaths(dir).activeFlow, row); - return { ok: true, code: prior ? 'flow_already_active' : 'flow_started', flow: row }; + return { ok: true, code: 'flow_started', flow: row }; } export function clearAutomationFlow({ dir, flow, threadId = '', now = new Date() }) { diff --git a/tests/init/automation-evidence.test.mjs b/tests/init/automation-evidence.test.mjs index e273c3e..47d84de 100644 --- a/tests/init/automation-evidence.test.mjs +++ b/tests/init/automation-evidence.test.mjs @@ -150,6 +150,19 @@ test('a terminal success clears one active flow and a new flow may bind another rmSync(dir, { recursive: true, force: true }); }); +test('starting the matching active flow is read-only and idempotent', () => { + const policy = loadAutomationPolicy(); + const { dir, paths } = fixture(); + const before = readFileSync(paths.activeFlow, 'utf8'); + const result = startAutomationFlow({ + dir, policy, flow: 'slack-auth', now: NOW, + }); + assert.equal(result.code, 'flow_already_active'); + assert.deepEqual(result.flow, JSON.parse(before)); + assert.equal(readFileSync(paths.activeFlow, 'utf8'), before); + rmSync(dir, { recursive: true, force: true }); +}); + test('model-blind clipboard evidence is auxiliary to the bound Discord browser flow', () => { const policy = loadAutomationPolicy(); const gatePolicy = policy.gates.get('token_direct_entry');