Back to skills
SkillHub ClubAnalyze Data & AIFull StackFrontendData / AI

jeo

JEO — Integrated AI agent orchestration skill. Plan with ralph+plannotator, execute with team/bmad, verify browser behavior with agent-browser, apply UI feedback with agentation(annotate), auto-cleanup worktrees after completion. Supports Claude, Codex, Gemini CLI, and OpenCode. Install: ralph, omc, omx, ohmg, bmad, plannotator, agent-browser, agentation.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
68
Hot score
92
Updated
March 20, 2026
Overall rating
C2.5
Composite score
2.5
Best-practice grade
F39.6

Install command

npx @skill-hub/cli install supercent-io-skills-template-jeo

Repository

supercent-io/skills-template

Skill path: .agent-skills/jeo

JEO — Integrated AI agent orchestration skill. Plan with ralph+plannotator, execute with team/bmad, verify browser behavior with agent-browser, apply UI feedback with agentation(annotate), auto-cleanup worktrees after completion. Supports Claude, Codex, Gemini CLI, and OpenCode. Install: ralph, omc, omx, ohmg, bmad, plannotator, agent-browser, agentation.

Open repository

Best for

Primary workflow: Analyze Data & AI.

Technical facets: Full Stack, Frontend, Data / AI.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: supercent-io.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install jeo into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/supercent-io/skills-template before adding jeo to shared team environments
  • Use jeo for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: jeo
description: "JEO — Integrated AI agent orchestration skill. Plan with ralph+plannotator, execute with team/bmad, verify browser behavior with agent-browser, apply UI feedback with agentation(annotate), auto-cleanup worktrees after completion. Supports Claude, Codex, Gemini CLI, and OpenCode. Install: ralph, omc, omx, ohmg, bmad, plannotator, agent-browser, agentation."
compatibility: "Requires git, node>=18, bash. Optional: bun, docker."
allowed-tools: Read Write Bash Grep Glob Task
metadata:
  tags: jeo, orchestration, ralph, plannotator, agentation, annotate, agentui, UI-review, team, bmad, omc, omx, ohmg, agent-browser, multi-agent, workflow, worktree-cleanup, browser-verification, ui-feedback
  platforms: Claude, Codex, Gemini, OpenCode
  keyword: jeo
  version: 1.3.1
  source: supercent-io/skills-template
---


# JEO — Integrated Agent Orchestration

> Keyword: `jeo` · `annotate` · `UI-review` · `agentui (deprecated)` | Platforms: Claude Code · Codex CLI · Gemini CLI · OpenCode
>
> A unified skill providing fully automated orchestration flow:
> Plan (ralph+plannotator) → Execute (team/bmad) → UI Feedback (agentation/annotate) → Cleanup (worktree cleanup)

## Control Layers

JEO uses one cross-platform abstraction for orchestration:

- `settings`: platform/runtime configuration such as Claude hooks, Codex `config.toml`, Gemini `settings.json`, MCP registration, and prompt parameters
- `rules`: policy constraints that must hold on every platform
- `hooks`: event callbacks that enforce those rules on each platform

The key JEO rules are:

- do not reopen the PLAN gate when the current plan hash already has a terminal result
- only a revised plan resets `plan_gate_status` to `pending`
- do not process agentation annotations before explicit submit/onSubmit opens the submit gate

The authoritative state is `.omc/state/jeo-state.json`. Hooks may help advance the workflow, but they must obey the state file.

---

## 0. Agent Execution Protocol (follow immediately upon `jeo` keyword detection)

> The following are commands, not descriptions. Execute them in order. Each step only proceeds after the previous one completes.

### STEP 0: State File Bootstrap (required — always first)

```bash
mkdir -p .omc/state .omc/plans .omc/logs
```

If `.omc/state/jeo-state.json` does not exist, create it:

<!-- NOTE: The `worktrees` array was removed from the initial schema as it is not yet implemented.
     Add it back when multi-worktree parallel execution tracking is needed.
     worktree-cleanup.sh queries git worktree list directly, so it works without this field. -->
```json
{
  "phase": "plan",
  "task": "<detected task>",
  "plan_approved": false,
  "plan_gate_status": "pending",
  "plan_current_hash": null,
  "last_reviewed_plan_hash": null,
  "last_reviewed_plan_at": null,
  "plan_review_method": null,
  "team_available": null,
  "retry_count": 0,
  "last_error": null,
  "checkpoint": null,
  "created_at": "<ISO 8601>",
  "updated_at": "<ISO 8601>",
  "agentation": {
    "active": false,
    "session_id": null,
    "keyword_used": null,
    "submit_gate_status": "idle",
    "submit_signal": null,
    "submit_received_at": null,
    "submitted_annotation_count": 0,
    "started_at": null,
    "timeout_seconds": 120,
    "annotations": { "total": 0, "acknowledged": 0, "resolved": 0, "dismissed": 0, "pending": 0 },
    "completed_at": null,
    "exit_reason": null
  }
}
```

Notify the user:
> "JEO activated. Phase: PLAN. Add the `annotate` keyword if a UI feedback loop is needed."

---

### STEP 0.1: Error Recovery Protocol (applies to all STEPs)

**Checkpoint recording — immediately after entering each STEP:**
```python
# Execute immediately at the start of each STEP (agent updates jeo-state.json directly)
python3 -c "
import json, datetime, os, subprocess, tempfile
try:
    root = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], stderr=subprocess.DEVNULL).decode().strip()
except:
    root = os.getcwd()
f = os.path.join(root, '.omc/state/jeo-state.json')
if os.path.exists(f):
    import fcntl
    with open(f, 'r+') as fh:
        fcntl.flock(fh, fcntl.LOCK_EX)
        try:
            d = json.load(fh)
            d['checkpoint']='<current_phase>'   # 'plan'|'execute'|'verify'|'cleanup'
            d['updated_at']=datetime.datetime.utcnow().isoformat()+'Z'
            fh.seek(0)
            json.dump(d, fh, ensure_ascii=False, indent=2)
            fh.truncate()
        finally:
            fcntl.flock(fh, fcntl.LOCK_UN)
" 2>/dev/null || true
```

**last_error recording — on pre-flight failure or exception:**
```python
python3 -c "
import json, datetime, os, subprocess, fcntl
try:
    root = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], stderr=subprocess.DEVNULL).decode().strip()
except:
    root = os.getcwd()
f = os.path.join(root, '.omc/state/jeo-state.json')
if os.path.exists(f):
    with open(f, 'r+') as fh:
        fcntl.flock(fh, fcntl.LOCK_EX)
        try:
            d = json.load(fh)
            d['last_error']='<error message>'
            d['retry_count']=d.get('retry_count',0)+1
            d['updated_at']=datetime.datetime.utcnow().isoformat()+'Z'
            fh.seek(0)
            json.dump(d, fh, ensure_ascii=False, indent=2)
            fh.truncate()
        finally:
            fcntl.flock(fh, fcntl.LOCK_UN)
" 2>/dev/null || true
```

**Checkpoint-based resume on restart:**
```python
# If jeo-state.json already exists, resume from checkpoint
python3 -c "
import json, os, subprocess
try:
    root = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], stderr=subprocess.DEVNULL).decode().strip()
except:
    root = os.getcwd()
f = os.path.join(root, '.omc/state/jeo-state.json')
if os.path.exists(f):
    d=json.load(open(f))
    cp=d.get('checkpoint')
    err=d.get('last_error')
    if err: print(f'Previous error: {err}')
    if cp: print(f'Resuming from: {cp}')
" 2>/dev/null || true
```

> **Rule**: Before `exit 1` in pre-flight, always update `last_error` and increment `retry_count`.
> If `retry_count >= 3`, ask the user whether to abort.

---

### STEP 1: PLAN (never skip)

**Pre-flight (required before entering):**
```bash
# Record checkpoint
python3 -c "
import json,datetime,os,subprocess,fcntl,tempfile
try:
    root=subprocess.check_output(['git','rev-parse','--show-toplevel'],stderr=subprocess.DEVNULL).decode().strip()
except:
    root=os.getcwd()
f=os.path.join(root,'.omc/state/jeo-state.json')
if os.path.exists(f):
    with open(f,'r+') as fh:
        fcntl.flock(fh,fcntl.LOCK_EX)
        try:
            d=json.load(fh)
            d.update({'checkpoint':'plan','updated_at':datetime.datetime.utcnow().isoformat()+'Z'})
            fh.seek(0); json.dump(d,fh,ensure_ascii=False,indent=2); fh.truncate()
        finally:
            fcntl.flock(fh,fcntl.LOCK_UN)
" 2>/dev/null || true

# NOTE: Claude Code — skip this entire bash block.
# plannotator is a hook-only binary; calling it directly always fails.
# For Claude Code: call EnterPlanMode → write plan → call ExitPlanMode.
# The ExitPlanMode PermissionRequest hook fires plannotator automatically.
# The following script is for Codex / Gemini / OpenCode only.

# GUARD: enforce no-repeat PLAN review by plan hash.
# same hash + terminal gate status => skip reopening the plan gate
# revised plan.md content => reset gate to pending and review again
PLAN_GATE_STATUS=$(python3 -c "
import json, os
try:
    s = json.load(open('.omc/state/jeo-state.json'))
    print(s.get('plan_gate_status', 'pending'))
except Exception:
    print('pending')
" 2>/dev/null || echo "pending")

HASH_MATCH=$(python3 -c "
import hashlib, json, os
try:
    s = json.load(open('.omc/state/jeo-state.json'))
    if not os.path.exists('plan.md'):
        print('no-match')
    else:
        current_hash = hashlib.sha256(open('plan.md', 'rb').read()).hexdigest()
        print('match' if current_hash == (s.get('last_reviewed_plan_hash') or '') else 'no-match')
except Exception:
    print('no-match')
" 2>/dev/null || echo "no-match")

if [[ "$HASH_MATCH" == "match" && "$PLAN_GATE_STATUS" =~ ^(approved|manual_approved)$ ]]; then
  echo "✅ Current plan hash already approved. Skipping re-review."
  exit 0
fi

# BUG FIX (v1.3.1): feedback_required + same hash must NOT exit 0 (approved).
# The plan has outstanding feedback and must be revised before re-opening plannotator.
# Exiting 0 here would cause JEO to proceed to EXECUTE with an unapproved plan.
if [[ "$HASH_MATCH" == "match" && "$PLAN_GATE_STATUS" == "feedback_required" ]]; then
  echo "❌ Feedback pending for this plan hash — revise plan.md content (new hash required) before re-opening plannotator."
  echo "   Check .omc/state/jeo-state.json → plannotator_feedback for the recorded feedback."
  exit 1
fi

if [[ "$HASH_MATCH" == "match" && "$PLAN_GATE_STATUS" == "infrastructure_blocked" ]]; then
  echo "⚠️ Infrastructure blocked for current plan hash. Use manual TTY gate or set plan_gate_status='manual_approved' in jeo-state.json."
  exit 32
fi

# plannotator is mandatory for the PLAN step (Codex/Gemini/OpenCode).
# If missing, JEO auto-installs it before opening the PLAN gate.
# Resolve the JEO scripts directory (works from any CWD)
_JEO_SCRIPTS=""
for _candidate in \
  "${JEO_SKILL_DIR:-}/scripts" \
  "$HOME/.agent-skills/jeo/scripts" \
  "$HOME/.codex/skills/jeo/scripts" \
  "$(pwd)/.agent-skills/jeo/scripts" \
  "scripts" \
  ; do
  if [ -f "${_candidate}/plannotator-plan-loop.sh" ]; then
    _JEO_SCRIPTS="$_candidate"
    break
  fi
done

if [ -z "$_JEO_SCRIPTS" ]; then
  echo "❌ JEO scripts not found. Re-run: bash setup-codex.sh (or setup-gemini.sh)"
  exit 1
fi

if ! bash "${_JEO_SCRIPTS}/ensure-plannotator.sh"; then
  echo "❌ plannotator auto-install failed: cannot proceed with PLAN step."
  echo "   Retry: bash ${_JEO_SCRIPTS}/../scripts/install.sh --with-plannotator"
  exit 1
fi

# Required PLAN gate (Codex / Gemini / OpenCode):
# - Must wait until approve/feedback is received
# - Auto-restart on session exit (up to 3 times)
# - After 3 exits, ask user whether to end PLAN
FEEDBACK_DIR=$(python3 -c "import hashlib,os; h=hashlib.md5(os.getcwd().encode()).hexdigest()[:8]; d=f'/tmp/jeo-{h}'; os.makedirs(d,exist_ok=True); print(d)" 2>/dev/null || echo '/tmp')
FEEDBACK_FILE="${FEEDBACK_DIR}/plannotator_feedback.txt"
bash "${_JEO_SCRIPTS}/plannotator-plan-loop.sh" plan.md "$FEEDBACK_FILE" 3
PLAN_RC=$?

if [ "$PLAN_RC" -eq 0 ]; then
  echo "✅ Plan approved"
elif [ "$PLAN_RC" -eq 10 ]; then
  echo "❌ Plan not approved — apply feedback, revise plan.md, and retry"
  exit 1
elif [ "$PLAN_RC" -eq 32 ]; then
  echo "⚠️ plannotator UI unavailable (sandbox/CI). Entering Conversation Approval Mode:"
  echo "   1. Output plan.md content to user in conversation"
  echo "   2. Ask user: 'approve' to proceed or provide feedback"
  echo "   3. DO NOT proceed to EXECUTE until user explicitly approves"
  exit 32
elif [ "$PLAN_RC" -eq 30 ] || [ "$PLAN_RC" -eq 31 ]; then
  echo "⛔ PLAN exit decision (or awaiting confirmation). Confirm with user before retrying."
  exit 1
else
  echo "❌ plannotator PLAN gate failed (code=$PLAN_RC)"
  exit 1
fi
mkdir -p .omc/plans .omc/logs
```

1. Write `plan.md` (include goal, steps, risks, and completion criteria)
2. **Invoke plannotator** (per platform):
   - **Claude Code (hook mode — only supported method)**:
     `plannotator` is a hook-only binary. It cannot be called via MCP tool or CLI directly.
     Call `EnterPlanMode`, write the plan content in plan mode, then call `ExitPlanMode`.
     The `ExitPlanMode` PermissionRequest hook fires the JEO Claude plan-gate wrapper automatically.
     That wrapper must skip re-entry when the current plan hash already has a terminal review result.
     Wait for the hook to return before proceeding — approved or feedback will arrive via the hook result.
   - **Codex / Gemini / OpenCode**: run blocking CLI (never use `&`):
     ```bash
     # _JEO_SCRIPTS must be resolved first via the dynamic path discovery block in the pre-flight above
     bash "${_JEO_SCRIPTS}/plannotator-plan-loop.sh" plan.md /tmp/plannotator_feedback.txt 3
     ```
     If `plannotator` is missing, JEO must auto-run `bash "${_JEO_SCRIPTS}/ensure-plannotator.sh"` first and continue only after the CLI is available.
3. Check result:
   - `approved: true` (Claude Code: hook returns approved) → update `jeo-state.json` `phase` to `"execute"` and `plan_approved` to `true` → **enter STEP 2**
   - Not approved (Claude Code: hook returns feedback; others: `exit 10`) → read feedback, revise `plan.md` → repeat step 2
   - Infrastructure blocked (`exit 32`) → localhost bind unavailable (e.g., sandbox/CI). Use manual gate in TTY; confirm with user and retry outside sandbox in non-TTY
   - Session exited 3 times (`exit 30/31`) → ask user whether to end PLAN and decide to abort or resume

**NEVER: enter EXECUTE without `approved: true`. NEVER: run with `&` background.**
**NEVER: reopen the same unchanged plan after `approved` or `manual_approved` — it is already approved.**
**NEVER: treat `feedback_required` as approved — it means the plan was REJECTED and must be revised.**
**When `feedback_required` + same hash → exit 1 (not 0), revise plan content to get a new hash, then re-open plannotator.**

---

### STEP 2: EXECUTE

**Pre-flight (auto-detect team availability):**
```bash
# Record checkpoint
python3 -c "
import json,datetime,os,subprocess,fcntl
try:
    root=subprocess.check_output(['git','rev-parse','--show-toplevel'],stderr=subprocess.DEVNULL).decode().strip()
except:
    root=os.getcwd()
f=os.path.join(root,'.omc/state/jeo-state.json')
if os.path.exists(f):
    with open(f,'r+') as fh:
        fcntl.flock(fh,fcntl.LOCK_EX)
        try:
            d=json.load(fh)
            d.update({'checkpoint':'execute','updated_at':datetime.datetime.utcnow().isoformat()+'Z'})
            fh.seek(0); json.dump(d,fh,ensure_ascii=False,indent=2); fh.truncate()
        finally:
            fcntl.flock(fh,fcntl.LOCK_UN)
" 2>/dev/null || true

TEAM_AVAILABLE=false
if [[ "${CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS:-}" =~ ^(1|true|True|yes|YES)$ ]]; then
  TEAM_AVAILABLE=true
elif python3 -c "
import json, os, sys
try:
    s = json.load(open(os.path.expanduser('~/.claude/settings.json')))
    val = s.get('env', {}).get('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', '')
    sys.exit(0 if str(val) in ('1', 'true', 'True', 'yes') else 1)
except Exception:
    sys.exit(1)
" 2>/dev/null; then
  TEAM_AVAILABLE=true
fi
export TEAM_AVAILABLE_BOOL="$TEAM_AVAILABLE"
python3 -c "
import json,os,subprocess,fcntl
try:
    root=subprocess.check_output(['git','rev-parse','--show-toplevel'],stderr=subprocess.DEVNULL).decode().strip()
except:
    root=os.getcwd()
f=os.path.join(root,'.omc/state/jeo-state.json')
if os.path.exists(f):
    with open(f,'r+') as fh:
        fcntl.flock(fh,fcntl.LOCK_EX)
        try:
            d=json.load(fh)
            d['team_available']=os.environ.get('TEAM_AVAILABLE_BOOL','false').lower()=='true'
            fh.seek(0); json.dump(d,fh,ensure_ascii=False,indent=2); fh.truncate()
        finally:
            fcntl.flock(fh,fcntl.LOCK_UN)
" 2>/dev/null || true
```

1. Update `jeo-state.json` `phase` to `"execute"`
2. **Team available (Claude Code + omc)**:
   ```
   /omc:team 3:executor "<task>"
   ```
3. **Claude Code but no team**:
   ```
   echo "❌ JEO requires Claude Code team mode. Re-run bash scripts/setup-claude.sh, restart Claude Code, and confirm CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1."
   exit 1
   ```
   Never fall back to single-agent execution in Claude Code.
4. **No omc (BMAD fallback — Codex / Gemini / OpenCode only)**:
   ```
   /workflow-init   # Initialize BMAD
   /workflow-status # Check current step
   ```

---

### STEP 3: VERIFY

1. Update `jeo-state.json` `phase` to `"verify"`
2. **Basic verification with agent-browser** (when browser UI is present):
   ```bash
   agent-browser snapshot http://localhost:3000
   ```
3. `annotate` keyword detected → **enter STEP 3.1**
4. Otherwise → **enter STEP 4**

---

### STEP 3.1: VERIFY_UI (only when `annotate` keyword is detected)

1. Pre-flight check (required before entering):
   ```bash
   # Auto-start server, auto-install package, auto-inject component (see §3.3.1 for full script)
   bash scripts/ensure-agentation.sh --project-dir "${PROJECT_DIR:-$PWD}" --endpoint "http://localhost:4747" || true
   # If agentation-mcp server is still not healthy after pre-flight → graceful skip to STEP 4
   if ! curl -sf --connect-timeout 2 http://localhost:4747/health >/dev/null 2>&1; then
     python3 -c "
import json,os,subprocess,fcntl,time
try:
    root=subprocess.check_output(['git','rev-parse','--show-toplevel'],stderr=subprocess.DEVNULL).decode().strip()
except:
    root=os.getcwd()
f=os.path.join(root,'.omc/state/jeo-state.json')
if os.path.exists(f):
    with open(f,'r+') as fh:
        fcntl.flock(fh,fcntl.LOCK_EX)
        try:
            d=json.load(fh)
            d['last_error']='agentation-mcp not running; VERIFY_UI skipped'
            d['updated_at']=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())
            fh.seek(0); json.dump(d,fh,ensure_ascii=False,indent=2); fh.truncate()
        finally:
            fcntl.flock(fh,fcntl.LOCK_UN)
" 2>/dev/null || true
     # Proceed to STEP 4 CLEANUP (no exit 1 — graceful skip)
   fi
   ```
2. Update `jeo-state.json`: `phase = "verify_ui"`, `agentation.active = true`, `agentation.submit_gate_status = "waiting_for_submit"`
3. Wait for explicit human submit:
   - **Claude Code**: wait for `UserPromptSubmit` after the user presses **Send Annotations** / `onSubmit`
   - **Codex / Gemini / OpenCode**: wait until the human confirms submission and the agent emits `ANNOTATE_READY` (or compatibility alias `AGENTUI_READY`)
4. Before that submit signal arrives, do not read `/pending`, do not acknowledge annotations, and do not start the fix loop
5. After submit arrives, switch `agentation.submit_gate_status = "submitted"` and record `submit_signal`, `submit_received_at`, and `submitted_annotation_count`
6. **Claude Code (MCP)**: blocking call to `agentation_watch_annotations` (`batchWindowSeconds:10`, `timeoutSeconds:120`)
7. **Codex / Gemini / OpenCode (HTTP)**: polling loop via `GET http://localhost:4747/pending`
8. Process each annotation: `acknowledge` → navigate code via `elementPath` → apply fix → `resolve`
9. `count=0` or timeout → reset the submit gate or finish the sub-phase → **enter STEP 4**

**NEVER: process draft annotations before submit/onSubmit.**

---

### STEP 4: CLEANUP

**Pre-flight (check before entering):**
```bash
# Record checkpoint
python3 -c "
import json,datetime,os,subprocess,fcntl
try:
    root=subprocess.check_output(['git','rev-parse','--show-toplevel'],stderr=subprocess.DEVNULL).decode().strip()
except:
    root=os.getcwd()
f=os.path.join(root,'.omc/state/jeo-state.json')
if os.path.exists(f):
    with open(f,'r+') as fh:
        fcntl.flock(fh,fcntl.LOCK_EX)
        try:
            d=json.load(fh)
            d.update({'checkpoint':'cleanup','updated_at':datetime.datetime.utcnow().isoformat()+'Z'})
            fh.seek(0); json.dump(d,fh,ensure_ascii=False,indent=2); fh.truncate()
        finally:
            fcntl.flock(fh,fcntl.LOCK_UN)
" 2>/dev/null || true

if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  echo "⚠️ Not a git repository — skipping worktree cleanup"
else
  UNCOMMITTED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
  [[ "$UNCOMMITTED" -gt 0 ]] && echo "⚠️ ${UNCOMMITTED} uncommitted change(s) — recommend commit/stash before cleanup"
fi
```

1. Update `jeo-state.json` `phase` to `"cleanup"`
2. Worktree cleanup:
   ```bash
   bash scripts/worktree-cleanup.sh || git worktree prune
   ```
3. Update `jeo-state.json` `phase` to `"done"`

---

## 1. Quick Start

> **Source of truth**: `https://github.com/supercent-io/skills-template`
> Local paths like `~/.claude/skills/jeo/` are copies installed via `npx skills add`.
> To update to the latest version, reinstall using the command below.

```bash
# Install JEO (npx skills add — recommended)
npx skills add https://github.com/supercent-io/skills-template --skill jeo

# Full install (all AI tools + all components)
bash scripts/install.sh --all

# Check status
bash scripts/check-status.sh

# Individual AI tool setup
bash scripts/setup-claude.sh      # Claude Code plugin + hooks
bash scripts/setup-codex.sh       # Codex CLI developer_instructions
bash scripts/setup-gemini.sh      # Gemini CLI hooks + GEMINI.md
bash scripts/setup-opencode.sh    # OpenCode plugin registration
```

---

## 2. Installed Components

Tools that JEO installs and configures:

| Tool | Description | Install Command |
|------|------|-----------|
| **omc** (oh-my-claudecode) | Claude Code multi-agent orchestration | `/plugin marketplace add https://github.com/Yeachan-Heo/oh-my-claudecode` |
| **omx** | Multi-agent orchestration for OpenCode | `bunx oh-my-opencode setup` |
| **ohmg** | Multi-agent framework for Gemini CLI | `bunx oh-my-ag` |
| **bmad** | BMAD workflow orchestration | Included in skills |
| **ralph** | Self-referential completion loop | Included in omc or install separately |
| **plannotator** | Visual plan/diff review | Auto-installed during PLAN via `bash scripts/ensure-plannotator.sh` (or preinstall with `bash scripts/install.sh --with-plannotator`) |
| **agentation** | UI annotation → agent code fix integration (`annotate` keyword, `agentui` compatibility maintained) | `bash scripts/install.sh --with-agentation` |
| **agent-browser** | Headless browser for AI agents — **primary tool for browser behavior verification** | `npm install -g agent-browser` |
| **playwriter** | Playwright-based browser automation (optional) | `npm install -g playwriter` |

---

## 3. JEO Workflow

### Full Flow

```
jeo "<task>"
    │
    ▼
[1] PLAN (ralph + plannotator)
    Draft plan with ralph → visual review with plannotator → Approve/Feedback
    │
    ▼
[2] EXECUTE
    ├─ team available? → /omc:team N:executor "<task>"
    │                    staged pipeline: plan→prd→exec→verify→fix
    └─ no team?       → /bmad /workflow-init → run BMAD steps
    │
    ▼
[3] VERIFY (agent-browser — default behavior)
    Verify browser behavior with agent-browser
    → capture snapshot → confirm UI/functionality is working
    │
    ├─ with annotate keyword → [3.3.1] VERIFY_UI (agentation watch loop)
    │   agentation_watch_annotations blocking → annotation ack→fix→resolve loop
    │
    ▼
[4] CLEANUP
    After all work is done → bash scripts/worktree-cleanup.sh
    git worktree prune
```

### 3.1 PLAN Step (ralph + plannotator)

> **Platform note**: The `/ralph` slash command is only available in Claude Code (omc).
> Use the "alternative method" below for Codex/Gemini/OpenCode.

**Claude Code (omc):**
```bash
/ralph "jeo-plan: <task>" --completion-promise="PLAN_APPROVED" --max-iterations=5
```

**Codex / Gemini / OpenCode (alternative):**
```bash
# Session-isolated feedback directory (prevents concurrent run conflicts)
FEEDBACK_DIR=$(python3 -c "import hashlib,os; h=hashlib.md5(os.getcwd().encode()).hexdigest()[:8]; d=f'/tmp/jeo-{h}'; os.makedirs(d,exist_ok=True); print(d)" 2>/dev/null || echo '/tmp')
FEEDBACK_FILE="${FEEDBACK_DIR}/plannotator_feedback.txt"

# 1. Write plan.md directly, then review with plannotator (blocking — no &)
PLANNOTATOR_RUNTIME_HOME="${FEEDBACK_DIR}/.plannotator"
mkdir -p "$PLANNOTATOR_RUNTIME_HOME"
touch /tmp/jeo-plannotator-direct.lock && python3 -c "
import json
print(json.dumps({'tool_input': {'plan': open('plan.md').read(), 'permission_mode': 'acceptEdits'}}))
" | env HOME="$PLANNOTATOR_RUNTIME_HOME" PLANNOTATOR_HOME="$PLANNOTATOR_RUNTIME_HOME" plannotator > "$FEEDBACK_FILE" 2>&1
# ↑ Run without &: waits until user clicks Approve/Send Feedback in browser

# 2. Check result and branch
if python3 -c "
import json, sys
try:
    d = json.load(open('$FEEDBACK_FILE'))
    sys.exit(0 if d.get('approved') is True else 1)
except Exception:
    sys.exit(1)
" 2>/dev/null; then
  echo "PLAN_APPROVED"   # → enter EXECUTE step
else
  echo "PLAN_FEEDBACK"   # → read \"$FEEDBACK_FILE\", replan, repeat above
fi
```

> **Important**: Do not run with `&` (background). Must run blocking to receive user feedback.

Common flow:
- Generate plan document (`plan.md`)
- Run plannotator blocking → browser UI opens automatically
- Review plan in browser → Approve or Send Feedback
- Approve (`"approved":true`) → enter [2] EXECUTE step
- Feedback → read `/tmp/plannotator_feedback.txt` annotations and replan (loop)
- **exit 32 (sandbox/CI — Conversation Approval Mode)**:
  1. Output full `plan.md` content to user
  2. Ask: "⚠️ plannotator UI unavailable. Reply 'approve' to proceed or provide feedback."
  3. **WAIT for user response — do NOT proceed to EXECUTE**
  4. On approval → update `jeo-state.json` `plan_approved=true, plan_gate_status="manual_approved"` → EXECUTE
  5. On feedback → revise `plan.md`, retry loop, repeat

**Claude Code manual run:**
```
Shift+Tab×2 → enter plan mode → plannotator runs automatically when plan is complete
```

### 3.2 EXECUTE Step

**When team is available (Claude Code + omc):**
```bash
/omc:team 3:executor "jeo-exec: <task based on approved plan>"
```
- staged pipeline: team-plan → team-prd → team-exec → team-verify → team-fix
- Maximize speed with parallel agent execution

**When Claude Code team mode is unavailable:**
```bash
echo "❌ JEO requires /omc:team in Claude Code. Run bash scripts/setup-claude.sh, restart Claude Code, then retry."
exit 1
```
- Do not degrade to single-agent mode

**When team is unavailable (BMAD fallback — Codex / Gemini / OpenCode):**
```bash
/workflow-init   # Initialize BMAD workflow
/workflow-status # Check current step
```
- Proceed in order: Analysis → Planning → Solutioning → Implementation
- Review documents with plannotator after each step completes

### 3.3 VERIFY Step (agent-browser — default behavior)

When browser-based functionality is present, verify behavior with `agent-browser`.

```bash
# Capture snapshot from the URL where the app is running
agent-browser snapshot http://localhost:3000

# Check specific elements (accessibility tree ref method)
agent-browser snapshot http://localhost:3000 -i
# → check element state using @eN ref numbers

# Save screenshot
agent-browser screenshot http://localhost:3000 -o verify.png
```

> **Default behavior**: Automatically runs the agent-browser verification step when browser-related work is complete.
> Backend/CLI tasks without a browser UI skip this step.

### 3.3.1 VERIFY_UI Step (annotate — agentation watch loop)

Runs the agentation watch loop when the `annotate` keyword is detected. (The `agentui` keyword is also supported for backward compatibility.)
This follows the same pattern as plannotator operating in `planui` / `ExitPlanMode`.

**Prerequisites (auto-resolved by pre-flight):**
1. `npx agentation-mcp server` (HTTP :4747) is running — **auto-started if not running**
2. `agentation` npm package installed in the project — **auto-installed if absent**
3. `<Agentation endpoint="http://localhost:4747" />` is mounted in the app — **auto-injected into entry point if absent**

**Pre-flight Check (required before entering — common to all platforms):**
```bash
# ── Step 1: Auto-start agentation-mcp server if not running ─────────────────
JEO_AGENTATION_ENDPOINT="${JEO_AGENTATION_ENDPOINT:-http://localhost:4747}"
JEO_AGENTATION_PORT="${JEO_AGENTATION_PORT:-4747}"

if ! curl -sf --connect-timeout 2 "${JEO_AGENTATION_ENDPOINT}/health" >/dev/null 2>&1; then
  echo "[JEO][ANNOTATE] agentation-mcp not running — attempting auto-start on port ${JEO_AGENTATION_PORT}..."
  if command -v npx >/dev/null 2>&1; then
    npx -y agentation-mcp server --port "${JEO_AGENTATION_PORT}" >/tmp/agentation-mcp.log 2>&1 &
    AGENTATION_MCP_PID=$!
    echo "[JEO][ANNOTATE] started agentation-mcp (PID ${AGENTATION_MCP_PID})"
    # Wait up to 8 seconds for server to become healthy
    for i in $(seq 1 8); do
      sleep 1
      if curl -sf --connect-timeout 1 "${JEO_AGENTATION_ENDPOINT}/health" >/dev/null 2>&1; then
        echo "[JEO][ANNOTATE] ✅ agentation-mcp server ready"
        break
      fi
    done
    if ! curl -sf --connect-timeout 2 "${JEO_AGENTATION_ENDPOINT}/health" >/dev/null 2>&1; then
      echo "[JEO][ANNOTATE] ⚠️  agentation-mcp failed to start — skipping VERIFY_UI"
      python3 -c "
import json,os,subprocess,fcntl,time
try:
    root=subprocess.check_output(['git','rev-parse','--show-toplevel'],stderr=subprocess.DEVNULL).decode().strip()
except:
    root=os.getcwd()
f=os.path.join(root,'.omc/state/jeo-state.json')
if os.path.exists(f):
    with open(f,'r+') as fh:
        fcntl.flock(fh,fcntl.LOCK_EX)
        try:
            d=json.load(fh)
            d['last_error']='agentation-mcp failed to start; VERIFY_UI skipped'
            d['updated_at']=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())
            fh.seek(0); json.dump(d,fh,ensure_ascii=False,indent=2); fh.truncate()
        finally:
            fcntl.flock(fh,fcntl.LOCK_UN)
" 2>/dev/null || true
      # Proceed to STEP 4 CLEANUP (no exit 1 — graceful skip)
    fi
  else
    echo "[JEO][ANNOTATE] ⚠️  npx not found — cannot auto-start agentation-mcp. Skipping VERIFY_UI."
    python3 -c "
import json,os,subprocess,fcntl,time
try:
    root=subprocess.check_output(['git','rev-parse','--show-toplevel'],stderr=subprocess.DEVNULL).decode().strip()
except:
    root=os.getcwd()
f=os.path.join(root,'.omc/state/jeo-state.json')
if os.path.exists(f):
    with open(f,'r+') as fh:
        fcntl.flock(fh,fcntl.LOCK_EX)
        try:
            d=json.load(fh)
            d['last_error']='npx not found; agentation-mcp auto-start skipped; VERIFY_UI skipped'
            d['updated_at']=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())
            fh.seek(0); json.dump(d,fh,ensure_ascii=False,indent=2); fh.truncate()
        finally:
            fcntl.flock(fh,fcntl.LOCK_UN)
" 2>/dev/null || true
    # Proceed to STEP 4 CLEANUP (no exit 1 — graceful skip)
  fi
fi

# ── Step 2: Auto-install agentation package + inject <Agentation> if needed ──
# Only runs when server is confirmed healthy
if curl -sf --connect-timeout 2 "${JEO_AGENTATION_ENDPOINT}/health" >/dev/null 2>&1; then
  SESSIONS=$(curl -sf "${JEO_AGENTATION_ENDPOINT}/sessions" 2>/dev/null || echo "[]")
  S_COUNT=$(echo "$SESSIONS" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo 0)

  if [ "$S_COUNT" -eq 0 ]; then
    echo "[JEO][ANNOTATE] No active sessions — running ensure-agentation.sh to install package and inject component..."

    # Locate ensure-agentation.sh (try common installation paths)
    ENSURE_SCRIPT=""
    for candidate in \
      "$(dirname "${BASH_SOURCE[0]:-$0}")/ensure-agentation.sh" \
      "$HOME/.claude/skills/jeo/scripts/ensure-agentation.sh" \
      "$HOME/.agent-skills/jeo/scripts/ensure-agentation.sh" \
      "$HOME/.codex/skills/jeo/scripts/ensure-agentation.sh"; do
      if [[ -f "$candidate" ]]; then
        ENSURE_SCRIPT="$candidate"
        break
      fi
    done

    if [[ -n "$ENSURE_SCRIPT" ]]; then
      ENSURE_EXIT=0
      bash "$ENSURE_SCRIPT" \
        --project-dir "${PROJECT_DIR:-$PWD}" \
        --endpoint "${JEO_AGENTATION_ENDPOINT}" || ENSURE_EXIT=$?

      if [ "$ENSURE_EXIT" -eq 0 ]; then
        echo "[JEO][ANNOTATE] ensure-agentation completed — waiting up to 15s for browser to reconnect..."
        # Wait for dev server hot-reload and browser reconnection
        for i in $(seq 1 15); do
          sleep 1
          NEW_S_COUNT=$(curl -sf "${JEO_AGENTATION_ENDPOINT}/sessions" 2>/dev/null | \
            python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo 0)
          if [ "$NEW_S_COUNT" -gt 0 ]; then
            echo "[JEO][ANNOTATE] ✅ Browser session established (${NEW_S_COUNT} session(s))"
            S_COUNT=$NEW_S_COUNT
            break
          fi
        done
        if [ "$S_COUNT" -eq 0 ]; then
          echo "[JEO][ANNOTATE] ⚠️  Component injected but no browser session yet."
          echo "   → Refresh the browser at your app URL, then re-run annotate."
        fi
      elif [ "$ENSURE_EXIT" -eq 2 ]; then
        echo "[JEO][ANNOTATE] ℹ️  Not a Node.js project — mount <Agentation endpoint='${JEO_AGENTATION_ENDPOINT}' /> manually."
      else
        echo "[JEO][ANNOTATE] ⚠️  ensure-agentation.sh failed (exit $ENSURE_EXIT) — mount <Agentation endpoint='${JEO_AGENTATION_ENDPOINT}' /> manually."
      fi
    else
      echo "[JEO][ANNOTATE] ⚠️  ensure-agentation.sh not found — mount <Agentation endpoint='${JEO_AGENTATION_ENDPOINT}' /> manually."
    fi
  fi

  echo "[JEO][ANNOTATE] ✅ agentation ready — server OK, ${S_COUNT} session(s)"
fi
```

> After passing pre-flight (`else` branch), update jeo-state.json `phase` to `"verify_ui"`, set `agentation.active` to `true`, and set `agentation.submit_gate_status` to `"waiting_for_submit"`.
> Do not call `/pending` yet. Draft annotations are not actionable until the user explicitly submits them.

**Claude Code (direct MCP tool call):**
```
# annotate keyword detected (or agentui — backward compatible)
# 1. wait for UserPromptSubmit after the user clicks Send Annotations / onSubmit
# 2. the JEO submit-gate hook records submit_gate_status="submitted"
# 3. only then run the blocking agentation watch loop
#
# batchWindowSeconds:10 — receive annotations in 10-second batches
# timeoutSeconds:120   — auto-exit after 120 seconds with no annotations
#
# Per-annotation processing loop:
# 1. agentation_acknowledge_annotation({id})           — show 'processing' in UI
# 2. navigate code via annotation.elementPath (CSS selector) → apply fix
# 3. agentation_resolve_annotation({id, summary})      — mark 'done' + save summary
#
# Loop ends when annotation count=0 or timeout
```

> **Important**: `agentation_watch_annotations` is a blocking call. Do not run with `&` background.
> Same as plannotator's `approved:true` loop: annotation count=0 or timeout = completion signal.
> `annotate` is the primary keyword. `agentui` is a backward-compatible alias and behaves identically.

**Codex / Gemini / OpenCode (HTTP REST API fallback):**
```bash
START_TIME=$(date +%s)
TIMEOUT_SECONDS=120

# Required gate: do not enter the loop until the human has clicked Send Annotations
# and the platform has opened agentation.submit_gate_status="submitted".
while true; do
  # Timeout check
  NOW=$(date +%s)
  ELAPSED=$((NOW - START_TIME))
  if [ $ELAPSED -ge $TIMEOUT_SECONDS ]; then
    echo "[JEO] agentation polling timeout (${TIMEOUT_SECONDS}s) — some annotations may remain unresolved"
    break
  fi

  SUBMIT_GATE=$(python3 -c "
import json
try:
    print(json.load(open('.omc/state/jeo-state.json')).get('agentation', {}).get('submit_gate_status', 'idle'))
except Exception:
    print('idle')
" 2>/dev/null || echo "idle")
  if [ "$SUBMIT_GATE" != "submitted" ]; then
    sleep 2
    continue
  fi

  COUNT=$(curl -sf --connect-timeout 3 --max-time 5 http://localhost:4747/pending 2>/dev/null | python3 -c "import sys,json; data=sys.stdin.read(); d=json.loads(data) if data.strip() else {}; print(d.get('count', len(d.get('annotations', [])) if isinstance(d, dict) else 0))" 2>/dev/null || echo 0)
  [ "$COUNT" -eq 0 ] && break

  # Process each annotation:
  # a) Acknowledge (show as in-progress)
  curl -X PATCH http://localhost:4747/annotations/<id> \
    -H 'Content-Type: application/json' \
    -d '{"status": "acknowledged"}'

  # b) Navigate code via elementPath (CSS selector) → apply fix

  # c) Resolve (mark done + fix summary)
  curl -X PATCH http://localhost:4747/annotations/<id> \
    -H 'Content-Type: application/json' \
    -d '{"status": "resolved", "resolution": "<fix summary>"}'

  sleep 3
done
```

### 3.4 CLEANUP Step (automatic worktree cleanup)

```bash
# Runs automatically after all work is complete
bash scripts/worktree-cleanup.sh

# Individual commands
git worktree list                         # List current worktrees
git worktree prune                        # Clean up worktrees for deleted branches
bash scripts/worktree-cleanup.sh --force  # Force cleanup including dirty worktrees
```

> Default run removes only clean extra worktrees; worktrees with changes are left with a warning.
> Use `--force` only after review.

---

## 4. Platform Plugin Configuration

### 4.1 Claude Code

```bash
# Automatic setup
bash scripts/setup-claude.sh

# Or manually:
/plugin marketplace add https://github.com/Yeachan-Heo/oh-my-claudecode
/plugin install oh-my-claudecode
/omc:omc-setup

# Add plannotator hook
bash .agent-skills/plannotator/scripts/setup-hook.sh
```

**Config file**: `~/.claude/settings.json`
```json
{
  "hooks": {
    "PermissionRequest": [{
      "matcher": "ExitPlanMode",
      "hooks": [{
        "type": "command",
        "command": "python3 ~/.agent-skills/jeo/scripts/claude-plan-gate.py",
        "timeout": 1800
      }]
    }]
  }
}
```

**agentation MCP config** (`~/.claude/settings.json` or `.claude/mcp.json`):
```json
{
  "mcpServers": {
    "agentation": {
      "command": "npx",
      "args": ["-y", "agentation-mcp", "server"]
    }
  },
  "hooks": {
    "UserPromptSubmit": [{
      "matcher": "*",
      "hooks": [{
        "type": "command",
        "command": "python3 ~/.agent-skills/jeo/scripts/claude-agentation-submit-hook.py",
        "timeout": 300
      }]
    }]
  }
}
```

`bash ~/.agent-skills/jeo/scripts/setup-claude.sh` migrates stale `~/.agent-skills/omg/...` hook paths to the active JEO install and installs compatibility shims for legacy Claude setups.


### 4.2 Codex CLI

```bash
# Automatic setup
bash scripts/setup-codex.sh

# What gets configured:
# - developer_instructions: ~/.codex/config.toml
# - prompt file: ~/.codex/prompts/jeo.md
# - notify hook: ~/.codex/hooks/jeo-notify.py
# - [tui] notifications: agent-turn-complete
```

**agentation MCP config** (`~/.codex/config.toml`):
```toml
[mcp_servers.agentation]
command = "npx"
args = ["-y", "agentation-mcp", "server"]
```


**notify hook** (`~/.codex/hooks/jeo-notify.py`):
- Detects `PLAN_READY` signal in `last-assistant-message` when agent turn completes
- Confirms `plan.md` exists, compares the current hash against `last_reviewed_plan_hash`, and skips the gate when the plan was already reviewed
- Saves result to `/tmp/plannotator_feedback.txt`
- Detects `ANNOTATE_READY` signal (or backward-compatible `AGENTUI_READY`) only in `verify_ui`
- Opens `agentation.submit_gate_status="submitted"` first, then polls `http://localhost:4747/pending`

**`~/.codex/config.toml`** config:
```toml
developer_instructions = """
# JEO Orchestration Workflow
# ...
"""

notify = ["python3", "~/.codex/hooks/jeo-notify.py"]

[tui]
notifications = ["agent-turn-complete"]
notification_method = "osc9"
```

> `developer_instructions` must be a **top-level string**.
> Writing it as a `[developer_instructions]` table may cause Codex to fail on startup with `invalid type: map, expected a string`.
> `notify` and `[tui].notifications` must also be set correctly for the PLAN/ANNOTATE follow-up loop to actually work.

Using in Codex:
```bash
/prompts:jeo    # Activate JEO workflow
# Agent writes plan.md and outputs "PLAN_READY" → notify hook runs automatically
```

### 4.3 Gemini CLI

```bash
# Automatic setup
bash scripts/setup-gemini.sh

# What gets configured:
# - AfterAgent backup hook: ~/.gemini/hooks/jeo-plannotator.sh
# - Instructions (MANDATORY loop): ~/.gemini/GEMINI.md
```

**Key principle**: The agent must call plannotator **directly in blocking mode** to receive feedback in the same turn.
The AfterAgent hook serves only as a safety net (runs after turn ends → injected in next turn).

**AfterAgent backup hook** (`~/.gemini/settings.json`):
```json
{
  "hooks": {
    "AfterAgent": [{
      "matcher": "",
      "hooks": [{
        "name": "plannotator-review",
        "type": "command",
        "command": "bash ~/.gemini/hooks/jeo-plannotator.sh",
        "description": "Run plannotator when plan.md is detected (AfterAgent backup)"
      }]
    }]
  }
}
```

**PLAN instructions added to GEMINI.md (mandatory loop)**:
```
1. Write plan.md
2. Run plannotator blocking (no &) → /tmp/plannotator_feedback.txt
3. approved=true → EXECUTE / Not approved → revise and repeat step 2
NEVER proceed to EXECUTE without approved=true.
```

**agentation MCP config** (`~/.gemini/settings.json`):
```json
{
  "mcpServers": {
    "agentation": {
      "command": "npx",
      "args": ["-y", "agentation-mcp", "server"]
    }
  }
}
```

> **Note**: Gemini CLI hook events use `BeforeTool` and `AfterAgent`.
> `ExitPlanMode` is a Claude Code-only hook.

> [Hooks Official Guide](https://developers.googleblog.com/tailor-gemini-cli-to-your-workflow-with-hooks/)

### 4.4 OpenCode

```bash
# Automatic setup
bash scripts/setup-opencode.sh

# Added to opencode.json:
# "@plannotator/opencode@latest" plugin
# "@oh-my-opencode/opencode@latest" plugin (omx)
```

OpenCode slash commands:
- `/jeo-plan` — plan with ralph + plannotator
- `/jeo-exec` — execute with team/bmad
- `/jeo-annotate` — start agentation watch loop (annotate; `/jeo-agentui` is a deprecated alias)
- `/jeo-cleanup` — worktree cleanup




**plannotator integration** (MANDATORY blocking loop):
```bash
# Write plan.md then run PLAN gate (no &) — receive feedback in same turn
bash scripts/plannotator-plan-loop.sh plan.md /tmp/plannotator_feedback.txt 3
# - Must wait until approve/feedback is received
# - Auto-restart on session exit (up to 3 times)
# - After 3 exits, confirm with user whether to abort or resume
# - exit 32 if localhost bind unavailable (replace with manual gate in TTY)

# Branch based on result
# approved=true  → enter EXECUTE
# not approved   → apply feedback, revise plan.md → repeat above
```


**agentation MCP config** (`opencode.json`):
```json
{
  "mcp": {
    "agentation": {
      "type": "local",
      "command": ["npx", "-y", "agentation-mcp", "server"]
    }
  }
}
```


---

## 5. Memory & State

JEO stores state at the following paths:

```
{worktree}/.omc/state/jeo-state.json   # JEO execution state
{worktree}/.omc/plans/jeo-plan.md      # Approved plan
{worktree}/.omc/logs/jeo-*.log         # Execution logs
```

**State file structure:**
```json
{
  "mode": "jeo",
  "phase": "plan|execute|verify|verify_ui|cleanup|done",
  "session_id": "<uuid>",
  "task": "current task description",
  "plan_approved": true,
  "plan_gate_status": "pending|approved|feedback_required|infrastructure_blocked|manual_approved",
  "plan_current_hash": "<sha256 or null>",
  "last_reviewed_plan_hash": "<sha256 or null>",
  "last_reviewed_plan_at": "2026-02-24T00:00:00Z",
  "plan_review_method": "plannotator|manual|null",
  "team_available": true,
  "retry_count": 0,
  "last_error": null,
  "checkpoint": "plan|execute|verify|verify_ui|cleanup",
  "created_at": "2026-02-24T00:00:00Z",
  "updated_at": "2026-02-24T00:00:00Z",
  "agentation": {
    "active": false,
    "session_id": null,
    "keyword_used": null,
    "submit_gate_status": "idle|waiting_for_submit|submitted",
    "submit_signal": "claude-user-prompt-submit|codex-notify|gemini-manual|null",
    "submit_received_at": "2026-02-24T00:00:00Z",
    "submitted_annotation_count": 0,
    "started_at": null,
    "timeout_seconds": 120,
    "annotations": {
      "total": 0, "acknowledged": 0, "resolved": 0, "dismissed": 0, "pending": 0
    },
    "completed_at": null,
    "exit_reason": null
  }
}
```

> **agentation fields**: `active` — whether the watch loop is running (used as hook guard), `session_id` — for resuming,
> `submit_gate_status` — prevents processing draft annotations before submit/onSubmit, `submit_signal` — which platform opened the gate,
> `submit_received_at` / `submitted_annotation_count` — audit trail for the submitted batch, `exit_reason` — `"all_resolved"` | `"timeout"` | `"user_cancelled"` | `"error"`
>
> **dismissed annotations**: When a user dismisses an annotation in the agentation UI (status becomes `"dismissed"`),
> the agent should skip code changes for that annotation, increment `annotations.dismissed`, and continue to the next pending annotation.
> Dismissed annotations are counted but not acted upon. The watch loop exits normally when `pending == 0` (resolved + dismissed covers all).
>
> **`plan_review_method`**: set to `"plannotator"` when approved via UI, `"manual"` when approved via TTY fallback gate.
>
> **`cleanup_completed`**: set to `true` by `worktree-cleanup.sh` after successful worktree prune.

> **Error recovery fields**:
> - `retry_count` — number of retries after an error. Increments +1 on each pre-flight failure. Ask user to confirm if `>= 3`.
> - `last_error` — most recent error message. Used to identify the cause on restart.
> - `checkpoint` — last phase that was started. Resume from this phase on restart (`plan|execute|verify|cleanup`).

**Checkpoint-based resume flow:**
```bash
# Check checkpoint on restart
python3 -c "
import json, os, subprocess
try:
    root = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], stderr=subprocess.DEVNULL).decode().strip()
except:
    root = os.getcwd()
f = os.path.join(root, '.omc/state/jeo-state.json')
if os.path.exists(f):
    d=json.load(open(f))
    cp=d.get('checkpoint')
    err=d.get('last_error')
    rc=d.get('retry_count',0)
    print(f'Resume from: {cp or \"beginning\"}')
    if err: print(f'Previous error ({rc} time(s)): {err}')
    if rc >= 3: print('⚠️ Retry count exceeded 3 — user confirmation required')
"
```

Restore after restart:
```bash
# Check status and resume
bash scripts/check-status.sh --resume
```

---

## 6. Recommended Workflow

```
# Step 1: Install (once)
bash scripts/install.sh --all
bash scripts/check-status.sh

# Step 2: Start work
jeo "<task description>"           # Activate with keyword
# Or in Claude: Shift+Tab×2 → plan mode

# Step 3: Review plan with plannotator
# Approve or Send Feedback in browser UI

# Step 4: Automatic execution
# team or bmad handles the work

# Step 5: Cleanup after completion
bash scripts/worktree-cleanup.sh
```

---

## 7. Best Practices

1. **Plan first**: always review the plan with ralph+plannotator before executing (catches wrong approaches early)
2. **Team first**: omc team mode is most efficient in Claude Code
3. **bmad fallback**: use BMAD in environments without team (Codex, Gemini)
4. **Worktree cleanup**: run `worktree-cleanup.sh` immediately after work completes (prevents branch pollution)
5. **State persistence**: use `.omc/state/jeo-state.json` to maintain state across sessions
6. **annotate**: use the `annotate` keyword to run the agentation watch loop for complex UI changes (precise code changes via CSS selector). `agentui` is a backward-compatible alias.

---

## 8. Troubleshooting

| Issue | Solution |
|------|------|
| plannotator not running | JEO first auto-runs `bash scripts/ensure-plannotator.sh`; if it still fails, run `bash .agent-skills/plannotator/scripts/check-status.sh` |
| plannotator not opening in Claude Code | plannotator is hook-only. Do NOT call it via MCP or CLI. Use `EnterPlanMode` → write plan → `ExitPlanMode`; the hook fires automatically. Verify hook is set: `cat ~/.claude/settings.json \| python3 -c "import sys,json;h=json.load(sys.stdin).get('hooks',{});print(h.get('PermissionRequest','missing'))"` |
| plannotator feedback not received | Remove `&` background execution → run blocking, then check `/tmp/plannotator_feedback.txt` (Codex/Gemini/OpenCode only) |
| Same plan is repeatedly re-reviewed in Codex | Compare `last_reviewed_plan_hash` in `jeo-state.json` with the current `plan.md` hash. If they match and `plan_gate_status` is terminal, do not re-run |
| Codex startup failure (`invalid type: map, expected a string`) | Re-run `bash scripts/setup-codex.sh` and confirm `developer_instructions` in `~/.codex/config.toml` is a top-level string |
| Gemini feedback loop missing | Add blocking direct call instruction to `~/.gemini/GEMINI.md` |
| worktree conflict | `git worktree prune && git worktree list` |
| team mode not working | JEO requires team mode in Claude Code. Run `bash scripts/setup-claude.sh`, restart Claude Code, and verify `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` before retrying |
| omc install failed | Run `/omc:omc-doctor` |
| agent-browser error | Check `agent-browser --version` |
| annotate (agentation) not opening | Check `curl http://localhost:4747/health` and `curl http://localhost:4747/sessions`. JEO waits for explicit submit/onSubmit before polling `/pending` |
| annotation not reflected in code | Confirm `summary` field is present when calling `agentation_resolve_annotation` |
| `agentui` keyword not activating | Use the `annotate` keyword (new). `agentui` is a deprecated alias but still works. |
| MCP tool not registered (Codex/Gemini) | Re-run `bash scripts/setup-codex.sh` / `setup-gemini.sh` |

---

## 9. References

- [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode) — Claude Code multi-agent
- [plannotator](https://plannotator.ai) — visual plan/diff review
- [BMAD Method](https://github.com/bmad-dev/BMAD-METHOD) — structured AI development workflow
- [Agent Skills Spec](https://agentskills.io/specification) — skill format specification
- [agentation](https://github.com/benjitaylor/agentation) — UI annotation → agent code fix integration (`annotate`; `agentui` backward compatible)


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### scripts/ensure-agentation.sh

```bash
#!/usr/bin/env bash
# JEO helper — ensures agentation npm package is installed and <Agentation> is mounted
# in the project's React entry point before VERIFY_UI runs.
#
# Usage: bash ensure-agentation.sh [--project-dir <path>] [--endpoint <url>] [--quiet] [--dry-run]
# Exit codes:
#   0  — agentation ready (already installed, or just installed+injected)
#   1  — could not install or inject (non-fatal: VERIFY_UI should graceful-skip)
#   2  — package.json not found (not a Node.js project — skip silently)

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# ── defaults ──────────────────────────────────────────────────────────────────
PROJECT_DIR="${PWD}"
ENDPOINT="http://localhost:4747"
QUIET=false
DRY_RUN=false

for arg in "$@"; do
  case "$arg" in
    --project-dir=*) PROJECT_DIR="${arg#*=}" ;;
    --endpoint=*)    ENDPOINT="${arg#*=}" ;;
    --quiet)         QUIET=true ;;
    --dry-run)       DRY_RUN=true ;;
    -h|--help)
      echo "Usage: bash ensure-agentation.sh [--project-dir <path>] [--endpoint <url>] [--quiet] [--dry-run]"
      echo "Ensures agentation npm package is installed and <Agentation> is mounted in the React entry point."
      exit 0
      ;;
  esac
done

log() { $QUIET || echo "[JEO][ANNOTATE] $*"; }
warn() { echo "[JEO][ANNOTATE] ⚠️  $*" >&2; }

# ── locate package.json ───────────────────────────────────────────────────────
find_package_json() {
  local dir="$1"
  # Check project dir and common sub-dirs
  for candidate in "$dir" "$dir/src" "$dir/app" "$dir/frontend" "$dir/client" "$dir/web"; do
    if [[ -f "$candidate/package.json" ]]; then
      echo "$candidate"
      return 0
    fi
  done
  return 1
}

PKG_DIR=""
if ! PKG_DIR=$(find_package_json "$PROJECT_DIR"); then
  log "No package.json found — not a Node.js project, skipping agentation setup."
  exit 2
fi

log "Found package.json at: $PKG_DIR"

# ── check if agentation already in dependencies ───────────────────────────────
is_package_installed() {
  python3 - "$PKG_DIR/package.json" <<'EOF'
import sys, json
try:
    d = json.load(open(sys.argv[1]))
    deps = {**d.get('dependencies', {}), **d.get('devDependencies', {})}
    sys.exit(0 if 'agentation' in deps else 1)
except Exception:
    sys.exit(1)
EOF
}

# ── detect package manager ────────────────────────────────────────────────────
detect_package_manager() {
  if [[ -f "$PKG_DIR/bun.lockb" ]] || [[ -f "$PKG_DIR/bun.lock" ]]; then
    echo "bun"
  elif [[ -f "$PKG_DIR/pnpm-lock.yaml" ]]; then
    echo "pnpm"
  elif [[ -f "$PKG_DIR/yarn.lock" ]]; then
    echo "yarn"
  else
    echo "npm"
  fi
}

# ── install agentation package ────────────────────────────────────────────────
install_agentation() {
  local pm="$1"
  log "Installing agentation with $pm..."
  if $DRY_RUN; then
    log "[dry-run] would run: cd $PKG_DIR && $pm add agentation -D (or equivalent)"
    return 0
  fi

  case "$pm" in
    bun)  (cd "$PKG_DIR" && bun add agentation -D) ;;
    pnpm) (cd "$PKG_DIR" && pnpm add agentation -D) ;;
    yarn) (cd "$PKG_DIR" && yarn add agentation --dev) ;;
    npm)  (cd "$PKG_DIR" && npm install agentation --save-dev --legacy-peer-deps) ;;
  esac
}

# ── find React entry point ────────────────────────────────────────────────────
find_entry_point() {
  local dir="$PROJECT_DIR"
  local candidates=(
    # Vite / CRA
    "$dir/src/main.tsx"
    "$dir/src/main.jsx"
    "$dir/main.tsx"
    "$dir/main.jsx"
    # Next.js App Router
    "$dir/app/layout.tsx"
    "$dir/app/layout.jsx"
    "$dir/src/app/layout.tsx"
    "$dir/src/app/layout.jsx"
    # Next.js Pages Router
    "$dir/pages/_app.tsx"
    "$dir/pages/_app.jsx"
    "$dir/src/pages/_app.tsx"
    "$dir/src/pages/_app.jsx"
  )
  for f in "${candidates[@]}"; do
    if [[ -f "$f" ]]; then
      echo "$f"
      return 0
    fi
  done
  return 1
}

# ── inject <Agentation> into entry point ─────────────────────────────────────
inject_agentation() {
  local entry="$1"
  local endpoint="$2"

  # Already injected?
  if grep -q 'from.*agentation' "$entry" 2>/dev/null; then
    log "<Agentation> already imported in $entry"
    return 0
  fi

  log "Injecting <Agentation endpoint=\"$endpoint\" /> into $entry"

  if $DRY_RUN; then
    log "[dry-run] would inject into $entry"
    return 0
  fi

  # Detect framework pattern from filename
  local filename
  filename="$(basename "$entry")"
  local framework="vite"
  [[ "$filename" == "layout.tsx" || "$filename" == "layout.jsx" ]] && framework="next-app"
  [[ "$filename" == "_app.tsx" || "$filename" == "_app.jsx" ]] && framework="next-pages"

  python3 - "$entry" "$endpoint" "$framework" <<'PYEOF'
import sys, re

entry_path = sys.argv[1]
endpoint = sys.argv[2]
framework = sys.argv[3]

with open(entry_path, 'r', encoding='utf-8') as f:
    content = f.read()

IMPORT_LINE = "import { Agentation } from 'agentation';"
COMPONENT_DEV = f"{{process.env.NODE_ENV === 'development' && <Agentation endpoint=\"{endpoint}\" />}}"

# Already injected guard (double-check)
if 'agentation' in content.lower():
    print(f"[JEO][ANNOTATE] agentation already present in {entry_path}", file=sys.stderr)
    sys.exit(0)

# --- Add import line ---
# Insert after the last existing import statement
import_match = list(re.finditer(r'^import\s.+;?\s*$', content, re.MULTILINE))
if import_match:
    last_import = import_match[-1]
    insert_pos = last_import.end()
    content = content[:insert_pos] + '\n' + IMPORT_LINE + content[insert_pos:]
else:
    # No imports found; prepend
    content = IMPORT_LINE + '\n' + content

# --- Inject component ---
if framework == 'next-app':
    # Next.js App Router layout.tsx: inject before closing </body> or </html>, or before return closing
    if '</body>' in content:
        content = content.replace('</body>', f'      {COMPONENT_DEV}\n      </body>', 1)
    elif 'return (' in content:
        # Inject before last closing paren of return block
        last_paren = content.rfind('\n)')
        if last_paren >= 0:
            content = content[:last_paren] + f'\n      {COMPONENT_DEV}' + content[last_paren:]
elif framework == 'next-pages':
    # Next.js Pages _app: inject inside Component rendering
    if '<Component' in content:
        content = re.sub(
            r'(<Component\s[^/]*/?>)',
            r'\1\n      ' + COMPONENT_DEV,
            content,
            count=1
        )
    elif 'return (' in content:
        last_paren = content.rfind('\n  )')
        if last_paren >= 0:
            content = content[:last_paren] + f'\n      {COMPONENT_DEV}' + content[last_paren:]
else:
    # Vite / CRA: inject inside the root render call
    # Strategy 1: inject before ReactDOM.createRoot closing render call's last closing tag
    # Look for </StrictMode>, </React.StrictMode>, or the outermost JSX closing tag
    strict_match = re.search(r'(</(?:React\.)?StrictMode>)', content)
    if strict_match:
        pos = strict_match.start()
        content = content[:pos] + f'  {COMPONENT_DEV}\n  ' + content[pos:]
    else:
        # Strategy 2: find render(<...>) and inject inside
        render_match = re.search(r'(\.render\s*\(\s*<)([^)]+?)(\s*>\s*\))', content, re.DOTALL)
        if render_match:
            inner = render_match.group(2)
            new_inner = inner + f'\n  {COMPONENT_DEV}'
            content = content[:render_match.start(2)] + new_inner + content[render_match.end(2):]

with open(entry_path, 'w', encoding='utf-8') as f:
    f.write(content)

print(f"[JEO][ANNOTATE] ✅ Injected <Agentation endpoint=\"{endpoint}\" /> into {entry_path}")
PYEOF
}

# ── main ──────────────────────────────────────────────────────────────────────
main() {
  local pm
  pm=$(detect_package_manager)
  log "Detected package manager: $pm"

  # 1. Install package if needed
  if is_package_installed; then
    log "agentation package already in $PKG_DIR/package.json"
  else
    log "agentation package not found — installing..."
    if ! install_agentation "$pm"; then
      warn "Failed to install agentation package. Run manually: cd $PKG_DIR && $pm add agentation -D"
      exit 1
    fi
    log "agentation package installed successfully."
  fi

  # 2. Find entry point and inject component
  local entry=""
  if ! entry=$(find_entry_point); then
    warn "Could not find React entry point (main.jsx, app/layout.tsx, pages/_app.tsx). Mount <Agentation endpoint=\"$ENDPOINT\" /> manually."
    exit 1
  fi

  inject_agentation "$entry" "$ENDPOINT"
  log "Entry point: $entry — injection complete."
  exit 0
}

main

```

### scripts/worktree-cleanup.sh

```bash
#!/usr/bin/env bash
# JEO Skill — Worktree Cleanup Script
# Removes stale git worktrees after task completion
# Usage: bash worktree-cleanup.sh [--force] [--dry-run] [--list]

set -euo pipefail

GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; BLUE='\033[0;34m'; NC='\033[0m'
ok()   { echo -e "${GREEN}✓${NC} $*"; }
warn() { echo -e "${YELLOW}⚠${NC}  $*"; }
err()  { echo -e "${RED}✗${NC} $*"; }
info() { echo -e "${BLUE}→${NC} $*"; }

DRY_RUN=false; FORCE=false; LIST_ONLY=false
for arg in "$@"; do
  case $arg in
    --dry-run) DRY_RUN=true ;;
    --force)
      FORCE=true
      warn "WARNING: --force removes ALL non-main worktrees."
      warn "         Active feature branches in separate worktrees will also be removed."
      warn "         Press Ctrl+C within 5 seconds to cancel..."
      sleep 5
      ;;
    --list)    LIST_ONLY=true ;;
  esac
done

echo ""
echo "JEO — Worktree Cleanup"
echo "====================="

# ── Check git repo ─────────────────────────────────────────────────────────────
if ! git rev-parse --git-dir >/dev/null 2>&1; then
  err "Not a git repository. Run from project root."
  exit 1
fi

GIT_ROOT=$(git rev-parse --show-toplevel)

# ── List all worktrees ─────────────────────────────────────────────────────────
info "Current worktrees:"
git worktree list
echo ""

# ── Identify stale worktrees ──────────────────────────────────────────────────
MAIN_WORKTREE=$(git worktree list | head -1 | awk '{print $1}')
WORKTREES_TO_REMOVE=()
DIRTY_WORKTREES=()

while IFS= read -r line; do
  WORKTREE_PATH=$(echo "$line" | awk '{print $1}')

  # Skip main worktree
  [[ "$WORKTREE_PATH" == "$MAIN_WORKTREE" ]] && continue

  if $FORCE; then
    WORKTREES_TO_REMOVE+=("$WORKTREE_PATH")
  elif [[ -z "$(git -C "$WORKTREE_PATH" status --porcelain 2>/dev/null)" ]]; then
    WORKTREES_TO_REMOVE+=("$WORKTREE_PATH")
  else
    DIRTY_WORKTREES+=("$WORKTREE_PATH")
  fi
done < <(git worktree list | tail -n +2)

# ── List mode ─────────────────────────────────────────────────────────────────
if $LIST_ONLY; then
  if [[ ${#WORKTREES_TO_REMOVE[@]} -eq 0 ]] && [[ ${#DIRTY_WORKTREES[@]} -eq 0 ]]; then
    ok "No extra worktrees found"
  else
    if [[ ${#WORKTREES_TO_REMOVE[@]} -gt 0 ]]; then
      echo "Worktrees ready to remove:"
      for wt in "${WORKTREES_TO_REMOVE[@]}"; do
        echo "  - $wt"
      done
    fi
    if [[ ${#DIRTY_WORKTREES[@]} -gt 0 ]]; then
      echo "Dirty worktrees skipped by default:"
      for wt in "${DIRTY_WORKTREES[@]}"; do
        echo "  - $wt"
      done
      echo "Use --force to remove dirty worktrees after reviewing them."
    fi
  fi
  exit 0
fi

# ── Remove identified worktrees ───────────────────────────────────────────────
if [[ ${#WORKTREES_TO_REMOVE[@]} -eq 0 ]]; then
  ok "No extra worktrees to remove"
else
  info "Removing ${#WORKTREES_TO_REMOVE[@]} worktree(s)..."
  for WORKTREE_PATH in "${WORKTREES_TO_REMOVE[@]}"; do
    if $DRY_RUN; then
      echo -e "${YELLOW}[DRY-RUN]${NC} Would remove: $WORKTREE_PATH"
    else
      info "Removing: $WORKTREE_PATH"
      if $FORCE; then
        if git worktree remove "$WORKTREE_PATH" --force 2>/dev/null; then
          ok "Removed: $WORKTREE_PATH"
        else
          warn "Could not remove $WORKTREE_PATH with --force"
        fi
      elif git worktree remove "$WORKTREE_PATH" 2>/dev/null; then
        ok "Removed: $WORKTREE_PATH"
      else
        warn "Could not remove $WORKTREE_PATH (dirty or already gone)"
      fi
    fi
  done
fi

if [[ ${#DIRTY_WORKTREES[@]} -gt 0 ]] && ! $FORCE; then
  warn "Skipped ${#DIRTY_WORKTREES[@]} dirty worktree(s). Re-run with --force after review."
fi

# ── Prune stale worktree entries ──────────────────────────────────────────────
info "Pruning stale worktree references..."
if $DRY_RUN; then
  echo -e "${YELLOW}[DRY-RUN]${NC} Would run: git worktree prune"
else
  git worktree prune
  ok "Stale worktree references pruned"
fi

# ── Deactivate ralphmode (revert project permissionMode) ─────────────────────
_PLAN_GATE_SCRIPT=""
for _candidate in \
  "$(dirname "${BASH_SOURCE[0]}")/claude-plan-gate.py" \
  "$HOME/.agent-skills/jeo/scripts/claude-plan-gate.py" \
  "$HOME/.claude/skills/jeo/scripts/claude-plan-gate.py"; do
  if [[ -f "$_candidate" ]]; then
    _PLAN_GATE_SCRIPT="$_candidate"
    break
  fi
done

if [[ -n "$_PLAN_GATE_SCRIPT" ]] && command -v python3 >/dev/null 2>&1; then
  if $DRY_RUN; then
    echo -e "${YELLOW}[DRY-RUN]${NC} Would deactivate ralphmode (restore project permissionMode)"
  else
    python3 "$_PLAN_GATE_SCRIPT" --deactivate 2>&1 || true
  fi
fi

# ── Update JEO state ──────────────────────────────────────────────────────────
STATE_FILE="$GIT_ROOT/.omc/state/jeo-state.json"
if [[ -f "$STATE_FILE" ]] && command -v python3 >/dev/null 2>&1; then
  if $DRY_RUN; then
    echo -e "${YELLOW}[DRY-RUN]${NC} Would update JEO state: phase=cleanup, cleanup_completed=true"
  else
    STATE_FILE="$STATE_FILE" python3 - <<'PYEOF'
import json, os
state_path = os.environ["STATE_FILE"]
try:
    with open(state_path) as f:
        state = json.load(f)
    state["phase"] = "done"
    # state["worktrees"] = []  # 미구현 필드 — SKILL.md 초기 스키마에서도 제거됨. 향후 멀티-worktree 추적 구현 시 복원.
    state["cleanup_completed"] = True
    with open(state_path, "w") as f:
        json.dump(state, f, indent=2)
    print("✓ JEO state updated: cleanup complete")
except Exception as e:
    print(f"⚠  Could not update state: {e}")
PYEOF
  fi
fi

# ── Final summary ─────────────────────────────────────────────────────────────
echo ""
echo "Final worktree list:"
git worktree list
echo ""
ok "Worktree cleanup complete"
echo ""

```

### scripts/install.sh

```bash
#!/usr/bin/env bash
# JEO Skill — Master Installation Script
# Installs and configures: ralph, omc, omx, ohmg, bmad, agent-browser, playwriter, plannotator, agentation
# Usage: bash install.sh [--all] [--with-omc] [--with-plannotator] [--with-browser] [--with-agentation] [--dry-run]

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
SKILLS_ROOT="$(dirname "$SKILL_DIR")"

# Colors
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
ok()   { echo -e "${GREEN}✓${NC} $*"; }
warn() { echo -e "${YELLOW}⚠${NC}  $*"; }
err()  { echo -e "${RED}✗${NC} $*"; }
info() { echo -e "${BLUE}→${NC} $*"; }

DRY_RUN=false
INSTALL_ALL=false
INSTALL_OMC=false
INSTALL_PLANNOTATOR=false
INSTALL_AGENTATION=false
INSTALL_BROWSER=false
INSTALL_BMAD=false
INSTALL_OMX=false
INSTALL_OHMG=false

for arg in "$@"; do
  case $arg in
    --all)             INSTALL_ALL=true ;;
    --with-omc)        INSTALL_OMC=true ;;
    --with-plannotator) INSTALL_PLANNOTATOR=true ;;
    --with-browser)    INSTALL_BROWSER=true ;;
    --with-bmad)       INSTALL_BMAD=true ;;
    --with-omx)        INSTALL_OMX=true ;;
    --with-ohmg)       INSTALL_OHMG=true ;;
    --with-agentation) INSTALL_AGENTATION=true ;;
    --dry-run)         DRY_RUN=true ;;
    -h|--help)
      echo "JEO Master Installer"
      echo "Usage: bash install.sh [options]"
      echo "Options:"
      echo "  --all              Install all components"
      echo "  --with-omc         Install oh-my-claudecode (Claude Code)"
      echo "  --with-plannotator Install plannotator CLI"
      echo "  --with-browser     Install agent-browser + playwriter"
      echo "  --with-bmad        Install BMAD orchestrator"
      echo "  --with-omx         Install omx (OpenCode multi-agent)"
      echo "  --with-ohmg        Install ohmg (Gemini multi-agent)"
      echo "  --with-agentation  Install agentation MCP (UI annotation \u2194 agent)"
      echo "  --dry-run          Preview without executing"
      exit 0
      ;;
  esac
done

if $INSTALL_ALL; then
  INSTALL_OMC=true; INSTALL_PLANNOTATOR=true
  INSTALL_BROWSER=true; INSTALL_BMAD=true; INSTALL_OMX=true; INSTALL_OHMG=true; INSTALL_AGENTATION=true
fi

echo ""
echo "╔══════════════════════════════════════════╗"
echo "║   JEO Skill — Integrated Orchestration  ║"
echo "║   Version 1.1.0                          ║"
echo "╚══════════════════════════════════════════╝"
echo ""

run() {
  if $DRY_RUN; then
    echo -e "${YELLOW}[DRY-RUN]${NC} $*"
  else
    eval "$@"
  fi
}

# ── Detect OS ─────────────────────────────────────────────────────────────────
OS="unknown"
if [[ "$OSTYPE" == "darwin"* ]]; then OS="macos"
elif [[ "$OSTYPE" == "linux"* ]]; then OS="linux"
elif [[ "$OSTYPE" == "msys"* ]] || [[ "$OSTYPE" == "cygwin"* ]]; then OS="windows"
fi
info "Detected OS: $OS"

# ── Check prerequisites ────────────────────────────────────────────────────────
info "Checking prerequisites..."
MISSING_DEPS=()
if command -v node >/dev/null 2>&1; then
  NODE_VER=$(node --version 2>/dev/null | grep -oE '[0-9]+' | head -1)
  if [[ -z "$NODE_VER" ]] || [[ "$NODE_VER" -lt 18 ]]; then
    MISSING_DEPS+=("node >=18 (현재: $(node --version 2>/dev/null || echo 'unknown'))")
  fi
else
  MISSING_DEPS+=("node >=18")
fi
command -v npm >/dev/null 2>&1  || MISSING_DEPS+=("npm")
command -v git >/dev/null 2>&1  || MISSING_DEPS+=("git")
command -v bash >/dev/null 2>&1 || MISSING_DEPS+=("bash")

if [[ ${#MISSING_DEPS[@]} -gt 0 ]]; then
  err "Missing required dependencies: ${MISSING_DEPS[*]}"
  echo "Install them first, then re-run this script."
  exit 1
fi
ok "Prerequisites satisfied"

# ── 1. omc (oh-my-claudecode) ──────────────────────────────────────────────────
if $INSTALL_OMC; then
  echo ""
  info "Installing omc (oh-my-claudecode)..."
  if command -v claude >/dev/null 2>&1; then
    echo "  Run these commands inside Claude Code:"
    echo "  /plugin marketplace add https://github.com/Yeachan-Heo/oh-my-claudecode"
    echo "  /plugin install oh-my-claudecode"
    echo "  /omc:omc-setup"
    ok "omc install instructions shown (Claude Code required)"
  else
    warn "claude CLI not found — install Claude Code first, then run omc setup manually"
  fi
fi

# ── 2. omx (oh-my-opencode) ───────────────────────────────────────────────────
if $INSTALL_OMX; then
  echo ""
  info "Installing omx (oh-my-opencode)..."
  if command -v bun >/dev/null 2>&1; then
    run "bunx oh-my-opencode setup 2>/dev/null || true"
    ok "omx (oh-my-opencode) configured"
  elif command -v npx >/dev/null 2>&1; then
    run "npx oh-my-opencode setup 2>/dev/null || true"
    ok "omx configured via npx"
  else
    warn "bun/npx not found — install bun first: curl -fsSL https://bun.sh/install | bash"
  fi
fi

# ── 3. ohmg (oh-my-ag / Gemini) ───────────────────────────────────────────────
if $INSTALL_OHMG; then
  echo ""
  info "Installing ohmg (oh-my-ag for Gemini CLI)..."
  if command -v bun >/dev/null 2>&1; then
    run "bunx oh-my-ag 2>/dev/null || true"
    ok "ohmg configured"
  else
    warn "bun not found — install bun first: curl -fsSL https://bun.sh/install | bash"
  fi
fi

# ── 4. plannotator ─────────────────────────────────────────────────────────────
if $INSTALL_PLANNOTATOR; then
  echo ""
  info "Installing plannotator..."
  PLANNOTATOR_INSTALL="$SKILLS_ROOT/plannotator/scripts/install.sh"
  PLANNOTATOR_INSTALLED=false
  if [[ -f "$PLANNOTATOR_INSTALL" ]]; then
    if run "bash '$PLANNOTATOR_INSTALL' --all"; then
      PLANNOTATOR_INSTALLED=true
      ok "plannotator installed via skills script"
    else
      err "plannotator install script failed: $PLANNOTATOR_INSTALL"
    fi
  else
    # Fallback: direct install
    if run "curl -fsSL https://plannotator.ai/install.sh | bash"; then
      PLANNOTATOR_INSTALLED=true
      ok "plannotator installed"
    else
      err "plannotator install failed — check https://plannotator.ai"
    fi
  fi

  export PATH="$HOME/.local/bin:$HOME/bin:$PATH"
  if ! $PLANNOTATOR_INSTALLED || ! command -v plannotator >/dev/null 2>&1; then
    err "plannotator is still unavailable after install attempt"
    exit 1
  fi
fi

# ── 5. agent-browser ──────────────────────────────────────────────────────────
if $INSTALL_BROWSER; then
  echo ""
  info "Installing agent-browser..."
  if command -v npm >/dev/null 2>&1; then
    run "npm install -g agent-browser 2>/dev/null || npx agent-browser --version 2>/dev/null || true"
    ok "agent-browser installed"
  else
    warn "npm not found"
  fi

  echo ""
  info "Installing playwriter..."
  if command -v npm >/dev/null 2>&1; then
    run "npm install -g playwriter 2>/dev/null || true"
    ok "playwriter installed"
  else
    warn "npm not found"
  fi
fi

# ── 7. bmad ───────────────────────────────────────────────────────────────────
if $INSTALL_BMAD; then
  echo ""
  info "Configuring BMAD orchestrator..."
  BMAD_SKILL="$SKILLS_ROOT/bmad-orchestrator/SKILL.md"
  if [[ -f "$BMAD_SKILL" ]]; then
    ok "BMAD skill available at: $BMAD_SKILL"
  else
    warn "BMAD skill not found — ensure skills-template is properly installed"
  fi
fi

# ── 8. agentation MCP ────────────────────────────────────────────────────────────────────────────
if $INSTALL_AGENTATION; then
  echo ""
  info "Installing agentation MCP..."
  if command -v npx >/dev/null 2>&1; then
    run "npx -y agentation-mcp doctor 2>/dev/null || npx -y agentation-mcp --version 2>/dev/null || true"
    ok "agentation-mcp (server) available via npx"
    info "Start server: npx agentation-mcp server"
    info "React component: auto-installed + injected by ensure-agentation.sh at VERIFY_UI time"
    info "Manual install: cd <your-app> && npm install agentation --save-dev"
    info "Manual inject:  import { Agentation } from 'agentation'; <Agentation endpoint=\"http://localhost:4747\" />"
  else
    warn "npx not found — install Node.js first"
  fi
fi

# ── 9. Setup platform integrations ────────────────────────────────────────────────────────────────────────────
if $INSTALL_ALL; then
  echo ""
  info "Setting up platform integrations..."
  run "bash '$SCRIPT_DIR/setup-claude.sh' 2>/dev/null || true"
  run "bash '$SCRIPT_DIR/setup-codex.sh' 2>/dev/null || true"
  run "bash '$SCRIPT_DIR/setup-gemini.sh' 2>/dev/null || true"
  run "bash '$SCRIPT_DIR/setup-opencode.sh' 2>/dev/null || true"
fi

# ── Done ──────────────────────────────────────────────────────────────────────
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║   JEO Installation Complete!             ║"
echo "╚══════════════════════════════════════════╝"
echo ""
ok "JEO skill installed successfully"
echo ""
echo "Next steps:"
echo "  1. bash scripts/check-status.sh    — Verify all integrations"
echo "  2. Restart your AI tools (Claude Code, Gemini CLI, OpenCode, Codex)"
echo "  3. Use keyword 'jeo' to activate the orchestration workflow"
echo "  4. Use keyword 'annotate' inside jeo to start agentation watch loop (agentui is a deprecated alias)"
echo ""

```

### scripts/check-status.sh

```bash
#!/usr/bin/env bash
# JEO Skill — Status Check
# Verifies all JEO components and integrations
# Usage: bash check-status.sh [--resume]

set -euo pipefail

GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
ok()   { echo -e "  ${GREEN}✓${NC} $*"; }
warn() { echo -e "  ${YELLOW}⚠${NC}  $*"; }
err()  { echo -e "  ${RED}✗${NC} $*"; }
info() { echo -e "${BLUE}${BOLD}$*${NC}"; }

RESUME=false
[[ "${1:-}" == "--resume" ]] && RESUME=true

PASS=0; WARN=0; FAIL=0

check() {
  local label="$1"; local cmd="$2"
  if eval "$cmd" >/dev/null 2>&1; then
    ok "$label"; ((PASS++)) || true
  else
    err "$label (not found)"; ((FAIL++)) || true
  fi
}

check_opt() {
  local label="$1"; local cmd="$2"
  if eval "$cmd" >/dev/null 2>&1; then
    ok "$label"; ((PASS++)) || true
  else
    warn "$label (optional — not configured)"; ((WARN++)) || true
  fi
}

echo ""
echo "╔══════════════════════════════════════════╗"
echo "║   JEO Skill — Status Check               ║"
echo "╚══════════════════════════════════════════╝"
echo ""

# ── Prerequisites ─────────────────────────────────────────────────────────────
info "Prerequisites"
check     "node >=18"        "node --version | grep -E 'v(1[89]|[2-9][0-9])'"
check     "npm"              "command -v npm"
check     "git"              "command -v git"
check_opt "bun"              "command -v bun"
check_opt "python3"          "command -v python3"
echo ""

# ── Core Tools ────────────────────────────────────────────────────────────────
info "Core Tools"
check_opt "plannotator CLI"  "command -v plannotator"
check_opt "agent-browser"    "command -v agent-browser || npx agent-browser --version"
check_opt "playwriter"       "command -v playwriter"
echo ""

# ── AI Tool Integrations ──────────────────────────────────────────────────────
info "AI Tool Integrations"

# Claude Code
if [[ -f "${HOME}/.claude/settings.json" ]]; then
  if grep -Eq "claude-plan-gate.py|plannotator" "${HOME}/.claude/settings.json" 2>/dev/null; then
    ok "Claude Code — plannotator hook configured"; ((PASS++)) || true
  else
    warn "Claude Code — plannotator hook not found in settings.json"; ((WARN++)) || true
  fi
  TEAMS_ENABLED=false
  if [[ "${CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS:-}" =~ ^(1|true|True|yes|YES)$ ]]; then
    TEAMS_ENABLED=true
  elif python3 -c "
import json, os, sys
try:
    s = json.load(open(os.path.expanduser('~/.claude/settings.json')))
    val = s.get('env', {}).get('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', '')
    sys.exit(0 if str(val) in ('1', 'true', 'True', 'yes') else 1)
except Exception:
    sys.exit(1)
" 2>/dev/null; then
    TEAMS_ENABLED=true
  fi
  if $TEAMS_ENABLED; then
    ok "Claude Code — experimental agent teams enabled"; ((PASS++)) || true
  else
    err "Claude Code — experimental agent teams not enabled (required for JEO team execution)"; ((FAIL++)) || true
  fi
  if grep -q '"agentation"' "${HOME}/.claude/settings.json" 2>/dev/null; then
    ok "Claude Code — agentation MCP configured"; ((PASS++)) || true
  else
    warn "Claude Code — agentation MCP not configured"; ((WARN++)) || true
  fi
  if grep -Eq "claude-agentation-submit-hook.py|localhost:4747/pending" "${HOME}/.claude/settings.json" 2>/dev/null; then
    ok "Claude Code — agentation prompt hook configured"; ((PASS++)) || true
  else
    warn "Claude Code — agentation prompt hook not configured"; ((WARN++)) || true
  fi
  if grep -Eq '\.agent-skills/omg/scripts/(claude-agentation-submit-hook\.py|claude-plan-gate\.py)' "${HOME}/.claude/settings.json" 2>/dev/null; then
    warn "Claude Code — legacy omg hook path detected; rerun ~/.agent-skills/jeo/scripts/setup-claude.sh"; ((WARN++)) || true
  fi
else
  warn "Claude Code — ~/.claude/settings.json not found"; ((WARN++)) || true
fi

# Codex CLI
if [[ -f "${HOME}/.codex/config.toml" ]]; then
  if python3 - <<'PYEOF' >/dev/null 2>&1
import pathlib, re
p = pathlib.Path.home() / '.codex' / 'config.toml'
text = p.read_text(encoding='utf-8')
m1 = re.search(r'(?ms)^developer_instructions\s*=\s*"""\n?(.*?)\n?"""\s*$', text)
if m1 and 'Keyword: jeo | Platforms: Codex, Claude, Gemini, OpenCode' in m1.group(1):
    raise SystemExit(0)
m2 = re.search(r'(?m)^developer_instructions\s*=\s*"(.*)"\s*$', text)
if m2 and 'Keyword: jeo | Platforms: Codex, Claude, Gemini, OpenCode' in bytes(m2.group(1), 'utf-8').decode('unicode_escape'):
    raise SystemExit(0)
raise SystemExit(1)
PYEOF
  then
    ok "Codex CLI — JEO developer_instructions configured"; ((PASS++)) || true
  else
    warn "Codex CLI — JEO developer_instructions missing/invalid in config.toml"; ((WARN++)) || true
  fi
  if [[ -f "${HOME}/.codex/prompts/jeo.md" ]]; then
    ok "Codex CLI — /prompts:jeo available"; ((PASS++)) || true
  else
    warn "Codex CLI — /prompts:jeo not found"; ((WARN++)) || true
  fi
  if grep -q "jeo-notify.py" "${HOME}/.codex/config.toml" 2>/dev/null; then
    ok "Codex CLI — notify hook configured"; ((PASS++)) || true
  else
    warn "Codex CLI — notify hook missing"; ((WARN++)) || true
  fi
  if grep -q 'agent-turn-complete' "${HOME}/.codex/config.toml" 2>/dev/null; then
    ok "Codex CLI — tui notifications include agent-turn-complete"; ((PASS++)) || true
  else
    warn "Codex CLI — tui notifications missing agent-turn-complete"; ((WARN++)) || true
  fi
else
  warn "Codex CLI — ~/.codex/config.toml not found"; ((WARN++)) || true
fi

# Gemini CLI
if [[ -f "${HOME}/.gemini/settings.json" ]]; then
  if grep -q "plannotator" "${HOME}/.gemini/settings.json" 2>/dev/null; then
    ok "Gemini CLI — plannotator hook configured"; ((PASS++)) || true
  else
    warn "Gemini CLI — plannotator hook not found"; ((WARN++)) || true
  fi
else
  warn "Gemini CLI — ~/.gemini/settings.json not found"; ((WARN++)) || true
fi
if [[ -f "${HOME}/.gemini/GEMINI.md" ]]; then
  if grep -q "JEO Orchestration" "${HOME}/.gemini/GEMINI.md" 2>/dev/null; then
    ok "Gemini CLI — JEO instructions in GEMINI.md"; ((PASS++)) || true
  else
    warn "Gemini CLI — JEO not in GEMINI.md"; ((WARN++)) || true
  fi
fi

# OpenCode
for candidate in "./opencode.json" "${HOME}/opencode.json" "${HOME}/.config/opencode/opencode.json"; do
  if [[ -f "$candidate" ]]; then
    if grep -q "plannotator" "$candidate" 2>/dev/null; then
      ok "OpenCode — plannotator plugin configured ($candidate)"; ((PASS++)) || true
    else
      warn "OpenCode — plannotator not in $candidate"; ((WARN++)) || true
    fi
    break
  fi
done
echo ""

# ── JEO State ─────────────────────────────────────────────────────────────────
info "JEO State"
GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
STATE_FILE="$GIT_ROOT/.omc/state/jeo-state.json"
PHASE="unknown"
if [[ -f "$STATE_FILE" ]]; then
  ok "State file found: $STATE_FILE"
  if command -v python3 >/dev/null 2>&1; then
    PHASE=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('phase','unknown'))" 2>/dev/null || echo "unknown")
    TASK=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('task','(none)'))" 2>/dev/null || echo "(none)")
    RETRY=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('retry_count',0))" 2>/dev/null || echo "0")
    LAST_ERR=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); e=d.get('last_error'); print(e if e else '(none)')" 2>/dev/null || echo "(none)")
    PLAN_GATE=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('plan_gate_status','(none)'))" 2>/dev/null || echo "(none)")
    PLAN_HASH=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('last_reviewed_plan_hash') or '(none)')" 2>/dev/null || echo "(none)")
    SUBMIT_GATE=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('agentation',{}).get('submit_gate_status','(none)'))" 2>/dev/null || echo "(none)")
    echo "     Current phase: $PHASE"
    echo "     Task: $TASK"
    echo "     Plan gate: $PLAN_GATE"
    [[ "$PLAN_HASH" != "(none)" ]] && echo "     Last reviewed hash: ${PLAN_HASH:0:12}..."
    echo "     Agentation submit gate: $SUBMIT_GATE"
    [[ "$RETRY" -gt 0 ]] && echo "     Retry count: $RETRY"
    [[ "$LAST_ERR" != "(none)" ]] && echo -e "     ${RED}Last error: $LAST_ERR${NC}"
  fi
  if $RESUME; then
    echo ""
    echo "  Resume instructions:"
    echo "  The JEO workflow was in phase: $PHASE"
    echo "  Check .omc/plans/jeo-plan.md for the approved plan"
  fi
else
  warn "No active JEO state (no workflow in progress)"
  if $RESUME && command -v python3 >/dev/null 2>&1; then
    info "Initializing fresh JEO state file for new session..."
    mkdir -p "$GIT_ROOT/.omc/state" "$GIT_ROOT/.omc/plans"
    GIT_ROOT="$GIT_ROOT" python3 - <<'PYEOF'
import json, datetime, os, uuid
now = datetime.datetime.utcnow().isoformat() + "Z"
git_root = os.environ["GIT_ROOT"]
state = {
    "mode": "jeo",
    "phase": "plan",
    "session_id": str(uuid.uuid4()),
    "task": "resumed session",
    "plan_approved": False,
    "plan_gate_status": "pending",
    "plan_current_hash": None,
    "last_reviewed_plan_hash": None,
    "last_reviewed_plan_at": None,
    "plan_review_method": None,
    "checkpoint": None,
    "last_error": None,
    "retry_count": 0,
    "team_available": False,
    "agentation": {
        "active": False,
        "session_id": None,
        "keyword_used": None,
        "submit_gate_status": "idle",
        "submit_signal": None,
        "submit_received_at": None,
        "submitted_annotation_count": 0,
        "started_at": None,
        "timeout_seconds": 120,
        "annotations": {"total": 0, "pending": 0, "acknowledged": 0, "resolved": 0, "dismissed": 0},
        "completed_at": None,
        "exit_reason": None
    },
    "created_at": now,
    "updated_at": now
}
os.makedirs(os.path.join(git_root, ".omc", "state"), exist_ok=True)
os.makedirs(os.path.join(git_root, ".omc", "plans"), exist_ok=True)
state_path = os.path.join(git_root, ".omc", "state", "jeo-state.json")
with open(state_path, "w") as f:
    json.dump(state, f, indent=2)
print(f"✓ Fresh JEO state initialized at {state_path} (phase: plan)")
PYEOF
  elif [[ -z "${1:-}" ]]; then
    echo "     Tip: Run with --resume to initialize state for a new session"
  fi
fi

# Worktrees
WORKTREE_COUNT=$(git worktree list 2>/dev/null | wc -l | tr -d ' ') || WORKTREE_COUNT=0
if [[ "$WORKTREE_COUNT" -gt 1 ]]; then
  warn "Active worktrees: $WORKTREE_COUNT (run worktree-cleanup.sh when done)"
else
  ok "No extra worktrees active"
fi
echo ""

# ── Summary ────────────────────────────────────────────────────────────────────
echo "══════════════════════════════════════════"
echo -e "  ${GREEN}✓${NC} Passed: $PASS  ${YELLOW}⚠${NC}  Warnings: $WARN  ${RED}✗${NC} Failed: $FAIL"
echo "══════════════════════════════════════════"
echo ""

if [[ $FAIL -gt 0 ]]; then
  echo "Fix failures by running: bash scripts/install.sh --all"
elif [[ $WARN -gt 0 ]]; then
  echo "Run platform setup scripts to resolve warnings:"
  echo "  bash scripts/setup-claude.sh"
  echo "  bash scripts/setup-codex.sh"
  echo "  bash scripts/setup-gemini.sh"
  echo "  bash scripts/setup-opencode.sh"
else
  echo -e "${GREEN}All checks passed! JEO is fully configured.${NC}"
fi
echo ""

```

### scripts/setup-claude.sh

```bash
#!/usr/bin/env bash
# JEO Skill — Claude Code Plugin & Hook Setup
# Configures: omc plugin, plannotator hook, agentation MCP, jeo workflow in ~/.claude/settings.json
# Usage: bash setup-claude.sh [--dry-run]

set -euo pipefail

GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; RED='\033[0;31m'; NC='\033[0m'
ok()   { echo -e "${GREEN}✓${NC} $*"; }
warn() { echo -e "${YELLOW}⚠${NC}  $*"; }
err()  { echo -e "${RED}✗${NC} $*"; }
info() { echo -e "${BLUE}→${NC} $*"; }

DRY_RUN=false
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true

# Resolve JEO_SKILL_DIR: prefer stable canonical paths over script location
# to ensure hooks survive reinstalls regardless of where this script is run from.
_SCRIPT_JEO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
_CANONICAL_JEO="${HOME}/.agent-skills/jeo"      # canonical (setup-all-skills Step 4 rsync target)
_GLOBAL_JEO="${HOME}/.agents/skills/jeo"        # npx skills add -g actual install path

if [[ -d "${_CANONICAL_JEO}/scripts" ]]; then
  JEO_SKILL_DIR="${_CANONICAL_JEO}"
elif [[ -d "${_GLOBAL_JEO}/scripts" ]]; then
  JEO_SKILL_DIR="${_GLOBAL_JEO}"
  warn "Using global install path: ${JEO_SKILL_DIR}"
  warn "Run Step 4 rsync from setup-all-skills-prompt.md to sync to canonical path."
else
  JEO_SKILL_DIR="${_SCRIPT_JEO}"
  warn "jeo not at canonical path. Using script location: ${JEO_SKILL_DIR}"
  warn "Hooks will break if this location changes. Run setup-all-skills-prompt.md Step 4 to fix."
fi
CLAUDE_SETTINGS="${HOME}/.claude/settings.json"
LEGACY_OMG_DIR="${HOME}/.agent-skills/omg/scripts"

sync_legacy_python_compat() {
  local source_script="$1"
  local legacy_target="$2"

  mkdir -p "$(dirname "$legacy_target")"

  if ln -sfn "$source_script" "$legacy_target" 2>/dev/null; then
    ok "Legacy omg shim linked: $legacy_target"
    return
  fi

  cp "$source_script" "$legacy_target"
  chmod +x "$legacy_target"
  ok "Legacy omg shim copied: $legacy_target"
}

echo ""
echo "JEO — Claude Code Setup"
echo "========================"

# ── 1. Check Claude Code ──────────────────────────────────────────────────────
if ! command -v claude >/dev/null 2>&1; then
  warn "claude CLI not found. Install Claude Code first."
  echo ""
  echo "Plugin installation (run inside Claude Code session):"
  echo "  /plugin marketplace add https://github.com/Yeachan-Heo/oh-my-claudecode"
  echo "  /plugin install oh-my-claudecode"
  echo "  /omc:omc-setup"
  echo ""
  echo "plannotator plugin:"
  echo "  /plugin marketplace add backnotprop/plannotator"
  echo "  /plugin install plannotator@plannotator"
else
  ok "claude CLI found"
fi

# ── 2. Configure ~/.claude/settings.json ─────────────────────────────────────
info "Configuring ~/.claude/settings.json..."

mkdir -p "$(dirname "$CLAUDE_SETTINGS")"

if [[ -f "$CLAUDE_SETTINGS" ]]; then
  if ! $DRY_RUN; then
    cp "$CLAUDE_SETTINGS" "${CLAUDE_SETTINGS}.jeo.bak"
    ok "Backup created: ${CLAUDE_SETTINGS}.jeo.bak"
  fi
fi

if $DRY_RUN; then
  echo -e "${YELLOW}[DRY-RUN]${NC} Would sync plannotator hook, agent teams, agentation MCP, and UserPromptSubmit hook in $CLAUDE_SETTINGS"
else
  JEO_SKILL_DIR="$JEO_SKILL_DIR" python3 - <<'PYEOF'
import json
import os

settings_path = os.path.expanduser("~/.claude/settings.json")
jeo_skill_dir = os.environ["JEO_SKILL_DIR"]
plan_gate_cmd = f'python3 "{jeo_skill_dir}/scripts/claude-plan-gate.py"'
agentation_cmd = f'python3 "{jeo_skill_dir}/scripts/claude-agentation-submit-hook.py"'

try:
    with open(settings_path) as f:
        settings = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
    settings = {}

changed = False
messages = []

hooks = settings.setdefault("hooks", {})
perm_req = hooks.setdefault("PermissionRequest", [])
plannotator_entry = next((entry for entry in perm_req if entry.get("matcher") == "ExitPlanMode"), None)
if plannotator_entry is None:
    plannotator_entry = {"matcher": "ExitPlanMode", "hooks": []}
    perm_req.append(plannotator_entry)
    changed = True

plan_hooks = plannotator_entry.setdefault("hooks", [])

# Remove raw `plannotator` entries — claude-plan-gate.py calls plannotator
# internally, so keeping both causes plannotator to launch twice.
raw_plannotator = [
    h for h in plan_hooks
    if h.get("command", "").strip() == "plannotator"
]
if raw_plannotator:
    plan_hooks[:] = [h for h in plan_hooks if h not in raw_plannotator]
    changed = True
    messages.append(f"✓ Removed {len(raw_plannotator)} duplicate raw plannotator hook(s) from ExitPlanMode")

existing_plan_hook = next(
    (
        h for h in plan_hooks
        if "claude-plan-gate.py" in h.get("command", "")
    ),
    None,
)
duplicate_plan_hooks = [
    h for h in plan_hooks
    if h is not existing_plan_hook and "claude-plan-gate.py" in h.get("command", "")
]
if duplicate_plan_hooks:
    plan_hooks[:] = [h for h in plan_hooks if h not in duplicate_plan_hooks]
    changed = True
    messages.append(f"✓ Removed {len(duplicate_plan_hooks)} duplicate plan gate hook(s)")
if existing_plan_hook is None:
    plan_hooks.append({
        "type": "command",
        "command": plan_gate_cmd,
        "timeout": 1800,
    })
    changed = True
    messages.append("✓ JEO plan gate wrapper added to ExitPlanMode")
else:
    if existing_plan_hook.get("command") != plan_gate_cmd:
        existing_plan_hook["command"] = plan_gate_cmd
        changed = True
    if existing_plan_hook.get("timeout") != 1800:
        existing_plan_hook["timeout"] = 1800
        changed = True
    messages.append("✓ JEO plan gate wrapper synced")

env = settings.setdefault("env", {})
if env.get("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS") != "1":
    env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1"
    changed = True
    messages.append("✓ Experimental agent teams enabled")
else:
    messages.append("✓ Experimental agent teams already enabled")

mcp_servers = settings.setdefault("mcpServers", {})
if "agentation" not in mcp_servers:
    mcp_servers["agentation"] = {
        "command": "npx",
        "args": ["-y", "agentation-mcp", "server"],
    }
    changed = True
    messages.append("✓ agentation MCP server registered")
else:
    messages.append("✓ agentation MCP already registered")

user_prompt = hooks.setdefault("UserPromptSubmit", [])

# Migrate old-format entries (flat {"type":"command",...}) to new matcher format
migrated = False
new_user_prompt = []
for entry in user_prompt:
    if "matcher" in entry and "hooks" in entry:
        new_user_prompt.append(entry)
    elif entry.get("type") == "command":
        # Old format → wrap in new matcher format
        new_user_prompt.append({"matcher": "*", "hooks": [entry]})
        migrated = True
    else:
        new_user_prompt.append(entry)
if migrated:
    hooks["UserPromptSubmit"] = new_user_prompt
    user_prompt = new_user_prompt
    changed = True
    messages.append("✓ UserPromptSubmit hooks migrated to new matcher format")

def is_agentation_submit_hook(command):
    return (
        "claude-agentation-submit-hook.py" in command
        or command.startswith("curl -sf --connect-timeout 1 http://localhost:4747")
    )

agentation_matches = []
for entry in user_prompt:
    if not isinstance(entry, dict):
        continue
    hook_list = entry.setdefault("hooks", [])
    for hook in list(hook_list):
        if is_agentation_submit_hook(hook.get("command", "")):
            agentation_matches.append((entry, hook_list, hook))

if not agentation_matches:
    user_prompt.append({
        "matcher": "*",
        "hooks": [{"type": "command", "command": agentation_cmd, "timeout": 300}],
    })
    changed = True
    messages.append("✓ agentation submit-gate hook added")
else:
    agentation_entry, hook_list, target_hook = agentation_matches[0]
    removed_duplicates = 0
    for duplicate_entry, duplicate_hook_list, duplicate_hook in agentation_matches[1:]:
        duplicate_hook_list.remove(duplicate_hook)
        removed_duplicates += 1
        changed = True
    if removed_duplicates:
        messages.append(f"✓ Removed {removed_duplicates} duplicate legacy agentation hook(s)")

    if target_hook.get("command") != agentation_cmd:
        target_hook["command"] = agentation_cmd
        changed = True
    if target_hook.get("timeout") != 300:
        target_hook["timeout"] = 300
        changed = True

    if agentation_entry.get("matcher") != "*":
        agentation_entry["matcher"] = "*"
        changed = True

    pruned_user_prompt = []
    for entry in user_prompt:
        if not isinstance(entry, dict):
            pruned_user_prompt.append(entry)
            continue
        if entry.get("hooks"):
            pruned_user_prompt.append(entry)
            continue
        changed = True
    if pruned_user_prompt != user_prompt:
        hooks["UserPromptSubmit"] = pruned_user_prompt
        user_prompt = pruned_user_prompt
    else:
        hooks["UserPromptSubmit"] = user_prompt
    messages.append("✓ agentation submit-gate hook synced")

if changed or not os.path.exists(settings_path):
    os.makedirs(os.path.dirname(settings_path), exist_ok=True)
    with open(settings_path, "w") as f:
        json.dump(settings, f, indent=2)

for message in messages:
    print(message)
PYEOF
  ok "Claude Code settings synced"
fi

if $DRY_RUN; then
  echo -e "${YELLOW}[DRY-RUN]${NC} Would create legacy omg compatibility shims in ${LEGACY_OMG_DIR}"
else
  sync_legacy_python_compat \
    "${JEO_SKILL_DIR}/scripts/claude-agentation-submit-hook.py" \
    "${LEGACY_OMG_DIR}/claude-agentation-submit-hook.py"
  sync_legacy_python_compat \
    "${JEO_SKILL_DIR}/scripts/claude-plan-gate.py" \
    "${LEGACY_OMG_DIR}/claude-plan-gate.py"
fi

# ── 3. Instructions ───────────────────────────────────────────────────────────
echo ""
echo "Manual plugin installation (run inside Claude Code):"
echo ""
echo "  # Install oh-my-claudecode (omc)"
echo "  /plugin marketplace add https://github.com/Yeachan-Heo/oh-my-claudecode"
echo "  /plugin install oh-my-claudecode"
echo "  /omc:omc-setup"
echo ""
echo "  # Install plannotator"
echo "  /plugin marketplace add backnotprop/plannotator"
echo "  /plugin install plannotator@plannotator"
echo ""
echo "  # Then restart Claude Code"
echo ""
ok "Claude Code setup complete"
echo "  IMPORTANT: Restart Claude Code to activate all hooks and plugins"
echo "  JEO requires /omc:team execution in Claude Code. Verify CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 after restart."
echo ""

```

### scripts/setup-codex.sh

```bash
#!/usr/bin/env bash
# JEO Skill — Codex CLI Setup
# Configures: developer_instructions + agentation MCP in ~/.codex/config.toml + /prompts:jeo
# Usage: bash setup-codex.sh [--dry-run]

set -euo pipefail

GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; RED='\033[0;31m'; NC='\033[0m'
ok()   { echo -e "${GREEN}✓${NC} $*"; }
warn() { echo -e "${YELLOW}⚠${NC}  $*"; }
info() { echo -e "${BLUE}→${NC} $*"; }

DRY_RUN=false
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true

JEO_SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

CODEX_CONFIG="${HOME}/.codex/config.toml"
CODEX_PROMPTS_DIR="${HOME}/.codex/prompts"
JEO_PROMPT_FILE="${CODEX_PROMPTS_DIR}/jeo.md"

echo ""
echo "JEO — Codex CLI Setup"
echo "======================"

# ── 1. Check Codex CLI ────────────────────────────────────────────────────────
if ! command -v codex >/dev/null 2>&1; then
  warn "codex CLI not found. Install via: npm install -g @openai/codex"
fi

# ── 2. Configure ~/.codex/config.toml ────────────────────────────────────────
info "Configuring ~/.codex/config.toml..."

HOOK_DIR="${HOME}/.codex/hooks"
HOOK_FILE="${HOOK_DIR}/jeo-notify.py"

if $DRY_RUN; then
  echo -e "${YELLOW}[DRY-RUN]${NC} Would create/update $CODEX_CONFIG"
  echo -e "${YELLOW}[DRY-RUN]${NC} Would create $JEO_PROMPT_FILE"
else
  mkdir -p "$(dirname "$CODEX_CONFIG")" "$CODEX_PROMPTS_DIR"

  # Backup existing config
  [[ -f "$CODEX_CONFIG" ]] && cp "$CODEX_CONFIG" "${CODEX_CONFIG}.jeo.bak"

  JEO_INSTRUCTION=$(cat <<JEOEOF
# JEO Orchestration Workflow
# Keyword: jeo | Platforms: Codex, Claude, Gemini, OpenCode
#
# JEO provides integrated AI orchestration:
#   1. PLAN: ralph+plannotator for visual plan review
#   2. EXECUTE: team (if available) or bmad workflow
#   3. VERIFY: agent-browser snapshot for UI verification
#   4. CLEANUP: auto worktree cleanup after completion
#
# Trigger with: jeo "<task description>"
# Use /prompts:jeo for full workflow activation
#
# PLAN phase protocol (Codex):
#   1. Write plan to plan.md
#   2. Run mandatory PLAN gate (auto-installs plannotator if missing, blocks for feedback/approve, retries dead sessions up to 3):
#      bash ${JEO_SKILL_DIR}/scripts/plannotator-plan-loop.sh plan.md /tmp/plannotator_feedback.txt 3
#      Rule: if the current plan hash already has a terminal gate result
#      (approved/manual_approved/feedback_required/infrastructure_blocked), do not reopen plannotator.
#      Only a revised plan.md resets the gate to pending.
#   3. Output "PLAN_READY" to trigger notify hook as backup signal
#   4. Check result:
#      - approved=true -> EXECUTE
#      - approved=false -> re-plan
#      - exit 32 -> localhost bind blocked (sandbox/CI). run PLAN gate in local TTY
#   5. Never emit PLAN_READY in execute/verify phases.
#
# VERIFY_UI submit gate protocol:
#   1. Enter verify_ui and set agentation.submit_gate_status="waiting_for_submit"
#   2. Wait until the user clicks Send Annotations / onSubmit in agentation
#   3. Only then emit "ANNOTATE_READY" (or "AGENTUI_READY" for compatibility)
#   4. notify hook may fetch /pending only after that explicit submit signal
#
# BMAD commands (fallback when team unavailable):
#   /workflow-init   — initialize BMAD workflow
#   /workflow-status — check current BMAD phase
#
# Tools: agent-browser, playwriter, plannotator
JEOEOF
)

  python3 - <<PYEOF
import re, os

config_path = os.path.expanduser("~/.codex/config.toml")
jeo_instruction = """${JEO_INSTRUCTION}"""

try:
    content = open(config_path).read() if os.path.exists(config_path) else ""
except Exception:
    content = ""

content = re.sub(r'(?ms)^\[developer_instructions\]\s*\n.*?(?=^\[|\Z)', '', content).strip() + "\n"
# Only strip legacy bare JEO block if it is NOT inside a developer_instructions value
if not re.search(r'(?ms)^developer_instructions\s*=\s*"""', content):
    content = re.sub(r'(?ms)^# JEO Orchestration Workflow\n.*?^"""\s*\n', '', content)

def parse_existing_instructions(text: str) -> str:
    m = re.search(r'(?ms)^developer_instructions\s*=\s*"""\n?(.*?)\n?"""\s*$', text)
    if m:
        return m.group(1)

    m = re.search(r'(?m)^developer_instructions\s*=\s*"(.*)"\s*$', text)
    if m:
        return bytes(m.group(1), "utf-8").decode("unicode_escape")

    return ""

existing = parse_existing_instructions(content)
jeo_pattern = re.compile(
    r'(?ms)^# JEO Orchestration Workflow\n.*?^# Tools: agent-browser, playwriter, plannotator\s*$'
)
if jeo_pattern.search(existing):
    merged = jeo_pattern.sub(jeo_instruction.strip(), existing).strip()
else:
    merged = (existing.rstrip() + "\n\n" if existing.strip() else "") + jeo_instruction.strip()

new_assignment = 'developer_instructions = """\n' + merged + '\n"""\n'

if re.search(r'(?m)^developer_instructions\s*=', content):
    content = re.sub(r'(?ms)^developer_instructions\s*=\s*(""".*?"""|".*?")\s*$', new_assignment, content, count=1)
else:
    first_table = re.search(r'(?m)^\[', content)
    if first_table:
        content = content[:first_table.start()] + new_assignment + "\n" + content[first_table.start():]
    else:
        content = new_assignment + "\n" + content

with open(config_path, "w") as f:
    f.write(content)

# Post-write: validate TOML and auto-fix stray standalone " lines
try:
    import tomllib as _toml
    with open(config_path, "rb") as f:
        _toml.load(f)
except Exception:
    with open(config_path) as f:
        raw = f.read()
    cleaned = re.sub(r'(?m)^"$\n', '', raw)
    with open(config_path, "w") as f:
        f.write(cleaned)

print("✓ JEO developer_instructions synced (top-level string)")
PYEOF
  ok "JEO developer_instructions synced in ~/.codex/config.toml"

  # ── 3. Create /prompts:jeo prompt file ──────────────────────────────────────
  cat > "$JEO_PROMPT_FILE" <<'PROMPTEOF'
# JEO — Integrated Agent Orchestration Prompt

You are now operating in **JEO mode** — Integrated AI Agent Orchestration.

## Your Workflow

### Step 1: PLAN (plannotator — blocking loop)
Before writing any code, create and review a plan:
1. Write a detailed implementation plan in \`plan.md\` (objectives, steps, risks, acceptance criteria)
2. Run plannotator PLAN gate (blocking, mandatory; auto-installs plannotator if missing):
   \`\`\`bash
   bash .agent-skills/jeo/scripts/plannotator-plan-loop.sh plan.md /tmp/plannotator_feedback.txt 3
   echo "PLAN_READY"
   \`\`\`
   Same-hash guard:
   - if \`plan_gate_status\` is already terminal for the current plan hash, do not reopen plannotator
   - only a modified plan resets the gate to \`pending\`
3. Read /tmp/plannotator_feedback.txt
4. If \`"approved":true\` → proceed to EXECUTE
5. If NOT approved → read annotations, revise plan.md, repeat from step 2
6. **If PLAN gate exits with 32 → CONVERSATION APPROVAL MODE (MANDATORY)**:
   - Output the full contents of \`plan.md\` to the user in the conversation
   - Ask explicitly: "⚠️ plannotator UI unavailable in this environment. Please review the plan above and reply 'approve' to proceed, or provide feedback."
   - **STOP and wait for user response. Do NOT proceed to EXECUTE until user approves.**
   - On 'approve'/'yes'/'ok': update \`jeo-state.json\` \`plan_approved=true, plan_gate_status="manual_approved"\` → EXECUTE
   - On feedback text: revise \`plan.md\`, retry \`plannotator-plan-loop.sh\`, repeat
NEVER skip plannotator. NEVER proceed to EXECUTE without approved=true or explicit user approval in conversation.

### Step 2: EXECUTE (BMAD workflow for Codex)
Use BMAD structured phases:
- \`/workflow-init\` — Initialize BMAD for this project
- Analysis phase: understand requirements fully
- Planning phase: detailed technical plan
- Solutioning phase: architecture decisions
- Implementation phase: write code

### Step 3: VERIFY (agent-browser)
If the task has browser UI:
- Run: \`agent-browser snapshot http://localhost:3000\`
- Check UI elements via accessibility tree (-i flag)
- Save screenshot: \`agent-browser screenshot <url> -o verify.png\`

### Step 3.1: VERIFY_UI (agentation submit gate)
If \`annotate\` / \`agentui\` is requested:
1. Update \`.omc/state/jeo-state.json\` to \`phase="verify_ui"\`
2. Set \`agentation.submit_gate_status="waiting_for_submit"\`
3. Wait until the human actually clicks **Send Annotations** / triggers \`onSubmit\`
4. Only then emit \`ANNOTATE_READY\`
5. Process agentation annotations after the notify hook reports submitted items
Never read \`/pending\` before the submit gate opens.

### Step 4: CLEANUP (worktree)
After all tasks complete:
- Run: git worktree prune
- Run: bash ${JEO_SKILL_DIR}/scripts/worktree-cleanup.sh

## Key Commands
- Plan review — run plannotator BLOCKING (no &), then output PLAN_READY:
  Mandatory behavior:
  - If plannotator is missing, the PLAN gate auto-runs \`ensure-plannotator.sh\` first
  - Wait for approve/feedback every time
  - If session dies, restart up to 3 times
  - After 3 dead sessions, stop and ask whether PLAN should be terminated
  - If loop exits 32: localhost bind blocked. use local TTY/manual PLAN gate
  \`\`\`bash
  bash .agent-skills/jeo/scripts/plannotator-plan-loop.sh plan.md /tmp/plannotator_feedback.txt 3
  # Output PLAN_READY to trigger notify hook as backup signal
  echo "PLAN_READY"
  # Check result
  python3 -c "
import json, sys
try:
    d = json.load(open('/tmp/plannotator_feedback.txt'))
    sys.exit(0 if d.get('approved') is True else 1)
except Exception:
    sys.exit(1)
" && echo "PLAN_APPROVED — proceed to EXECUTE" || cat /tmp/plannotator_feedback.txt
  \`\`\`
- Browser verify: \`agent-browser snapshot http://localhost:3000\`
- BMAD init: \`/workflow-init\`
- Worktree cleanup: \`bash ${JEO_SKILL_DIR}/scripts/worktree-cleanup.sh\`

## State File
Save progress to: \`.omc/state/jeo-state.json\`
\`\`\`json
{
  "phase": "plan|execute|verify|verify_ui|cleanup|done",
  "task": "current task description",
  "plan_approved": false,
  "plan_gate_status": "pending",
  "plan_current_hash": null,
  "last_reviewed_plan_hash": null,
  "plan_review_method": null,
  "team_available": false,
  "retry_count": 0,
  "last_error": null,
  "checkpoint": null,
  "agentation": {
    "submit_gate_status": "idle",
    "submit_signal": null,
    "submit_received_at": null,
    "submitted_annotation_count": 0
  }
}
\`\`\`

Always check state file on resume to continue from last phase.
PROMPTEOF

  # Fix absolute paths in prompt file (replace relative .agent-skills paths with absolute JEO_SKILL_DIR)
  sed -i.bak \
    -e "s|bash .agent-skills/jeo/scripts/plannotator-plan-loop.sh|bash ${JEO_SKILL_DIR}/scripts/plannotator-plan-loop.sh|g" \
    -e "s|\\\${JEO_SKILL_DIR}|${JEO_SKILL_DIR}|g" \
    "$JEO_PROMPT_FILE"
  rm -f "${JEO_PROMPT_FILE}.bak"
  ok "JEO prompt file created: $JEO_PROMPT_FILE"

  # ── 4. Create plannotator notify hook ────────────────────────────────────────
  info "Setting up plannotator notify hook..."
  mkdir -p "$HOOK_DIR"

  cat > "$HOOK_FILE" << 'HOOKEOF'
#!/usr/bin/env python3
"""JEO Codex notify hook — detects PLAN_READY / ANNOTATE_READY and triggers plannotator / agentation."""
import hashlib, json, os, re, subprocess, sys, urllib.request, urllib.error, time

# Exact signal strings (matched as standalone lines, allowing surrounding whitespace)
PLAN_SIGNALS = ["PLAN_READY"]
ANNOTATE_SIGNALS = ["ANNOTATE_READY", "AGENTUI_READY"]
PLAN_TERMINAL_STATUSES = {"approved", "manual_approved", "feedback_required", "infrastructure_blocked"}

def get_jeo_phase(cwd: str) -> str:
    """Read current JEO phase from state file. Returns empty string if not available."""
    state_path = os.path.join(cwd, ".omc", "state", "jeo-state.json")
    try:
        with open(state_path) as f:
            return json.load(f).get("phase", "")
    except (FileNotFoundError, json.JSONDecodeError, KeyError):
        return ""


def get_state_path(cwd: str) -> str:
    return os.path.join(cwd, ".omc", "state", "jeo-state.json")


def load_state(cwd: str) -> dict:
    state_path = get_state_path(cwd)
    try:
        with open(state_path) as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return {}


def update_state(cwd: str, mutator) -> None:
    state_path = get_state_path(cwd)
    if not os.path.exists(state_path):
        return
    try:
        import fcntl, datetime

        with open(state_path, "r+") as fh:
            fcntl.flock(fh, fcntl.LOCK_EX)
            try:
                try:
                    data = json.load(fh)
                except Exception:
                    data = {}
                mutator(data)
                data["updated_at"] = datetime.datetime.utcnow().isoformat() + "Z"
                fh.seek(0)
                json.dump(data, fh, indent=2)
                fh.truncate()
            finally:
                fcntl.flock(fh, fcntl.LOCK_UN)
    except Exception:
        pass

def get_feedback_file(cwd: str) -> str:
    """Return session-isolated feedback file path based on cwd MD5."""
    _session_key = hashlib.md5(cwd.encode()).hexdigest()[:8]
    feedback_dir = f"/tmp/jeo-{_session_key}"
    os.makedirs(feedback_dir, exist_ok=True)
    return os.path.join(feedback_dir, "plannotator_feedback.txt")


def get_plannotator_env(cwd: str) -> dict:
    _session_key = hashlib.md5(cwd.encode()).hexdigest()[:8]
    runtime_home = f"/tmp/jeo-{_session_key}/.plannotator"
    os.makedirs(runtime_home, exist_ok=True)
    env = os.environ.copy()
    env["HOME"] = runtime_home
    env["PLANNOTATOR_HOME"] = runtime_home
    return env


def get_plan_loop_script(cwd: str):
    candidates = [
        os.path.join(cwd, ".agent-skills", "jeo", "scripts", "plannotator-plan-loop.sh"),
        os.path.expanduser("~/.codex/skills/jeo/scripts/plannotator-plan-loop.sh"),
        os.path.expanduser("~/.agent-skills/jeo/scripts/plannotator-plan-loop.sh"),
    ]
    for p in candidates:
        if os.path.exists(p):
            return p
    return None


def compute_plan_hash(plan_path: str) -> str:
    try:
        with open(plan_path, "rb") as fh:
            return hashlib.sha256(fh.read()).hexdigest()
    except Exception:
        return ""


def should_skip_plan_gate(cwd: str, plan_path: str) -> bool:
    state = load_state(cwd)
    current_hash = compute_plan_hash(plan_path)
    if not current_hash:
        return False
    return (
        state.get("phase") == "plan"
        and state.get("plan_gate_status") in PLAN_TERMINAL_STATUSES
        and state.get("last_reviewed_plan_hash") == current_hash
    )

def write_plan_gate_result(cwd: str, rc: int, feedback_file: str) -> None:
    """Write plannotator gate result to jeo-state.json."""
    state_path = os.path.join(cwd, '.omc', 'state', 'jeo-state.json')
    if not os.path.exists(state_path):
        return
    try:
        import fcntl, datetime
        with open(state_path, 'r+') as fh:
            fcntl.flock(fh, fcntl.LOCK_EX)
            try:
                d = json.load(fh)
                if rc == 0:
                    d['plan_approved'] = True
                    d['phase'] = 'execute'
                    d['plan_gate_status'] = 'approved'
                elif rc == 10:
                    d['plan_approved'] = False
                    d['plan_gate_status'] = 'feedback_required'
                    try:
                        d['plannotator_feedback'] = json.load(open(feedback_file))
                    except Exception:
                        pass
                elif rc == 32:
                    d['plan_gate_status'] = 'infrastructure_blocked'
                    d['last_error'] = 'localhost bind unavailable (sandbox/CI)'
                d['updated_at'] = datetime.datetime.utcnow().isoformat() + 'Z'
                fh.seek(0); json.dump(d, fh, indent=2); fh.truncate()
            finally:
                fcntl.flock(fh, fcntl.LOCK_UN)
    except Exception:
        pass


def main() -> int:
    try:
        notification = json.loads(sys.argv[1])
    except (IndexError, json.JSONDecodeError):
        return 0

    if notification.get("type") != "agent-turn-complete":
        return 0

    msg = notification.get("last-assistant-message", "").strip()
    cwd = notification.get("cwd", os.getcwd())
    phase = get_jeo_phase(cwd)

    # PLAN_READY: trigger plannotator (only during plan phase)
    if phase in ("plan",):
        if any(re.search(rf'(?m)^{re.escape(sig)}\s*$', msg or '') for sig in PLAN_SIGNALS):
            plan_candidates = ["plan.md", ".omc/plans/jeo-plan.md", "docs/plan.md"]
            plan_path = None
            for candidate in plan_candidates:
                p = os.path.join(cwd, candidate)
                if os.path.exists(p):
                    plan_path = p
                    break
            if plan_path is None:
                print("[JEO] plan.md not found in known locations")
                return 0
            if should_skip_plan_gate(cwd, plan_path):
                state = load_state(cwd)
                print(
                    f"[JEO] plan gate skipped: current hash already reviewed ({state.get('plan_gate_status', 'unknown')})"
                )
                return 0
            feedback_file = get_feedback_file(cwd)
            loop_script = get_plan_loop_script(cwd)
            if loop_script:
                result = subprocess.run(
                    ["bash", loop_script, plan_path, feedback_file, "3"],
                    cwd=cwd,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True,
                    env=get_plannotator_env(cwd),
                )
                if result.stdout:
                    print(result.stdout.strip())
                write_plan_gate_result(cwd, result.returncode, feedback_file)
                if result.returncode == 32:
                    print("[JEO] plannotator unavailable: localhost bind blocked (sandbox/CI).")
                    print("[JEO] CONVERSATION APPROVAL MODE: agent must output plan.md and request explicit user approval.")
                print(f"[JEO] plannotator loop result code={result.returncode} feedback={feedback_file}")
            else:
                plan_content = open(plan_path).read()
                payload = json.dumps({"tool_input": {"plan": plan_content, "permission_mode": "acceptEdits"}})
                try:
                    with open(feedback_file, "w") as f:
                        subprocess.run(["plannotator"], input=payload, stdout=f, stderr=f, text=True, env=get_plannotator_env(cwd))
                    print(f"[JEO] plannotator feedback \u2192 {feedback_file}")
                except FileNotFoundError:
                    print("[JEO] plannotator not found \u2014 skipping")
            return 0

    # ANNOTATE_READY: poll agentation HTTP API only after explicit submit signal in VERIFY_UI
    if phase == "verify_ui":
        if any(re.search(rf'(?m)^{re.escape(sig)}\s*$', msg or '') for sig in ANNOTATE_SIGNALS):
            state = load_state(cwd)
            agentation = state.get("agentation", {})
            if agentation.get("submit_gate_status") == "submitted":
                print("[JEO] agentation submit gate already opened for this turn")
            else:
                def mark_submitted(data):
                    agentation_state = data.setdefault("agentation", {})
                    agentation_state["submit_gate_status"] = "submitted"
                    agentation_state["submit_signal"] = "codex-notify"
                    agentation_state["submit_received_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())

                update_state(cwd, mark_submitted)
            base_url = "http://localhost:4747"
            try:
                with urllib.request.urlopen(f"{base_url}/pending", timeout=2) as r:
                    data = json.loads(r.read())
                count = data.get("count", 0)
                annotations = data.get("annotations", [])
                def record_count(state_data):
                    agentation_state = state_data.setdefault("agentation", {})
                    agentation_state["submitted_annotation_count"] = count
                    if count == 0:
                        agentation_state["submit_gate_status"] = "waiting_for_submit"
                update_state(cwd, record_count)
                if count == 0:
                    print("[JEO] agentation: no pending annotations")
                else:
                    print(f"[JEO] agentation: {count} pending annotations")
                    for ann in annotations:
                        sev = ann.get("severity", "suggestion")
                        print(f"  [{sev}] {ann.get('element','?')} | {ann.get('comment','')[:80]}")
                        print(f"    elementPath: {ann.get('elementPath','?')}")
            except (urllib.error.URLError, Exception) as e:
                print(f"[JEO] agentation server not reachable ({base_url}): {e}")
            return 0

    return 0
if __name__ == "__main__":
    sys.exit(main())
HOOKEOF

  chmod +x "$HOOK_FILE"
  # Inject absolute JEO script path as first candidate in get_plan_loop_script()
  sed -i.bak \
    "s|os.path.join(cwd, \".agent-skills\", \"jeo\", \"scripts\", \"plannotator-plan-loop.sh\")|\"${JEO_SKILL_DIR}/scripts/plannotator-plan-loop.sh\",\n        os.path.join(cwd, \".agent-skills\", \"jeo\", \"scripts\", \"plannotator-plan-loop.sh\")|" \
    "$HOOK_FILE"
  rm -f "${HOOK_FILE}.bak"
  ok "JEO notify hook created: $HOOK_FILE"

  # Add notify + tui to config.toml
  python3 - <<PYEOF
import re, os

config_path = os.path.expanduser("~/.codex/config.toml")
hook_path = os.path.expanduser("~/.codex/hooks/jeo-notify.py")

try:
    content = open(config_path).read() if os.path.exists(config_path) else ""
except Exception:
    content = ""

notify_line = f'notify = ["python3", "{hook_path}"]\n'
if re.search(r'(?m)^notify\s*=', content):
    content = re.sub(r'(?m)^notify\s*=.*$', notify_line.rstrip(), content, count=1)
    print("✓ notify hook synced in config.toml")
else:
    first_table = re.search(r'^\[', content, re.MULTILINE)
    if first_table:
        content = content[:first_table.start()] + notify_line + "\n" + content[first_table.start():]
    else:
        content = notify_line + content
    print("✓ notify hook registered in config.toml")

# Add agentation [[mcp_servers]] if missing (check both array-of-tables and table formats)
if not re.search(r'(?ms)^\[\[mcp_servers\]\]\s*\nname\s*=\s*"agentation"\s*\n', content) and \
   not re.search(r'(?m)^\[mcp_servers\.agentation\]', content):
    agentation_block = '\n[mcp_servers.agentation]\ncommand = "npx"\nargs = ["-y", "agentation-mcp", "server"]\n'
    content = content.rstrip() + agentation_block
    print("\u2713 agentation MCP server added to config.toml")
else:
    print("\u2713 agentation MCP already in config.toml")

# Add or sync [tui] section
tui_match = re.search(r'(?ms)^\[tui\]\s*\n(.*?)(?=^\[|\Z)', content)
if not tui_match:
    content = content.rstrip() + '\n\n[tui]\nnotifications = ["agent-turn-complete"]\nnotification_method = "osc9"\n'
    print("✓ [tui] section added")
else:
    tui_body = tui_match.group(1)
    notif_match = re.search(r'(?m)^notifications\s*=\s*\[(.*?)\]\s*$', tui_body)
    notifications = []
    if notif_match:
        notifications = re.findall(r'"([^"]+)"', notif_match.group(1))
    if "agent-turn-complete" not in notifications:
        notifications.append("agent-turn-complete")
    notifications_line = 'notifications = [' + ', '.join(f'"{item}"' for item in notifications) + ']'
    if notif_match:
        tui_body = re.sub(r'(?m)^notifications\s*=\s*\[(.*?)\]\s*$', notifications_line, tui_body, count=1)
    else:
        tui_body = notifications_line + '\n' + tui_body

    if re.search(r'(?m)^notification_method\s*=', tui_body):
        tui_body = re.sub(r'(?m)^notification_method\s*=.*$', 'notification_method = "osc9"', tui_body, count=1)
    else:
        tui_body = tui_body.rstrip() + '\nnotification_method = "osc9"\n'

    content = content[:tui_match.start(1)] + tui_body + content[tui_match.end(1):]
    print("✓ [tui] notifications synced")

with open(config_path, "w") as f:
    f.write(content)
PYEOF

  ok "Codex config.toml updated (notify hook + agentation MCP + tui)"
fi

echo ""
echo "Codex CLI usage after setup:"
echo "  /prompts:jeo             ← Activate JEO orchestration workflow"
echo "  notify hook: ~/.codex/hooks/jeo-notify.py"
echo "    fires on: PLAN_READY / ANNOTATE_READY signals in agent output (AGENTUI_READY also accepted)"
echo "    writes to: /tmp/plannotator_feedback.txt"
echo ""
ok "Codex CLI setup complete"
echo ""

```

### scripts/setup-gemini.sh

```bash
#!/usr/bin/env bash
# JEO Skill — Gemini CLI Hook & GEMINI.md Setup
# Configures: ExitPlanMode hook in ~/.gemini/settings.json + JEO instructions in GEMINI.md
# Usage: bash setup-gemini.sh [--dry-run] [--hook-only] [--md-only]

set -euo pipefail

GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; RED='\033[0;31m'; NC='\033[0m'
ok()   { echo -e "${GREEN}✓${NC} $*"; }
warn() { echo -e "${YELLOW}⚠${NC}  $*"; }
info() { echo -e "${BLUE}→${NC} $*"; }

DRY_RUN=false; HOOK_ONLY=false; MD_ONLY=false
for arg in "$@"; do
  case $arg in --dry-run) DRY_RUN=true ;; --hook-only) HOOK_ONLY=true ;; --md-only) MD_ONLY=true ;; esac
done

JEO_SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

GEMINI_SETTINGS="${HOME}/.gemini/settings.json"
GEMINI_MD="${HOME}/.gemini/GEMINI.md"

echo ""
echo "JEO — Gemini CLI Setup"
echo "======================"

# ── 1. Check Gemini CLI ───────────────────────────────────────────────────────
if ! command -v gemini >/dev/null 2>&1; then
  warn "gemini CLI not found. Install via: npm install -g @google/gemini-cli"
fi

# NOTE: Gemini CLI uses AfterAgent hook (not ExitPlanMode, which is Claude Code-only).
# The primary method is agent direct blocking call — do NOT use & (background).
# Manual blocking call (same-turn feedback, auto-installs plannotator if missing):
#   bash .agent-skills/jeo/scripts/plannotator-plan-loop.sh plan.md /tmp/plannotator_feedback.txt 3

# ── 2. Configure ~/.gemini/settings.json ─────────────────────────────────────
if ! $MD_ONLY; then
  info "Configuring ~/.gemini/settings.json..."

  if $DRY_RUN; then
    echo -e "${YELLOW}[DRY-RUN]${NC} Would add AfterAgent hook to $GEMINI_SETTINGS"
  else
    mkdir -p "$(dirname "$GEMINI_SETTINGS")"
    [[ -f "$GEMINI_SETTINGS" ]] && cp "$GEMINI_SETTINGS" "${GEMINI_SETTINGS}.jeo.bak"

    # Create hook helper script (avoids plannotator plan - hanging on empty stdin)
    GEMINI_HOOK_DIR="${HOME}/.gemini/hooks"
    mkdir -p "$GEMINI_HOOK_DIR"
    cat > "${GEMINI_HOOK_DIR}/jeo-plannotator.sh" << 'HOOKEOF'
#!/usr/bin/env bash
# JEO AfterAgent backup hook — runs plannotator if plan.md exists in cwd
# Phase guard: only fire during PLAN phase to prevent conflict with agentation.
# Repeat guard: same plan hash + terminal gate status must not reopen plannotator.

JEO_STATE="${PWD}/.omc/state/jeo-state.json"
if [[ ! -f "$JEO_STATE" ]]; then
  exit 0  # JEO is not active — no state file
fi
PHASE=$(python3 -c "
import json, sys
try:
    d = json.load(open('$JEO_STATE'))
    print(d.get('phase', 'unknown'))
except Exception:
    print('unknown')
" 2>/dev/null || echo "unknown")

# 화이트리스트: "plan"일 때만 실행, 그 외(unknown, done, execute 등) 모두 종료
if [[ "$PHASE" != "plan" ]]; then
  exit 0
fi

# AfterAgent 이중 실행 방지: 에이전트가 직접 호출한 턴이면 건너뜀
LOCK_FILE="/tmp/jeo-plannotator-direct.lock"
if [[ -f "$LOCK_FILE" ]]; then
  rm -f "$LOCK_FILE"
  exit 0
fi

PLAN_FILE="$(pwd)/plan.md"
test -f "$PLAN_FILE" || exit 0
LOOP_SCRIPT_CANDIDATES=(
  "$(pwd)/.agent-skills/jeo/scripts/plannotator-plan-loop.sh"
  "$HOME/.codex/skills/jeo/scripts/plannotator-plan-loop.sh"
  "$HOME/.agent-skills/jeo/scripts/plannotator-plan-loop.sh"
)

LOOP_SCRIPT=""
for candidate in "${LOOP_SCRIPT_CANDIDATES[@]}"; do
  if [[ -f "$candidate" ]]; then
    LOOP_SCRIPT="$candidate"
    break
  fi
done

if [[ -n "$LOOP_SCRIPT" ]]; then
  set +e
  bash "$LOOP_SCRIPT" "$PLAN_FILE" /tmp/plannotator_feedback.txt 3
  LOOP_RC=$?
  set -e
  if [[ "$LOOP_RC" -eq 0 ]]; then
    echo "[JEO] plannotator approved=true (written to jeo-state.json)"
  elif [[ "$LOOP_RC" -eq 10 ]]; then
    echo "[JEO] plannotator approved=false — feedback written to jeo-state.json"
  elif [[ "$LOOP_RC" -eq 32 ]]; then
    echo "[JEO] plannotator unavailable: localhost bind blocked (sandbox/CI)." >&2
    echo "[JEO] run PLAN gate in local TTY to use manual fallback approve/feedback." >&2
  fi
else
  PLANNOTATOR_RUNTIME_HOME="/tmp/jeo-$(python3 -c "import hashlib,os; print(f'/tmp/jeo-{hashlib.md5(os.getcwd().encode()).hexdigest()[:8]}')")/.plannotator"
  mkdir -p "$PLANNOTATOR_RUNTIME_HOME"
  python3 -c "
import json, sys
plan = open(sys.argv[1]).read()
sys.stdout.write(json.dumps({'tool_input': {'plan': plan, 'permission_mode': 'acceptEdits'}}))
" "$PLAN_FILE" | env HOME="$PLANNOTATOR_RUNTIME_HOME" PLANNOTATOR_HOME="$PLANNOTATOR_RUNTIME_HOME" plannotator > /tmp/plannotator_feedback.txt 2>&1 || true
fi
HOOKEOF
    chmod +x "${GEMINI_HOOK_DIR}/jeo-plannotator.sh"

    # Create agentation AfterAgent hook (phase-guarded + submit-gated)
    cat > "${GEMINI_HOOK_DIR}/jeo-agentation.sh" << 'AGENTHOOKEOF'
#!/usr/bin/env bash
# JEO AfterAgent hook — check pending agentation annotations during VERIFY_UI phase
# Submit gate: only process annotations after explicit Send Annotations / onSubmit confirmation.

# Phase guard: only fire during verify_ui phase
JEO_STATE="${PWD}/.omc/state/jeo-state.json"
if [[ -f "$JEO_STATE" ]]; then
  PHASE=$(python3 -c "import json; print(json.load(open('$JEO_STATE')).get('phase',''))" 2>/dev/null || echo "")
  SUBMIT_GATE=$(python3 -c "import json; print(json.load(open('$JEO_STATE')).get('agentation',{}).get('submit_gate_status',''))" 2>/dev/null || echo "")
  if [[ "$PHASE" != "verify_ui" ]]; then
    exit 0
  fi
  if [[ "$SUBMIT_GATE" != "submitted" ]]; then
    exit 0
  fi
else
  exit 0  # No state file means JEO is not active
fi

# Check agentation server and report pending annotations
PENDING=$(curl -sf --connect-timeout 2 http://localhost:4747/pending 2>/dev/null) || exit 0
COUNT=$(echo "$PENDING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('count',0))" 2>/dev/null || echo 0)

if [ "$COUNT" -gt 0 ]; then
  echo "=== AGENTATION: ${COUNT} annotations pending ==="
  echo "$PENDING" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for i, a in enumerate(d.get('annotations', [])):
    sev = a.get('severity', 'suggestion')
    print(f'  [{i+1}] [{sev}] {a.get(\"element\",\"?\")} ({a.get(\"elementPath\",\"?\")[:60]})')
    print(f'      {a.get(\"comment\",\"\")[:80]}')
" 2>/dev/null
  echo "=== END ==="
fi
AGENTHOOKEOF
    chmod +x "${GEMINI_HOOK_DIR}/jeo-agentation.sh"

    python3 - <<PYEOF
import json, os

settings_path = os.path.expanduser("~/.gemini/settings.json")
hook_path = os.path.expanduser("~/.gemini/hooks/jeo-plannotator.sh")
try:
    with open(settings_path) as f:
        settings = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
    settings = {}

hooks = settings.setdefault("hooks", {})
# AfterAgent is the correct Gemini CLI hook (ExitPlanMode is Claude Code-only)
after_agent = hooks.setdefault("AfterAgent", [])

# Migrate old-format entries (flat {"type":"command",...}) to new matcher format
migrated = False
new_after_agent = []
for entry in after_agent:
    if "matcher" in entry and "hooks" in entry:
        # Ensure timeout on plannotator hooks (1800s for blocking UI wait)
        for h in entry.get("hooks", []):
            if "plannotator" in h.get("command", "") and "timeout" not in h:
                h["timeout"] = 1800
                migrated = True
        new_after_agent.append(entry)
    elif entry.get("type") == "command":
        # Old format -> wrap in new matcher format
        if "timeout" not in entry:
            entry["timeout"] = 300
        new_after_agent.append({"matcher": "", "hooks": [entry]})
        migrated = True
    else:
        new_after_agent.append(entry)
if migrated:
    hooks["AfterAgent"] = new_after_agent
    after_agent = new_after_agent
    with open(settings_path, "w") as f:
        json.dump(settings, f, indent=2)
    print("\\u2713 AfterAgent hooks migrated to new matcher format with timeouts")

# Check if jeo plannotator hook already exists (old or new form)
planno_exists = any(
    any(
        h.get("command", "").startswith("plannotator") or "jeo-plannotator" in h.get("command", "")
        for h in entry.get("hooks", [])
    )
    for entry in after_agent
)

if not planno_exists:
    after_agent.append({
        "matcher": "",
        "hooks": [{
            "name": "plannotator-review",
            "type": "command",
            "command": f"bash {hook_path}",
            "timeout": 1800,
            "description": "PLAN phase backup gate with no-repeat hash guard"
        }]
    })
    with open(settings_path, "w") as f:
        json.dump(settings, f, indent=2)
    print("✓ plannotator AfterAgent hook added to ~/.gemini/settings.json")
else:
    print("\u2713 plannotator hook already present")

# Add agentation AfterAgent hook (phase-guarded + submit-gated)
agentation_hook_path = os.path.expanduser("~/.gemini/hooks/jeo-agentation.sh")
agentation_exists = any(
    any(
        "jeo-agentation" in h.get("command", "")
        for h in entry.get("hooks", [])
    )
    for entry in after_agent
)
if not agentation_exists:
    after_agent.append({
        "matcher": "",
        "hooks": [{
            "name": "agentation-check",
            "type": "command",
            "command": f"bash {agentation_hook_path}",
            "timeout": 300,
            "description": "VERIFY_UI submit gate opened: check pending agentation annotations"
        }]
    })
    with open(settings_path, "w") as f:
        json.dump(settings, f, indent=2)
    print("\u2713 agentation AfterAgent hook added to ~/.gemini/settings.json")
else:
    print("\u2713 agentation hook already present")

# Add agentation MCP server if missing
mcp_servers = settings.setdefault("mcpServers", {})
if "agentation" not in mcp_servers:
    mcp_servers["agentation"] = {
        "command": "npx",
        "args": ["-y", "agentation-mcp", "server"]
    }
    with open(settings_path, "w") as f:
        json.dump(settings, f, indent=2)
    print("\u2713 agentation MCP server added to ~/.gemini/settings.json")
else:
    print("\u2713 agentation MCP already present")
PYEOF
    ok "Gemini CLI settings updated"
  fi
fi

# ── 3. Update GEMINI.md ───────────────────────────────────────────────────────
if ! $HOOK_ONLY; then
  info "Updating ~/.gemini/GEMINI.md..."

  JEO_SECTION='
## JEO Orchestration Workflow

Keyword: `jeo` | Tool: Gemini CLI

JEO provides integrated AI agent orchestration across all AI tools.

### Workflow Phases

**PLAN** (plannotator — 직접 blocking 호출 필수):
1. `plan.md` 작성 (목표, 단계, 리스크, 완료 기준 포함)
2. PLAN gate 실행 (& 절대 금지, plannotator 없으면 자동 설치 후 계속 진행):
  bash .agent-skills/jeo/scripts/plannotator-plan-loop.sh plan.md /tmp/plannotator_feedback.txt 3
  # 동작 보장:
  # - approve/feedback 입력까지 반드시 대기
  # - 같은 plan hash 에 이미 approved/feedback/infrastructure_blocked가 기록돼 있으면 재실행 금지
  # - plan.md 내용이 바뀔 때만 gate_status를 pending으로 리셋
  # - 세션 종료 시 자동 재시작 (최대 3회)
  # - 3회 종료 시 PLAN 종료 여부를 사용자에게 확인
  # - exit 32 시 localhost bind 차단(sandbox/CI): local TTY에서 수동 PLAN gate 실행
3. /tmp/plannotator_feedback.txt 읽기
4. "approved":true → EXECUTE 진입 / 미승인 → 피드백 반영 후 plan.md 수정 후 2번 반복
5. PLAN gate exit 32면 인프라 차단이므로 local TTY에서 PLAN gate 재실행
NEVER skip plannotator. NEVER proceed to EXECUTE without approved=true.

**EXECUTE** (BMAD for Gemini):
- BMAD is the primary orchestration fallback when omc team is unavailable
- `/workflow-init` — Initialize BMAD structured workflow
- `/workflow-status` — Check current phase
- Phases: Analysis → Planning → Solutioning → Implementation

**VERIFY** (agent-browser):
- `agent-browser snapshot http://localhost:3000`
- UI/기능 정상 여부 확인

**CLEANUP** (worktree):
- After all work: `bash '"${JEO_SKILL_DIR}"'/scripts/worktree-cleanup.sh`

**ANNOTATE** (agentation watch loop — HTTP API 폴백):
When user says "annotate" or "agentui" (deprecated alias) or asks to process UI annotations:
1. Set `.omc/state/jeo-state.json` → `phase="verify_ui"` and `agentation.submit_gate_status="waiting_for_submit"`
2. Wait for the human to click **Send Annotations** / trigger `onSubmit`
3. Only after that explicit submit signal, reply `ANNOTATE_READY` and update `agentation.submit_gate_status="submitted"`
4. Then GET http://localhost:4747/pending — check count
5. For each annotation: PATCH status:acknowledged, fix code via elementPath, PATCH status:resolved + resolution
6. Repeat until count=0. Emit `AGENTUI_READY` when done.
NEVER poll `/pending` before submit gate opens. NEVER treat draft annotations as actionable.

### ohmg Integration
For Gemini multi-agent orchestration:
```bash
bunx oh-my-ag           # Initialize ohmg
/coordinate "<task>"    # Coordinate multi-agent task
```
'

  if $DRY_RUN; then
    echo -e "${YELLOW}[DRY-RUN]${NC} Would append JEO section to $GEMINI_MD"
  else
    mkdir -p "$(dirname "$GEMINI_MD")"
    [[ -f "$GEMINI_MD" ]] && cp "$GEMINI_MD" "${GEMINI_MD}.jeo.bak"

    if [[ -f "$GEMINI_MD" ]] && grep -q "^## JEO Orchestration Workflow" "$GEMINI_MD"; then
      GEMINI_MD="$GEMINI_MD" JEO_SECTION="$JEO_SECTION" python3 - <<'PYEOF'
import os
import re

path = os.environ["GEMINI_MD"]
section = os.environ["JEO_SECTION"].strip()
text = open(path, encoding="utf-8").read()
pattern = re.compile(r"\n## JEO Orchestration Workflow\n.*?\Z", re.S)
updated = pattern.sub("\n" + section + "\n", text.rstrip() + "\n")
with open(path, "w", encoding="utf-8") as f:
    f.write(updated)
PYEOF
      ok "JEO section synced in ~/.gemini/GEMINI.md"
    else
      echo "$JEO_SECTION" >> "$GEMINI_MD"
      ok "JEO instructions added to ~/.gemini/GEMINI.md"
    fi
  fi
fi

echo ""
echo "Gemini CLI usage after setup:"
echo "  gemini --approval-mode plan    ← Plan mode (plannotator fires on exit)"
echo "  /workflow-init                 ← BMAD orchestration"
echo "  bunx oh-my-ag                  ← ohmg multi-agent (Gemini)"
echo ""
ok "Gemini CLI setup complete"
echo ""

```

### scripts/setup-opencode.sh

```bash
#!/usr/bin/env bash
# JEO Skill — OpenCode Plugin Registration
# Configures: opencode.json plugin entry + agentation MCP + slash commands
# Usage: bash setup-opencode.sh [--dry-run]

set -euo pipefail

GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; RED='\033[0;31m'; NC='\033[0m'
ok()   { echo -e "${GREEN}✓${NC} $*"; }
warn() { echo -e "${YELLOW}⚠${NC}  $*"; }
info() { echo -e "${BLUE}→${NC} $*"; }

DRY_RUN=false
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true

# Resolve opencode.json priority:
# 1) cwd (project-level config), then
# 2) ~/.config/opencode/opencode.json, then
# 3) legacy ~/.opencode.json
OPENCODE_JSON=""
for candidate in "./opencode.json" "${HOME}/.config/opencode/opencode.json" "${HOME}/opencode.json"; do
  [[ -f "$candidate" ]] && OPENCODE_JSON="$candidate" && break
done

echo ""
echo "JEO — OpenCode Plugin Setup"
echo "==========================="

# ── 1. Check OpenCode ────────────────────────────────────────────────────────
if ! command -v opencode >/dev/null 2>&1; then
  warn "opencode CLI not found. Install via: npm install -g opencode-ai"
fi

# Optional runtime check: OpenCode writes SQLite/cache/state under XDG dirs.
# If HOME-backed defaults are not writable, suggest tmp-based launcher.
if command -v opencode >/dev/null 2>&1; then
  OPENCODE_DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/opencode"
  if ! (mkdir -p "$OPENCODE_DATA_DIR" 2>/dev/null && [[ -w "$OPENCODE_DATA_DIR" ]]); then
    warn "OpenCode data dir is not writable: $OPENCODE_DATA_DIR"
    warn "Use: bash $(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/run-opencode-safe.sh"
  fi
fi

# ── 2. Configure opencode.json ────────────────────────────────────────────────
info "Configuring opencode.json..."

if [[ -z "$OPENCODE_JSON" ]]; then
  OPENCODE_JSON="${HOME}/.config/opencode/opencode.json"
  warn "No opencode.json found — will create at $OPENCODE_JSON"
fi

if $DRY_RUN; then
  echo -e "${YELLOW}[DRY-RUN]${NC} Would configure $OPENCODE_JSON with JEO plugin"
else
  # Backup
  mkdir -p "$(dirname "$OPENCODE_JSON")"
  [[ -f "$OPENCODE_JSON" ]] && cp "$OPENCODE_JSON" "${OPENCODE_JSON}.jeo.bak"

  OPENCODE_JSON_PATH="$OPENCODE_JSON" python3 - <<'PYEOF'
import json, os

config_path = os.environ["OPENCODE_JSON_PATH"]
try:
    with open(config_path) as f:
        config = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
    config = {}

# Set schema (normalize old escaped key if present)
legacy_schema_key = "\\$schema"
if legacy_schema_key in config:
    if "$schema" not in config:
        config["$schema"] = config[legacy_schema_key]
    config.pop(legacy_schema_key)
config.setdefault("$schema", "https://opencode.ai/config.json")

# Add plugins
plugins = config.setdefault("plugin", [])

# Add plannotator if not present
if "@plannotator/opencode@latest" not in plugins:
    plugins.append("@plannotator/opencode@latest")
    print("✓ plannotator plugin added")

# Add omx if not present
if "@oh-my-opencode/opencode@latest" not in plugins:
    plugins.append("@oh-my-opencode/opencode@latest")
    print("\u2713 omx (oh-my-opencode) plugin added")

# Add agentation MCP if not present
mcp_config = config.setdefault("mcp", {})
if "agentation" not in mcp_config:
    mcp_config["agentation"] = {
        "type": "local",
        "command": ["npx", "-y", "agentation-mcp", "server"]
    }
    print("\u2713 agentation MCP added to opencode.json")
else:
    print("\u2713 agentation MCP already present")

# Migrate legacy instructions to valid type (OpenCode expects array)
legacy_instructions = config.get("instructions")
if isinstance(legacy_instructions, str):
    text = legacy_instructions.strip()
    config["instructions"] = [text] if text else []
elif legacy_instructions is not None and not isinstance(legacy_instructions, list):
    config["instructions"] = [str(legacy_instructions)]

# Register JEO slash commands in OpenCode's "command" table
commands = config.setdefault("command", {})
jeo_commands = {
    "jeo-plan": {
        "description": "JEO planning workflow (ralph + plannotator)",
        "template": (
            "Write plan.md, then run mandatory PLAN gate: "
            "bash .agent-skills/jeo/scripts/plannotator-plan-loop.sh plan.md /tmp/plannotator_feedback.txt 3. "
            "If plannotator is missing, the PLAN gate auto-installs it first. "
            "This waits for approve/feedback, restarts dead sessions up to 3 times, "
            "and asks whether to stop PLAN after repeated failures."
        ),
    },
    "jeo-exec": {
        "description": "JEO execute workflow (team/bmad)",
        "template": (
            "Execute approved plan using team agents if available; otherwise use BMAD workflow phases."
        ),
    },
    "jeo-annotate": {
        "description": "Process agentation annotations (VERIFY_UI loop)",
        "template": (
            "Run agentation watch loop: acknowledge, implement fix, resolve, and repeat until pending count is 0."
        ),
    },
    "jeo-verify": {
        "description": "Verify browser behavior with agent-browser",
        "template": "Run agent-browser snapshot and verify the UI/flow for the current task.",
    },
    "jeo-cleanup": {
        "description": "Cleanup worktrees after JEO completion",
        "template": "Run: bash .agent-skills/jeo/scripts/worktree-cleanup.sh",
    },
}

added = 0
for name, spec in jeo_commands.items():
    if name not in commands:
        commands[name] = spec
        added += 1

if added:
    print(f"\u2713 Added {added} JEO command(s) to opencode.json")
else:
    print("\u2713 JEO commands already present")

with open(config_path, "w") as f:
    json.dump(config, f, indent=2)
print(f"✓ opencode.json saved: {config_path}")
PYEOF

  ok "OpenCode configuration updated"
fi

echo ""
echo "OpenCode slash commands after setup:"
echo "  /jeo-plan      ← Start planning workflow"
echo "  /jeo-exec      \u2190 Execute task"
echo "  /jeo-annotate  \u2190 agentation watch loop (VERIFY_UI); /jeo-agentui is deprecated alias"
echo "  /jeo-verify    \u2190 Verify UI with agent-browser"
echo "  /jeo-cleanup   ← Clean worktrees"
echo "  /plannotator-review ← Code review UI"
echo ""
echo "If OpenCode shows 'readonly database', run:"
echo "  bash $(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/run-opencode-safe.sh"
echo ""
echo "  Restart OpenCode to activate plugins."
echo ""
ok "OpenCode setup complete"
echo ""

```

### scripts/plannotator-plan-loop.sh

```bash
#!/usr/bin/env bash
# JEO PLAN gate for plannotator.
# Guarantees blocking review, retries dead sessions, and requires explicit stop decision.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Cross-platform temp dir: respects TMPDIR (macOS/Linux) TMP/TEMP (Windows Git Bash)
_TMPDIR="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"
PLAN_FILE="${1:-plan.md}"
FEEDBACK_FILE="${2:-}"
MAX_RESTARTS="${3:-3}"
# Dedicated port for plannotator (IANA unassigned — rarely conflicts with other services).
# Override with: PLANNOTATOR_PORT=XXXXX bash plannotator-plan-loop.sh ...
PLANNOTATOR_PORT="${PLANNOTATOR_PORT:-47291}"
# Seconds to wait for plannotator to bind its port after launch (startup detection).
PLANNOTATOR_START_TIMEOUT="${PLANNOTATOR_START_TIMEOUT:-15}"
PORT_ERROR_REGEX='Failed to start server\. Is port .* in use|EADDRINUSE|EPERM|operation not permitted|Failed to listen'

if ! command -v plannotator >/dev/null 2>&1; then
  if ! bash "$SCRIPT_DIR/ensure-plannotator.sh" --quiet; then
    echo "[JEO][PLAN] plannotator is required in PLAN phase." >&2
    exit 127
  fi
fi

export PATH="$HOME/.local/bin:$HOME/bin:$PATH"

if [[ ! -f "$PLAN_FILE" ]]; then
  echo "[JEO][PLAN] plan file not found: $PLAN_FILE" >&2
  exit 2
fi

if ! [[ "$MAX_RESTARTS" =~ ^[0-9]+$ ]] || [[ "$MAX_RESTARTS" -lt 1 ]]; then
  echo "[JEO][PLAN] invalid MAX_RESTARTS: $MAX_RESTARTS" >&2
  exit 2
fi

PLAN_HASH="$(python3 - "$PLAN_FILE" <<'PYEOF'
import hashlib, pathlib, sys
path = pathlib.Path(sys.argv[1])
try:
    data = path.read_text(encoding="utf-8")
except Exception:
    data = ""
print(hashlib.sha256(data.encode("utf-8")).hexdigest() if data else "")
PYEOF
)"

SESSION_KEY="$(python3 -c "import hashlib,os; print(hashlib.md5(os.getcwd().encode()).hexdigest()[:8])" 2>/dev/null || echo "default")"
FEEDBACK_DIR="${_TMPDIR}/jeo-${SESSION_KEY}"
RUNTIME_HOME="${FEEDBACK_DIR}/.plannotator"
mkdir -p "$FEEDBACK_DIR" "$RUNTIME_HOME"

if [[ -z "$FEEDBACK_FILE" ]]; then
  FEEDBACK_FILE="${FEEDBACK_DIR}/plannotator_feedback.txt"
else
  mkdir -p "$(dirname "$FEEDBACK_FILE")"
fi

write_manual_feedback_json() {
  local approved="$1"
  local note="${2:-}"
  python3 - "$FEEDBACK_FILE" "$approved" "$note" <<'PYEOF'
import json, sys
path, approved_raw, note = sys.argv[1], sys.argv[2], sys.argv[3]
approved = approved_raw.lower() == "true"
payload = {
    "approved": approved,
    "source": "jeo-manual-fallback",
    "note": note,
}
with open(path, "w", encoding="utf-8") as f:
    json.dump(payload, f, ensure_ascii=False, indent=2)
PYEOF
}

write_state_gate_status() {
  local status="$1"
  JEO_GATE_STATUS="$status" python3 -c "
import json, os, subprocess, datetime
try:
    root = subprocess.check_output(['git','rev-parse','--show-toplevel'], stderr=subprocess.DEVNULL).decode().strip()
except Exception:
    root = os.getcwd()
f = os.path.join(root, '.omc/state/jeo-state.json')
if os.path.exists(f):
    try:
        import fcntl
        with open(f, 'r+') as fh:
            fcntl.flock(fh, fcntl.LOCK_EX)
            try:
                d = json.load(fh)
                d['plan_gate_status'] = os.environ['JEO_GATE_STATUS']
                d['updated_at'] = datetime.datetime.utcnow().isoformat() + 'Z'
                fh.seek(0); json.dump(d, fh, indent=2); fh.truncate()
            finally:
                fcntl.flock(fh, fcntl.LOCK_UN)
    except Exception:
        pass
" 2>/dev/null || true
}

persist_plan_state() {
  local gate_status="$1"
  local approved="$2"
  local review_method="${3:-plannotator}"
  local feedback_path="${4:-}"
  JEO_GATE_STATUS="$gate_status" \
  JEO_APPROVED="$approved" \
  JEO_REVIEW_METHOD="$review_method" \
  JEO_PLAN_HASH="$PLAN_HASH" \
  JEO_FEEDBACK_FILE="$feedback_path" \
  python3 - <<'PYEOF'
import datetime
import json
import os
import subprocess

try:
    root = subprocess.check_output(
        ["git", "rev-parse", "--show-toplevel"],
        stderr=subprocess.DEVNULL,
        text=True,
    ).strip()
except Exception:
    root = os.getcwd()

state_path = os.path.join(root, ".omc", "state", "jeo-state.json")
if not os.path.exists(state_path):
    raise SystemExit(0)

try:
    import fcntl
except Exception:
    fcntl = None

feedback_payload = None
feedback_file = os.environ.get("JEO_FEEDBACK_FILE", "")
if feedback_file and os.path.exists(feedback_file):
    try:
        feedback_payload = json.load(open(feedback_file))
    except Exception:
        feedback_payload = None

with open(state_path, "r+", encoding="utf-8") as fh:
    if fcntl:
        fcntl.flock(fh, fcntl.LOCK_EX)
    try:
        state = json.load(fh)
        gate_status = os.environ.get("JEO_GATE_STATUS", "pending")
        approved = os.environ.get("JEO_APPROVED", "false").lower() == "true"
        review_method = os.environ.get("JEO_REVIEW_METHOD", "plannotator")
        plan_hash = os.environ.get("JEO_PLAN_HASH", "")
        state["plan_gate_status"] = gate_status
        state["plan_approved"] = approved
        state["plan_review_method"] = review_method
        state["plan_current_hash"] = plan_hash
        state["last_reviewed_plan_hash"] = plan_hash
        state["last_reviewed_plan_at"] = datetime.datetime.utcnow().isoformat() + "Z"
        if approved:
            state["phase"] = "execute"
        elif gate_status == "feedback_required" and feedback_payload is not None:
            state["plannotator_feedback"] = feedback_payload
        state["updated_at"] = datetime.datetime.utcnow().isoformat() + "Z"
        fh.seek(0)
        json.dump(state, fh, ensure_ascii=False, indent=2)
        fh.truncate()
    finally:
        if fcntl:
            fcntl.flock(fh, fcntl.LOCK_UN)
PYEOF
}

prepare_state_for_plan_hash() {
  JEO_PLAN_HASH="$PLAN_HASH" python3 - <<'PYEOF'
import datetime
import json
import os
import subprocess
import sys

try:
    root = subprocess.check_output(
        ["git", "rev-parse", "--show-toplevel"],
        stderr=subprocess.DEVNULL,
        text=True,
    ).strip()
except Exception:
    root = os.getcwd()

state_path = os.path.join(root, ".omc", "state", "jeo-state.json")
if not os.path.exists(state_path):
    raise SystemExit(0)

state = json.load(open(state_path, encoding="utf-8"))
current_hash = os.environ.get("JEO_PLAN_HASH", "")
last_hash = state.get("last_reviewed_plan_hash")
gate_status = state.get("plan_gate_status", "pending")

if current_hash and last_hash == current_hash and gate_status in {"approved", "manual_approved"}:
    print("SKIP_APPROVED")
    raise SystemExit(0)
if current_hash and last_hash == current_hash and gate_status == "feedback_required":
    print("SKIP_FEEDBACK")
    raise SystemExit(0)
if current_hash and last_hash == current_hash and gate_status == "infrastructure_blocked":
    print("SKIP_BLOCKED")
    raise SystemExit(0)

state["plan_current_hash"] = current_hash
if current_hash and last_hash and current_hash != last_hash and gate_status != "pending":
    state["plan_gate_status"] = "pending"
    state["plan_approved"] = False
    state["updated_at"] = datetime.datetime.utcnow().isoformat() + "Z"
    with open(state_path, "w", encoding="utf-8") as f:
        json.dump(state, f, ensure_ascii=False, indent=2)
    print("RESET_FOR_NEW_HASH")
PYEOF
}

STATE_PLAN_GUARD="$(prepare_state_for_plan_hash 2>/dev/null || true)"
case "$STATE_PLAN_GUARD" in
  SKIP_APPROVED)
    echo "[JEO][PLAN] plan gate already approved for current plan hash — skipping re-entry." >&2
    exit 0
    ;;
  SKIP_FEEDBACK)
    echo "[JEO][PLAN] feedback already recorded for current plan hash — revise the plan before re-opening plannotator." >&2
    exit 10
    ;;
  SKIP_BLOCKED)
    echo "[JEO][PLAN] infrastructure-blocked state already recorded for current plan hash — waiting for manual approval path." >&2
    exit 32
    ;;
  RESET_FOR_NEW_HASH)
    echo "[JEO][PLAN] detected revised plan content — resetting gate status to pending." >&2
    ;;
esac

manual_fallback_gate() {
  if [[ ! -t 0 || ! -t 1 ]]; then
    return 32
  fi

  echo "[JEO][PLAN] plannotator UI를 열 수 없는 환경입니다. 수동 PLAN gate로 전환합니다." >&2
  echo "[JEO][PLAN] 선택: [a]pprove / [f]eedback / [s]top" >&2
  read -r -p "선택하세요 [a/f/s]: " choice

  case "${choice,,}" in
    a|approve)
      write_manual_feedback_json "true" "manual-approve (fallback gate)"
      persist_plan_state "manual_approved" "true" "manual" "$FEEDBACK_FILE"
      echo "[JEO][PLAN] manual approved=true" >&2
      return 0
      ;;
    f|feedback)
      read -r -p "피드백 내용을 입력하세요: " fb
      write_manual_feedback_json "false" "${fb:-manual-feedback (fallback gate)}"
      persist_plan_state "feedback_required" "false" "manual" "$FEEDBACK_FILE"
      echo "[JEO][PLAN] manual approved=false (feedback)" >&2
      return 10
      ;;
    s|stop|n|no)
      echo "[JEO][PLAN] user requested PLAN stop." >&2
      return 30
      ;;
    *)
      echo "[JEO][PLAN] invalid choice. stopping PLAN." >&2
      return 31
      ;;
  esac
}

# probe_plannotator_port PORT
# Returns:
#   0 — port is free and localhost bind is permitted (plannotator can start)
#   1 — port is already in use (conflict — another instance may be running)
#   2 — localhost bind is not permitted (sandbox/CI restriction)
probe_plannotator_port() {
  local port="${1:-$PLANNOTATOR_PORT}"
  # Primary: Node.js probe on the exact plannotator port
  if command -v node >/dev/null 2>&1; then
    node -e "
const net=require('net');
const s=net.createServer();
s.on('error',(e)=>{
  process.exitCode = e.code==='EADDRINUSE' ? 1 : 2;
  process.exit();
});
s.listen({host:'127.0.0.1',port:${port}},()=>s.close(()=>process.exit(0)));
" >/dev/null 2>&1
    return $?
  fi
  # Fallback: Python3 socket probe on the exact port
  if command -v python3 >/dev/null 2>&1; then
    python3 -c "
import socket, sys
port = int(sys.argv[1])
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
try:
    s.bind(('127.0.0.1', port))
    s.close()
    sys.exit(0)
except OSError as e:
    import errno
    sys.exit(1 if e.errno in (errno.EADDRINUSE,) else 2)
" "$port" 2>/dev/null
    return $?
  fi
  # Neither available — assume free (conservative default)
  return 0
}

# wait_for_listen PORT PID [TIMEOUT_SECS]
# Polls until plannotator binds PORT, the process dies, or timeout is reached.
# Returns:
#   0 — plannotator is listening (browser UI ready)
#   1 — process exited before binding
#   2 — timeout reached (process still alive but port not yet bound)
wait_for_listen() {
  local port="$1" pid="$2" timeout="${3:-$PLANNOTATOR_START_TIMEOUT}"
  local elapsed=0
  while [[ $elapsed -lt $timeout ]]; do
    kill -0 "$pid" 2>/dev/null || return 1
    # Primary: bash built-in /dev/tcp (no subprocess, available on Linux/macOS)
    if ( exec 3<>/dev/tcp/127.0.0.1/"$port" ) 2>/dev/null; then
      return 0
    fi
    # Fallback: Python3 socket connect (Windows Git Bash, systems without /dev/tcp)
    if command -v python3 >/dev/null 2>&1; then
      python3 -c "
import socket, sys
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.5)
try:
    s.connect(('127.0.0.1', int(sys.argv[1])))
    s.close()
    sys.exit(0)
except Exception:
    sys.exit(1)
" "$port" 2>/dev/null && return 0
    fi
    sleep 1
    (( elapsed++ )) || true
  done
  return 2
}

# Pre-launch probe: verify the dedicated port is available before starting plannotator.
if [[ "${JEO_SKIP_LISTEN_PROBE:-0}" != "1" ]]; then
  set +e
  probe_plannotator_port "$PLANNOTATOR_PORT"
  probe_port_rc=$?
  set -e
  if [[ "$probe_port_rc" -eq 2 ]]; then
    echo "[JEO][PLAN] localhost bind probe failed — listen not permitted (sandbox/CI)." >&2
    set +e
    manual_fallback_gate
    probe_rc=$?
    set -e
    if [[ "$probe_rc" -eq 32 ]]; then
      write_state_gate_status "infrastructure_blocked"
    fi
    exit "$probe_rc"
  elif [[ "$probe_port_rc" -eq 1 ]]; then
    echo "[JEO][PLAN] port ${PLANNOTATOR_PORT} already in use — another plannotator instance may be running." >&2
    echo "[JEO][PLAN] override with: PLANNOTATOR_PORT=<free-port> or kill the existing process." >&2
    exit 32
  fi
  echo "[JEO][PLAN] port ${PLANNOTATOR_PORT} is available — starting plannotator." >&2
fi

attempt=1
while (( attempt <= MAX_RESTARTS )); do
  : > "$FEEDBACK_FILE"

  # Write plan JSON to a temp file so plannotator can be backgrounded (enables PID tracking)
  PLAN_JSON_FILE="${FEEDBACK_DIR}/plan_input.json"
  python3 -c "
import json, sys
plan = open(sys.argv[1]).read()
sys.stdout.write(json.dumps({'tool_input': {'plan': plan, 'permission_mode': 'acceptEdits'}}))
" "$PLAN_FILE" > "$PLAN_JSON_FILE"

  # Launch plannotator with the dedicated port so we can monitor its binding state.
  PORT="$PLANNOTATOR_PORT" env HOME="$RUNTIME_HOME" PLANNOTATOR_HOME="$RUNTIME_HOME" \
    plannotator < "$PLAN_JSON_FILE" > "$FEEDBACK_FILE" 2>&1 &
  PLANNOTATOR_PID=$!

  # Phase 1: STARTING — wait for plannotator to bind the port (browser UI ready).
  set +e
  wait_for_listen "$PLANNOTATOR_PORT" "$PLANNOTATOR_PID" "$PLANNOTATOR_START_TIMEOUT"
  listen_rc=$?
  set -e
  case "$listen_rc" in
    0)
      echo "[JEO][PLAN] plannotator listening on port ${PLANNOTATOR_PORT} — waiting for user input." >&2
      ;;
    1)
      echo "[JEO][PLAN] plannotator exited during startup (attempt ${attempt}/${MAX_RESTARTS})." >&2
      wait "$PLANNOTATOR_PID" 2>/dev/null || true
      ((attempt++)) || true
      continue
      ;;
    2)
      echo "[JEO][PLAN] plannotator startup timeout (${PLANNOTATOR_START_TIMEOUT}s) — port ${PLANNOTATOR_PORT} not bound yet; continuing to wait." >&2
      ;;
  esac

  # Phase 2: LISTENING / RUNNING — block while browser session is active.
  while kill -0 "$PLANNOTATOR_PID" 2>/dev/null; do
    sleep 1
  done
  wait "$PLANNOTATOR_PID" 2>/dev/null || true

  set +e
  python3 - "$FEEDBACK_FILE" <<'PYEOF'
import json, sys
path = sys.argv[1]
try:
    payload = json.load(open(path))
except Exception:
    sys.exit(20)
approved = payload.get("approved")
if approved is True:
    sys.exit(0)
if approved is False:
    sys.exit(10)
sys.exit(20)
PYEOF
  rc=$?
  set -e

  if [[ "$rc" -eq 0 ]]; then
    echo "[JEO][PLAN] approved=true"
    persist_plan_state "approved" "true" "plannotator" "$FEEDBACK_FILE"
    exit 0
  fi

  if [[ "$rc" -eq 10 ]]; then
    echo "[JEO][PLAN] approved=false (feedback)"
    persist_plan_state "feedback_required" "false" "plannotator" "$FEEDBACK_FILE"
    exit 10
  fi

  if grep -Eiq "$PORT_ERROR_REGEX" "$FEEDBACK_FILE"; then
    echo "[JEO][PLAN] plannotator server bind failure detected (EADDRINUSE/EPERM)." >&2
    set +e
    manual_fallback_gate
    fallback_rc=$?
    set -e
    if [[ "$fallback_rc" -eq 32 ]]; then
      write_state_gate_status "infrastructure_blocked"
    fi
    exit "$fallback_rc"
  fi

  # Classify crash type for clearer diagnostics before retrying
  if [[ -s "$FEEDBACK_FILE" ]]; then
    echo "[JEO][PLAN] browser crash detected (non-JSON output, attempt ${attempt}/${MAX_RESTARTS}). restarting..." >&2
  else
    echo "[JEO][PLAN] plannotator exited without output (attempt ${attempt}/${MAX_RESTARTS}). restarting..." >&2
  fi
  ((attempt++))
done

echo "[JEO][PLAN] plannotator session ended ${MAX_RESTARTS} times." >&2
set +e
manual_fallback_gate
fallback_rc=$?
set -e
if [[ "$fallback_rc" -eq 32 ]]; then
  echo "[JEO][PLAN] confirmation required. stop and ask user whether to continue PLAN." >&2
  write_state_gate_status "infrastructure_blocked"
  # Write a structured blocked-state file so agent frameworks can parse the situation
  python3 - "$FEEDBACK_DIR/jeo-blocked.json" "$PLAN_FILE" <<'PYEOF'
import json, sys
out_path, plan_file = sys.argv[1], sys.argv[2]
try:
    with open(out_path, "w", encoding="utf-8") as f:
        json.dump({
            "status": "infrastructure_blocked",
            "reason": "plannotator_session_exhausted",
            "max_restarts_reached": True,
            "action_required": "manual_approval",
            "plan_file": plan_file,
            "instruction": (
                "Review the plan file and manually approve or reject: "
                "run the hook script again in a TTY, or set plan_gate_status "
                "in .omc/state/jeo-state.json to 'manual_approved'."
            ),
        }, f, ensure_ascii=False, indent=2)
except Exception:
    pass
PYEOF
fi
exit "$fallback_rc"

```

### scripts/ensure-plannotator.sh

```bash
#!/usr/bin/env bash
# JEO helper — guarantees plannotator is available before PLAN.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
SKILLS_ROOT="$(dirname "$SKILL_DIR")"

QUIET=false

for arg in "$@"; do
  case "$arg" in
    --quiet) QUIET=true ;;
    -h|--help)
      echo "Usage: bash ensure-plannotator.sh [--quiet]"
      echo "Ensures the plannotator CLI is installed before JEO PLAN runs."
      exit 0
      ;;
  esac
done

log() {
  if ! $QUIET; then
    echo "$@"
  fi
}

export PATH="$HOME/.local/bin:$HOME/bin:$PATH"

if command -v plannotator >/dev/null 2>&1; then
  log "[JEO][PLAN] plannotator already available: $(command -v plannotator)"
  exit 0
fi

if [[ "${JEO_SKIP_PLANNOTATOR_AUTO_INSTALL:-0}" == "1" ]]; then
  echo "[JEO][PLAN] plannotator not found and auto-install is disabled." >&2
  exit 127
fi

INSTALL_CANDIDATES=(
  "$SKILLS_ROOT/plannotator/scripts/install.sh"
  "$HOME/.agent-skills/plannotator/scripts/install.sh"
  "$HOME/.codex/skills/plannotator/scripts/install.sh"
)

for candidate in "${INSTALL_CANDIDATES[@]}"; do
  if [[ ! -f "$candidate" ]]; then
    continue
  fi

  log "[JEO][PLAN] plannotator missing. Installing via $candidate"
  if bash "$candidate"; then
    export PATH="$HOME/.local/bin:$HOME/bin:$PATH"
    if command -v plannotator >/dev/null 2>&1; then
      log "[JEO][PLAN] plannotator installation completed: $(command -v plannotator)"
      exit 0
    fi
  fi

  echo "[JEO][PLAN] installer finished but plannotator is still unavailable: $candidate" >&2
done

echo "[JEO][PLAN] plannotator auto-install failed. Run: bash scripts/install.sh --with-plannotator" >&2
exit 127

```



---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### references/FLOW.md

```markdown
# JEO Workflow — Detailed Reference

## Complete Execution Flow

```
┌─────────────────────────────────────────────────────────────────┐
│                      JEO WORKFLOW                               │
│                                                                 │
│  [START] User activates "jeo" keyword with task description     │
│                          │                                      │
│          ┌───────────────▼──────────────────┐                  │
│          │         PHASE 1: PLAN             │                  │
│          │   ralph creates plan.md           │                  │
│          │   plannotator reviews visually    │                  │
│          │   same hash never reopens gate    │                  │
│          │   ┌──────────────────────────┐   │                  │
│          │   │  Approve → continue      │   │                  │
│          │   │  Feedback → re-plan      │   │                  │
│          │   └──────────────────────────┘   │                  │
│          └───────────────┬──────────────────┘                  │
│                          │                                      │
│          ┌───────────────▼──────────────────┐                  │
│          │         PHASE 2: EXECUTE          │                  │
│          │                                   │                  │
│          │  team available?                  │                  │
│          │  ├─ YES: /omc:team N:executor    │                  │
│          │  │       staged pipeline          │                  │
│          │  └─ NO:  /bmad /workflow-init    │                  │
│          │          Analysis→Planning→       │                  │
│          │          Solutioning→Implementation│                 │
│          └───────────────┬──────────────────┘                  │
│                          │                                      │
│          │         PHASE 3: VERIFY           │                  │
│          │   agent-browser snapshot <url>    │                  │
│          │   UI/기능 동작 확인               │                  │
│          │                                   │                  │
│          │   [annotate keyword? (agentui alias)]│                │
│          │   └─ YES: VERIFY_UI (agentation)  │                  │
│          │       wait for onSubmit first     │                  │
│          │       ack → fix → resolve         │                  │
│          └───────────────┬──────────────────┘                  │
│                          │                                      │
│          ┌───────────────▼──────────────────┐                  │
│          │         PHASE 4: CLEANUP          │                  │
│          │   bash scripts/worktree-cleanup.sh│                  │
│          │   clean worktrees only by default │                  │
│          │   git worktree prune              │                  │
│          └───────────────┬──────────────────┘                  │
│                          │                                      │
│                       [DONE]                                    │
└─────────────────────────────────────────────────────────────────┘
```

---

## annotate — agentation Watch Loop (VERIFY_UI Sub-Phase)

```
annotate keyword detected (or agentui alias — or user requests UI annotation review)
    │
    ▼
[PREFLIGHT]  3-step check before entering watch loop:
    │  1. GET /health         → server running?
    │  2. GET /sessions       → <Agentation> component mounted?
    │  3. GET /pending        → baseline annotation count
    │
    ├─ FAIL (server down) → retry 3x, 5s interval → ERROR (exit with message)
    │
    ▼  OK → update jeo-state.json: phase="verify_ui", agentation.active=true
[SUBMIT GATE]  wait for explicit Send Annotations / onSubmit
         state: agentation.submit_gate_status="waiting_for_submit"
    │
    ├─ no submit yet → stay idle, do not poll /pending
    │
    ▼  submit observed → set submit_gate_status="submitted"
[WATCH]  agentation_watch_annotations({batchWindowSeconds:10, timeoutSeconds:120})
         blocking — waits for submitted annotations or timeout
    │
    ├─ Annotations received (sorted by severity: blocking → important → suggestion):
    │   │
    │   ▼
    │  [ACK]   agentation_acknowledge_annotation({id})
    │          → status: 'acknowledged' → spinner shown in toolbar
    │   │
    │   ▼
    │  [FIND]  grep/AST search using annotation.elementPath (CSS selector)
    │          annotation.comment → understand desired change
    │   │
    │   ▼
    │  [FIX]   apply code change to matched component/file
    │   │
    │   ▼
    │  [RESOLVE] agentation_resolve_annotation({id, summary})
    │            → status: 'resolved' → green checkmark in toolbar
    │   │
    │   ▼
    │  [RE-SNAPSHOT] agent-browser snapshot <url>  ← verify fix visually
    │            → compare before/after
    │   │
    │   └─ Next annotation → repeat ACK→FIND→FIX→RESOLVE→RE-SNAPSHOT
    │
    ├─ count=0 (all resolved)
    │   └─ update jeo-state.json: agentation.exit_reason="all_resolved"
    │   └─ VERIFY_UI complete → proceed to CLEANUP
    │
    └─ timeout (120s)
        └─ update jeo-state.json: agentation.exit_reason="timeout"
        └─ summarize what was/wasn't addressed → proceed to CLEANUP
```

**HTTP REST API (Codex / Gemini / OpenCode fallback — no MCP):**
```
LOOP:
  GET  http://localhost:4747/pending         → {count, annotations[]}
  if count == 0 → break (done)
  for each annotation:
    PATCH .../annotations/:id {status:"acknowledged"}
    [search & fix code via elementPath]
    PATCH .../annotations/:id {status:"resolved", resolution:"<summary>"}
  sleep 5 → repeat
```

### VERIFY_UI Internal State Machine

```
  IDLE ──(annotate keyword)──► PREFLIGHT
                                  │
                     ┌────────────┤
                     │            │
                   FAIL        OK
                     │            │
                     ▼            ▼
                  RECOVER   WAIT_FOR_SUBMIT
                  (3x retry)      │
                     │      ┌─────┼───────────────┐
                     │      │     │               │
                   ERROR  submit  no submit    timeout
                     │      │     │      │
                     ▼      ▼     ▼      ▼
                   FAIL WATCHING IDLE  TIMEOUT
                          │              │
                   ACK→FIX→RESOLVE    report
                          │
                      RE-SNAPSHOT
                          │
                    issues? ─Y─► WATCHING
                          │
                          N
                          ▼
                        DONE
```

### plannotator-agentation Phase Separation

```
Phase Guard: hooks check jeo-state.json phase before executing

  PLAN phase:
    plannotator ✅ (allowed)
    agentation  ❌ (blocked by phase guard)
    rule        same hash + approved/manual_approved => skip re-entry (exit 0)
    rule        same hash + feedback_required        => skip re-entry (exit 1, NOT exit 0)
    rule        same hash + infrastructure_blocked   => skip re-entry (exit 32)
    ⚠️  feedback_required is NOT terminal — it means plan was REJECTED.
        Same hash + feedback_required must exit 1 (not approved), not exit 0.
        Only a revised plan (new hash) resets gate to pending and reopens plannotator.

  EXECUTE phase:
    plannotator ❌ (blocked by phase guard)
    agentation  ❌ (blocked by phase guard)

  VERIFY / VERIFY_UI phase:
    plannotator ❌ (blocked by phase guard)
    agentation  ✅ (allowed only after submit gate opens)
```

---

## Platform-Specific Execution Paths

### Claude Code (Primary)

```
jeo keyword detected
    │
    ├─ omc available? → /omc:team N:executor (team orchestration)
    │   ├─ team-plan: explore + planner agents
    │   ├─ team-prd: analyst agent
    │   ├─ team-exec: executor agents (parallel)
    │   ├─ team-verify: verifier + reviewers
    │   └─ team-fix: debugger/executor (loop until done)
    │
    └─ plannotator hook: ExitPlanMode → JEO plan-gate wrapper
        └─ skips same reviewed hash, otherwise opens browser UI
```

**State file**: `{worktree}/.omc/state/jeo-state.json`

### Codex CLI

```
/prompts:jeo activated
    │
    ├─ Plan: Write plan.md manually or via ralph prompt
    ├─ Execute: BMAD /workflow-init (no native team support)
    ├─ Verify: agent-browser snapshot <url>
    └─ Cleanup: bash .agent-skills/jeo/scripts/worktree-cleanup.sh
```

**Config**: `~/.codex/config.toml` (developer_instructions)
**Prompt**: `~/.codex/prompts/jeo.md`

### Gemini CLI

```
gemini --approval-mode plan
    │
    ├─ Plan mode: write plan → exit → plannotator fires
    ├─ Execute: ohmg (bunx oh-my-ag) or BMAD /workflow-init
    ├─ Verify: agent-browser snapshot <url>
    └─ Cleanup: bash .agent-skills/jeo/scripts/worktree-cleanup.sh
```

**Config**: `~/.gemini/settings.json` (AfterAgent hook)
**Instructions**: `~/.gemini/GEMINI.md`

### OpenCode

```
/jeo-plan → /jeo-exec → /jeo-status → /jeo-cleanup
    │
    ├─ omx (oh-my-opencode): /omx:team N:executor "<task>"
    ├─ BMAD fallback: /workflow-init
    ├─ plannotator: /plannotator-review (code review)
    └─ Slash commands registered via opencode.json
```

**Config**: `opencode.json` (plugins + instructions)

---

## State Machine

```
States: plan → execute → verify → verify_ui? → cleanup → done

Transitions:
  plan     → execute  (plan approved — approved=true or manual_approved)
  plan     → plan     (feedback received, re-plan: gate=feedback_required, agent must revise plan.md)
  plan     → plan     (same hash + approved/manual_approved => skip re-entry, stay at plan→execute)
  plan     → plan     (same hash + feedback_required => exit 1, NOT exit 0 — plan rejected, revise required)
  plan     → plan     (same hash + infrastructure_blocked => exit 32 — manual gate needed)
  plan     → plan     (new hash after feedback => RESET_FOR_NEW_HASH, gate resets to pending, reopens plannotator)

  ⚠️  v1.3.1 fix: same hash + feedback_required used to incorrectly exit 0 (approved), causing JEO
      to enter EXECUTE without proper approval. Now correctly exits 1 (not approved).
  execute  → verify   (tasks complete, browser UI present)
  verify   → verify_ui (annotate keyword detected — or agentui alias — submit gate waiting)
  verify   → cleanup  (no annotate/agentui, verification passed)
  verify_ui → verify_ui (waiting_for_submit until explicit onSubmit/ANNOTATE_READY)
  verify_ui → cleanup (annotations all resolved or timeout)

  cleanup  → done     (worktrees removed, prune complete)
```

State persisted in: `.omc/state/jeo-state.json`

```json
{
  "phase": "plan",
  "task": "Implement user authentication",
  "plan_approved": false,
  "plan_gate_status": "pending",
  "plan_current_hash": null,
  "last_reviewed_plan_hash": null,
  "last_reviewed_plan_at": null,
  "plan_review_method": null,
  "team_available": true,
  "retry_count": 0,
  "last_error": null,
  "checkpoint": null,
  "created_at": "2026-02-24T00:00:00Z",
  "updated_at": "2026-02-24T00:00:00Z",
  "agentation": {
    "active": false,
    "session_id": null,
    "keyword_used": null,
    "submit_gate_status": "idle",
    "submit_signal": null,
    "submit_received_at": null,
    "submitted_annotation_count": 0,
    "started_at": null,
    "timeout_seconds": 120,
    "annotations": {
      "total": 0,
      "acknowledged": 0,
      "resolved": 0,
      "dismissed": 0,
      "pending": 0
    },
    "completed_at": null,
    "exit_reason": null
  }
}
```

**Error recovery fields:**
- `retry_count`: incremented on each Pre-flight failure; ≥ 3 triggers user confirmation
- `last_error`: most recent error message; cleared on successful step entry
- `checkpoint`: last successfully entered phase (`"plan"` | `"execute"` | `"verify"` | `"cleanup"`); used to resume after interruption

**agentation fields:**
- `active`: whether VERIFY_UI watch loop is currently running (used as guard by hooks)
- `session_id`: agentation session ID for resume via `agentation_get_session`
- `keyword_used`: `"annotate"` or `"agentui"` (tracks which keyword triggered entry)
- `submit_gate_status`: `"idle"` | `"waiting_for_submit"` | `"submitted"`; draft annotations stay blocked until submit
- `submit_signal`: which platform opened the submit gate
- `submit_received_at` / `submitted_annotation_count`: audit trail for the submitted batch
- `started_at`: ISO-8601 timestamp when VERIFY_UI watch loop started
- `timeout_seconds`: poll timeout in seconds (default: 120)
- `annotations.*`: cumulative counts by lifecycle status (`total`, `acknowledged`, `resolved`, `dismissed`, `pending`)
- `exit_reason`: `"all_resolved"` | `"timeout"` | `"user_cancelled"` | `"error"`

---

## Team vs BMAD Decision Matrix

| Condition | Executor | Notes |
|-----------|----------|-------|
| Claude Code + omc + AGENT_TEAMS=1 | **team** | Best option — parallel staged pipeline |
| Claude Code + omc (no teams) | **blocked** | Re-run setup and enable experimental teams; JEO should not silently downgrade |
| Codex CLI | **BMAD** | Structured phases, no native team |
| Gemini CLI + ohmg | **ohmg** | Multi-agent via oh-my-ag |
| Gemini CLI (basic) | **BMAD** | Fallback structured workflow |
| OpenCode + omx | **omx team** | oh-my-opencode team orchestration |
| OpenCode (basic) | **BMAD** | Fallback structured workflow |

---

## agent-browser Verify Pattern

```bash
# 앱 실행 중인 URL에서 스냅샷 캡처
agent-browser snapshot http://localhost:3000

# 특정 요소 확인 (accessibility tree ref 방식)
agent-browser snapshot http://localhost:3000 -i
# → @eN ref 번호로 요소 상태 확인

# 스크린샷 저장
agent-browser screenshot http://localhost:3000 -o verify.png
```

---

## Worktree Manual Cleanup

```bash
# List all worktrees
git worktree list

# Remove specific worktree
git worktree remove /path/to/worktree --force

# Prune stale references
git worktree prune
```

---

## Environment Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` | Enable native team orchestration | `1` |
| `PLANNOTATOR_REMOTE` | Remote mode (no auto browser open) | unset |
| `PLANNOTATOR_PORT` | Fixed plannotator port | auto |
| `JEO_MAX_ITERATIONS` | Max ralph loop iterations | `20` |

| `AGENTATION_PORT` | agentation MCP server port | `4747` |
| `AGENTATION_TIMEOUT` | annotate watch loop timeout (seconds) | `120` |
---

## Troubleshooting

### plannotator feedback loop: accept not registering / subsequent feedback skipped

**Root cause (v1.3.1 fix)**: When plan hash matched and `plan_gate_status == "feedback_required"`,
the pre-flight guard in SKILL.md incorrectly exited 0 (approved), causing JEO to enter EXECUTE
without a real approval. This affected Codex / Gemini / OpenCode platforms.

**Symptom**: After giving feedback, the plan appears "accepted" but feedback items are missing
on the next round, or plannotator skips re-opening for subsequent feedback rounds.

**Fix applied in v1.3.1**: The guard now exits 1 (not approved) for `feedback_required` + same hash.

**Manual recovery** (if stuck in wrong state):
```bash
# Check current gate state
python3 -c "import json; s=json.load(open('.omc/state/jeo-state.json')); print(s.get('plan_gate_status'), s.get('last_reviewed_plan_hash','')[:8])"

# Force reset gate to pending (allows re-review of same plan)
python3 -c "
import json, datetime
f='.omc/state/jeo-state.json'
s=json.load(open(f))
s['plan_gate_status']='pending'
s['last_reviewed_plan_hash']=None
s['updated_at']=datetime.datetime.utcnow().isoformat()+'Z'
open(f,'w').write(json.dumps(s,indent=2))
print('reset done')
"
```

**Best practice**: Always make substantive content changes to `plan.md` when incorporating feedback.
The gate uses SHA-256 content hash — even a single character change produces a new hash.

### plannotator not opening on plan exit
```bash
# Check hook is configured
bash scripts/check-status.sh

# Re-run hook setup
bash scripts/setup-claude.sh  # or setup-gemini.sh

# Verify plannotator CLI is installed
which plannotator || plannotator --version
```

### team mode not working
```bash
# Ensure env variable is set
export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1

# Or add to ~/.claude/settings.json:
# "env": { "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" }

# Fall back to ralph:
/ralph "<task>" --max-iterations=20
```

### worktree removal fails
```bash
# Force remove
git worktree remove /path/to/wt --force

# If git objects missing
git worktree prune --verbose

# Manual directory removal
rm -rf /path/to/worktree
git worktree prune
```

### annotate (agentation) watch loop not triggering
```bash
# Verify agentation-mcp server is running
curl -sf http://localhost:4747/pending
# Should return: {"count": N, "annotations": [...]}

# Check MCP registration (Claude Code)
cat ~/.claude/settings.json | python3 -c "import sys,json; s=json.load(sys.stdin); print(s.get('mcpServers', {}))"

# Restart agentation-mcp server
npx agentation-mcp server
```

```

### references/PLANNOTATOR_RELIABILITY_CONSENSUS_2026-03-06.md

```markdown
# plannotator 신뢰성 이슈 합의서 (2026-03-06)

## 문제 정의
- 이슈: `jeo` PLAN 단계에서 `plannotator`가 시작되지 않아 승인 게이트가 막힘.
- 관측 에러:
  - `Failed to start server. Is port 0 in use?`
  - `code: "EADDRINUSE"`
- 영향: `approved=true`를 만들 수 없어 PLAN→EXECUTE 전이가 중단됨.

---

## 근거 기반 진단

### 1) 로컬 재현 (Codex sandbox)
- `bun -e "Bun.serve({port:0,...})"` 실행 시 동일 에러 재현.
- `node`로 localhost bind probe 시 `EPERM listen 127.0.0.1`.
- 결론: 일부 실행환경(특히 sandbox/CI/headless)은 localhost listen 자체가 차단됨.

### 2) 외부 근거
- Bun 공식 문서: `Bun.serve`는 실제로 포트를 bind하며 `port: 0`은 랜덤 포트에 listen하도록 설계됨.
  - https://bun.com/docs/api/http
- Claude Code 이슈에서도 동일한 plannotator/Bun 에러가 보고됨.
  - https://github.com/anthropics/claude-code/issues/11469
- Claude Code hooks는 `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Notification` 등 이벤트 기반 자동화를 지원.
  - https://docs.anthropic.com/en/docs/claude-code/hooks
- Gemini CLI hooks도 동기 이벤트 기반으로 동작 (`BeforeTool`, `AfterTool`, `UserPromptSubmit` 등)하며 설정 파일에서 관리.
  - https://developers.googleblog.com/tailor-gemini-cli-to-your-workflow-with-hooks/
- Codex는 `notify` 훅/이벤트를 지원하며 config에서 hook command를 연결 가능.
  - https://developers.openai.com/codex/config/

---

## 역할별 토론 결과

### QA 관점
- 현재 실패는 일회성 플루크가 아니라 환경 의존 재현 버그.
- 승인 게이트는 “실패 이유를 분류”해야 하며, 인프라 실패와 사용자 피드백 실패를 구분해야 함.
- 수용 기준:
  1. localhost bind 불가 시 즉시 감지
  2. 불필요한 3회 재시도 제거
  3. 대체 승인 루프 제공

### 시스템 엔지니어 관점
- 본질은 `plannotator` UI 서버 의존성 + 실행환경의 listen 제한.
- `EADDRINUSE`는 실제 포트 충돌이 아니라 listen 실패(권한/격리) 래핑일 수 있음.
- 안정화 전략:
  - 사전 probe (localhost listen)
  - 인프라 차단 전용 종료 코드
  - TTY/비TTY 분기 fallback

### Codex 관점
- notify hook 기반 자동 루프는 가능하지만 sandbox 정책에 따라 UI 서버가 실패할 수 있음.
- 따라서 PLAN gate는 “UI-only hard fail”이 아니라 “fallback-aware state machine”이어야 함.

### Claude 관점
- hook 체인은 강력하지만, 실행환경 제약(권한/네트워크) 문제는 hooks만으로 해결 불가.
- 실패를 빠르게 surfacing하고 수동 gate로 전환하는 것이 실용적.

### Gemini CLI 관점
- hook는 안전망으로 유효하지만, PLAN 단계는 blocking gate가 본질.
- UI 서버 실패 시에도 다음 턴으로 피드백 전달 가능한 deterministic fallback이 필요.

---

## 합의안 (최종)

1. PLAN gate를 2레인으로 분리
- Primary: `plannotator` hook mode (UI approve/feedback)
- Fallback: 수동 PLAN gate (TTY에서 approve/feedback/stop)

2. 인프라 실패 전용 exit code 도입
- `exit 32`: localhost bind 불가 또는 plannotator server bind 실패
- 기존 코드 유지:
  - `0` approved
  - `10` feedback
  - `30/31` stop/confirm

3. 재시도 정책 개선
- bind 불가 시 재시도 반복 대신 즉시 fallback 전환.
- 비TTY에서는 `exit 32`로 빠르게 사용자 확인 유도.

4. 문서/운영 규칙 업데이트
- JEO 문서에 `exit 32` 분기와 운영 가이드 추가.
- 플랫폼 설정 스크립트(setup-codex/setup-gemini 등)에서도 안내 문구 통일.

---

## 적용 내용

### 코드
- 수정: `.agent-skills/jeo/scripts/plannotator-plan-loop.sh`
  - localhost listen probe 추가
  - bind 실패 패턴 감지 (`EADDRINUSE/EPERM/...`)
  - TTY 수동 gate 추가 (`approve/feedback/stop`)
  - `exit 32` 표준화

### 문서
- 수정: `.agent-skills/jeo/SKILL.md`
  - PLAN 결과 분기에 `exit 32` 추가
  - 인프라 차단 시 운영 절차 명시

---

## 운영 권고

1. 로컬 개발(권장)
- sandbox 없는 로컬 터미널에서 PLAN gate 수행
- UI 승인 완료 후 EXECUTE 단계로 진입

2. 제한 환경(CI/강샌드박스)
- PLAN 단계는 수동 gate 또는 외부 승인 채널로 분리
- `exit 32`를 “환경 제약”으로 취급하고 실패 집계에서 분리

3. 장기 개선
- plannotator upstream에 Node HTTP fallback 또는 no-listen 승인 모드 제안
- 훅 체인에서 인프라 실패 telemetry를 수집해 자동 진단 메시지 제공

```

### scripts/claude-agentation-submit-hook.py

```python
#!/usr/bin/env python3
"""JEO Claude UserPromptSubmit wrapper for agentation.

Only exposes pending annotations after an explicit submit-style prompt arrives
during the VERIFY_UI gate.
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any


READY_TOKENS = (
    "annotate",
    "annotate_ready",
    "agentui",
    "ui검토",
    "send annotations",
    "submitted annotations",
    "elementpath",
)


def git_root() -> Path:
    try:
        return Path(
            subprocess.check_output(
                ["git", "rev-parse", "--show-toplevel"],
                stderr=subprocess.DEVNULL,
                text=True,
            ).strip()
        )
    except Exception:
        return Path.cwd()


def state_path(root: Path) -> Path:
    return root / ".omc" / "state" / "jeo-state.json"


def load_state(root: Path) -> dict[str, Any]:
    path = state_path(root)
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {}


def save_state(root: Path, state: dict[str, Any]) -> None:
    path = state_path(root)
    if not path.parent.exists():
        path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")


def flatten_strings(node: Any) -> list[str]:
    parts: list[str] = []
    if isinstance(node, str):
        parts.append(node)
    elif isinstance(node, dict):
        for value in node.values():
            parts.extend(flatten_strings(value))
    elif isinstance(node, list):
        for value in node:
            parts.extend(flatten_strings(value))
    return parts


def submitted_prompt(payload: str) -> bool:
    try:
        data = json.loads(payload)
    except Exception:
        return False

    combined = " ".join(flatten_strings(data)).lower()
    return any(token in combined for token in READY_TOKENS)


def fetch_pending() -> dict[str, Any] | None:
    try:
        with urllib.request.urlopen("http://localhost:4747/pending", timeout=2) as response:
            return json.loads(response.read().decode("utf-8"))
    except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError):
        return None


def update_submit_gate(root: Path, state: dict[str, Any], count: int) -> None:
    agentation = state.setdefault("agentation", {})
    agentation["submit_gate_status"] = "submitted"
    agentation["submit_signal"] = "claude-user-prompt-submit"
    agentation["submit_received_at"] = subprocess.check_output(
        ["python3", "-c", "import datetime;print(datetime.datetime.utcnow().isoformat()+\"Z\")"],
        text=True,
    ).strip()
    agentation["submitted_annotation_count"] = count
    save_state(root, state)


def main() -> int:
    payload = sys.stdin.read()
    root = git_root()
    state = load_state(root)

    if state.get("phase") != "verify_ui":
        return 0
    if not submitted_prompt(payload):
        return 0

    pending = fetch_pending()
    if not pending:
        return 0

    count = int(pending.get("count", 0) or 0)
    if count <= 0:
        return 0

    update_submit_gate(root, state, count)

    print(f"=== AGENTATION: {count} submitted UI annotations ===")
    for index, annotation in enumerate(pending.get("annotations", []), start=1):
        element = annotation.get("element", "?")
        path = annotation.get("elementPath", "?")
        comment = annotation.get("comment", "")
        print(f"[{index}] {element} ({path})")
        if comment:
            print(f"    {comment}")
    print("=== END ===")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

```

### scripts/claude-plan-gate.py

```python
#!/usr/bin/env python3
"""JEO Claude plan gate wrapper.

Wraps the Claude Code ExitPlanMode hook so JEO can skip redundant plannotator
launches when the current plan content has already been reviewed.

On approval (plannotator exit code 0), ralphmode is automatically activated:
- jeo-state.json: ralphmode_active=true, plan_gate_status="approved"
- project .claude/settings.json: permissionMode="acceptEdits"
This allows the EXECUTE phase (/omc:team) to run with minimal approval prompts.
Ralphmode is deactivated at CLEANUP (worktree-cleanup.sh resets permissionMode).
"""

from __future__ import annotations

import datetime
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any


SKIP_STATUSES = {"approved", "manual_approved", "feedback_required", "infrastructure_blocked"}
RALPHMODE_PERMISSION = "acceptEdits"


def git_root() -> Path:
    try:
        return Path(
            subprocess.check_output(
                ["git", "rev-parse", "--show-toplevel"],
                stderr=subprocess.DEVNULL,
                text=True,
            ).strip()
        )
    except Exception:
        return Path.cwd()


def state_path(root: Path) -> Path:
    return root / ".omc" / "state" / "jeo-state.json"


def load_state(root: Path) -> dict[str, Any]:
    path = state_path(root)
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {}


def save_state(root: Path, state: dict[str, Any]) -> None:
    path = state_path(root)
    if not path.parent.exists():
        path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")


def find_plan_text(root: Path, payload: str) -> str:
    for candidate in (
        root / ".omc" / "plans" / "jeo-plan.md",
        root / "plan.md",
        root / "docs" / "plan.md",
    ):
        if candidate.exists():
            try:
                return candidate.read_text(encoding="utf-8")
            except Exception:
                continue

    try:
        data = json.loads(payload)
    except Exception:
        return ""

    tool_input = data.get("tool_input", {})
    if isinstance(tool_input, dict):
        plan = tool_input.get("plan")
        if isinstance(plan, str):
            return plan
    return ""


def plan_hash(plan_text: str) -> str:
    if not plan_text:
        return ""
    return hashlib.sha256(plan_text.encode("utf-8")).hexdigest()


def should_skip(state: dict[str, Any], current_hash: str) -> bool:
    if state.get("phase") != "plan":
        return False

    gate_status = state.get("plan_gate_status")
    last_hash = state.get("last_reviewed_plan_hash")
    return bool(current_hash and gate_status in SKIP_STATUSES and last_hash == current_hash)


def reset_for_revised_plan(root: Path, state: dict[str, Any], current_hash: str) -> None:
    last_hash = state.get("last_reviewed_plan_hash")
    if not current_hash or current_hash == last_hash:
        return

    if state.get("plan_gate_status") in SKIP_STATUSES:
        state["plan_gate_status"] = "pending"
        state["plan_approved"] = False
        state["plan_current_hash"] = current_hash
        state["updated_at"] = datetime.datetime.utcnow().isoformat() + "Z"
        try:
            save_state(root, state)
        except Exception as exc:
            print(f"[JEO] ⚠️  Failed to reset plan state: {exc}", file=sys.stderr)


def run_plannotator(payload: str, plan_text: str = "") -> int:
    """Run plannotator with the hook payload, injecting plan_text if tool_input.plan is missing.

    plannotator expects {"tool_input": {"plan": "...", "permission_mode": "..."}} on stdin.
    If the ExitPlanMode hook payload does not include tool_input.plan (e.g. Claude Code does
    not embed it), we inject the plan text found by find_plan_text() so plannotator can
    render the plan UI correctly.  Without this injection the browser page has no content
    and clicking "Approve" causes a page error.
    """
    try:
        data = json.loads(payload)
    except Exception:
        data = {}

    if plan_text:
        tool_input = data.get("tool_input")
        if not isinstance(tool_input, dict):
            data["tool_input"] = {"plan": plan_text, "permission_mode": "acceptEdits"}
        else:
            if not tool_input.get("plan"):
                tool_input["plan"] = plan_text
            if not tool_input.get("permission_mode"):
                tool_input["permission_mode"] = "acceptEdits"

    enriched_payload = json.dumps(data)
    proc = subprocess.run(["plannotator"], input=enriched_payload, text=True)
    return proc.returncode


def activate_ralphmode(root: Path, state: dict[str, Any], current_hash: str = "") -> None:
    """Activate ralphmode after plan approval.

    - Sets ralphmode_active=true in jeo-state.json
    - Records last_reviewed_plan_hash so should_skip() can skip re-reviews of the same plan
    - Writes permissionMode=acceptEdits to project .claude/settings.json
      so the EXECUTE phase (/omc:team) runs with minimal approval prompts.
    """
    now = datetime.datetime.utcnow().isoformat() + "Z"
    state["plan_approved"] = True
    state["plan_gate_status"] = "approved"
    state["ralphmode_active"] = True
    state["ralphmode_activated_at"] = now
    state["updated_at"] = now
    if current_hash:
        state["last_reviewed_plan_hash"] = current_hash
        state["last_reviewed_plan_at"] = now
        state["plan_review_method"] = "plannotator"
    try:
        save_state(root, state)
    except Exception as exc:
        print(f"[JEO] ⚠️  Failed to save jeo-state.json: {exc}", file=sys.stderr)

    # Write project-local .claude/settings.json with acceptEdits
    project_settings_path = root / ".claude" / "settings.json"
    try:
        project_settings_path.parent.mkdir(parents=True, exist_ok=True)
        ps: dict[str, Any] = {}
        if project_settings_path.exists():
            try:
                ps = json.loads(project_settings_path.read_text(encoding="utf-8"))
            except Exception:
                ps = {}
        previous = ps.get("permissionMode", "default")
        ps["permissionMode"] = RALPHMODE_PERMISSION
        ps["_ralphmode_previous_permission"] = previous
        project_settings_path.write_text(json.dumps(ps, ensure_ascii=False, indent=2), encoding="utf-8")
        print(
            f"[JEO] ✅ Plan approved → ralphmode activated"
            f" (permissionMode: {previous!r} → {RALPHMODE_PERMISSION!r})."
            f" EXECUTE phase ready.",
            file=sys.stderr,
        )
    except Exception as exc:
        print(f"[JEO] ⚠️  ralphmode profile write failed: {exc}", file=sys.stderr)


def deactivate_ralphmode(root: Path) -> None:
    """Revert permissionMode to the previous value saved before ralphmode activation.

    Called by worktree-cleanup.sh (or manually) at CLEANUP phase.
    """
    project_settings_path = root / ".claude" / "settings.json"
    if not project_settings_path.exists():
        return
    try:
        ps = json.loads(project_settings_path.read_text(encoding="utf-8"))
        if ps.get("permissionMode") != RALPHMODE_PERMISSION:
            return  # Already reverted or never set
        previous = ps.pop("_ralphmode_previous_permission", "default")
        if previous == "default":
            ps.pop("permissionMode", None)
        else:
            ps["permissionMode"] = previous
        project_settings_path.write_text(json.dumps(ps, ensure_ascii=False, indent=2), encoding="utf-8")

        # Update jeo-state.json
        state = load_state(root)
        if state:
            state["ralphmode_active"] = False
            state["updated_at"] = datetime.datetime.utcnow().isoformat() + "Z"
            save_state(root, state)

        print(f"[JEO] ralphmode deactivated (permissionMode restored to {previous!r}).", file=sys.stderr)
    except Exception as exc:
        print(f"[JEO] ⚠️  ralphmode deactivation failed: {exc}", file=sys.stderr)


def main() -> int:
    # Support --deactivate flag for use in CLEANUP (worktree-cleanup.sh)
    if "--deactivate" in sys.argv:
        deactivate_ralphmode(git_root())
        return 0

    payload = sys.stdin.read()
    root = git_root()
    state = load_state(root)
    plan_text = find_plan_text(root, payload)
    current_hash = plan_hash(plan_text)

    if should_skip(state, current_hash):
        status = state.get("plan_gate_status", "unknown")
        print(
            f"[JEO][PLAN] Claude hook skipped: plan gate already recorded for current hash ({status}).",
            file=sys.stderr,
        )
        return 0

    reset_for_revised_plan(root, state, current_hash)
    rc = run_plannotator(payload, plan_text)

    if rc == 0:
        # Plan approved — activate ralphmode for the EXECUTE phase
        activate_ralphmode(root, load_state(root), current_hash)

    return rc


if __name__ == "__main__":
    raise SystemExit(main())

```

### scripts/run-opencode-safe.sh

```bash
#!/usr/bin/env bash
# Launch OpenCode with writable runtime directories.
# Useful when default HOME/XDG paths are readonly in sandboxed environments.

set -euo pipefail

if ! command -v opencode >/dev/null 2>&1; then
  echo "opencode CLI not found. Install via: npm install -g opencode-ai" >&2
  exit 1
fi

SESSION_KEY="$(python3 -c "import hashlib,os; print(hashlib.md5(os.getcwd().encode()).hexdigest()[:8])" 2>/dev/null || echo "default")"
RUNTIME_ROOT="/tmp/jeo-opencode-${SESSION_KEY}"

export XDG_DATA_HOME="${RUNTIME_ROOT}/data"
export XDG_STATE_HOME="${RUNTIME_ROOT}/state"
export XDG_CACHE_HOME="${RUNTIME_ROOT}/cache"

mkdir -p "${XDG_DATA_HOME}" "${XDG_STATE_HOME}" "${XDG_CACHE_HOME}"

exec opencode "$@"

```

jeo | SkillHub